From 5afc6e7a0c08e9ee7cfb5183707b942344930919 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Thu, 1 Oct 2026 08:58:57 -0500 Subject: [PATCH 1/2] Fix role-label leakage, pass through Supermemory client options, and port live evals. Conversation turns are stored as [user] and [assistant] blocks so extraction does not treat the next role as a word in the previous message. supermemory() forwards SDK client options and leaves omitted fields to the SDK, including SUPERMEMORY_API_KEY, SUPERMEMORY_BASE_URL, and SUPERMEMORY_LOG. Upstream PR #1 conflicted with the memory-provider rewrite, so the live evals follow the current tools, Eve's session API, and the opaque scope key. --- README.md | 23 ++- biome.json | 2 +- evals/README.md | 36 ++++ evals/capture.eval.ts | 30 +++ evals/evals.config.ts | 3 + evals/extract-file.eval.ts | 29 +++ evals/fixtures/extraction-source.txt | 9 + evals/forget-exact.eval.ts | 29 +++ evals/forget-matching.eval.ts | 52 ++++++ evals/helpers.ts | 268 +++++++++++++++++++++++++++ evals/profile-context.eval.ts | 71 +++++++ evals/remember-routing.eval.ts | 25 +++ evals/remember.eval.ts | 39 ++++ evals/scope-key.ts | 34 ++++ evals/search-read.eval.ts | 46 +++++ package.json | 5 +- src/index.ts | 6 +- src/lib/capture-conversation.ts | 17 +- src/lib/client.ts | 33 ++-- src/lib/conversation-format.ts | 29 +++ src/lib/profile-context.ts | 9 +- src/options.ts | 17 +- src/provider.ts | 4 +- tests/client-options.test.ts | 83 +++++++++ tests/conversation-format.test.ts | 51 +++++ tests/resolve-ts.mjs | 16 ++ tsconfig.build.json | 3 +- tsconfig.json | 2 +- 28 files changed, 926 insertions(+), 45 deletions(-) create mode 100644 evals/README.md create mode 100644 evals/capture.eval.ts create mode 100644 evals/evals.config.ts create mode 100644 evals/extract-file.eval.ts create mode 100644 evals/fixtures/extraction-source.txt create mode 100644 evals/forget-exact.eval.ts create mode 100644 evals/forget-matching.eval.ts create mode 100644 evals/helpers.ts create mode 100644 evals/profile-context.eval.ts create mode 100644 evals/remember-routing.eval.ts create mode 100644 evals/remember.eval.ts create mode 100644 evals/scope-key.ts create mode 100644 evals/search-read.eval.ts create mode 100644 src/lib/conversation-format.ts create mode 100644 tests/client-options.test.ts create mode 100644 tests/conversation-format.test.ts create mode 100644 tests/resolve-ts.mjs diff --git a/README.md b/README.md index 5b5ecd5..5613023 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ npm install @supermemory/eve ``` The provider supports Eve 0.47.3 and newer 0.x releases. Set `SUPERMEMORY_API_KEY` from the -[Supermemory console](https://console.supermemory.ai). +[Supermemory console](https://console.supermemory.ai). For a self-hosted server, set +`SUPERMEMORY_BASE_URL` (for example `http://localhost:6767`). Both are read when the client is +first used. `SUPERMEMORY_LOG` sets the SDK log level. Create a memory slot in the consuming Eve agent. `supermemory(...)` configures the provider; `defineMemory(...)` binds it to an Eve-managed scope. @@ -153,6 +155,22 @@ export default defineMemory({ need a different memory policy. The model cannot change caller identity, container routing, or capture policy at runtime. +`client` is passed to the Supermemory SDK constructor. Leave a field out and the SDK keeps its +default, including `SUPERMEMORY_BASE_URL` and `SUPERMEMORY_LOG`. Set `client.baseURL` when the +URL should come from code instead of the environment. + +```ts +provider: supermemory({ + client: { + baseURL: "http://localhost:6767", + timeout: 20_000, + }, +}), +``` + +`supermemory()` with no options is enough when `SUPERMEMORY_API_KEY` is set in the environment +that runs the agent. + ## Frequently asked questions ### How do I add memory to Eve? @@ -183,8 +201,11 @@ Requires Node.js 24 or newer. ```bash npm install +npm test npm run check npm run typecheck npm run build npm pack --dry-run ``` + +Live agent evals live in `evals/` and run with `eve eval` from a consuming Eve app. See `evals/README.md`. diff --git a/biome.json b/biome.json index 1e1cd0a..6bb6276 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json", "files": { - "includes": ["src/**/*.ts"] + "includes": ["src/**/*.ts", "evals/**/*.ts", "tests/**/*.ts"] }, "formatter": { "enabled": true, diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..7b6ee8a --- /dev/null +++ b/evals/README.md @@ -0,0 +1,36 @@ +# Live Eve evals + +These evals run an Eve agent against Supermemory. Each run uses its own container prefix. Upstream pull request #1 targeted the old extension, so these cases now follow the memory provider. + +`eve eval` discovers `evals/*.eval.ts` next to `agent/`. Add that agent in this repository. The helpers import this package's source, and they derive the container tag from Eve's scope key. + +```ts +// agent/memory/supermemory.ts +import supermemory from "@supermemory/eve"; +import { defineMemory } from "eve/memory"; + +export default defineMemory({ + namespace: process.env.SUPERMEMORY_E2E_NAMESPACE, + description: "Recall and manage durable context for the current user.", + provider: supermemory({ + containerTagPrefix: process.env.SUPERMEMORY_E2E_PREFIX, + }), + scope: process.env.SUPERMEMORY_E2E_SCOPE ?? "e2e", +}); +``` + +Also export `askQuestion()` from `agent/tools/ask_question.ts`. The slot file name must stay `supermemory.ts` so the tools are `supermemory__*`. + +```text +OPENAI_API_KEY +SUPERMEMORY_API_KEY +SUPERMEMORY_E2E_PREFIX +SUPERMEMORY_E2E_NAMESPACE +SUPERMEMORY_E2E_RUN_ID +``` + +`SUPERMEMORY_E2E_SCOPE` defaults to `e2e`. `SUPERMEMORY_BASE_URL` points the helpers at a self-hosted server. Omit it for `https://api.supermemory.ai`. + +```bash +eve eval --max-concurrency 1 --timeout 180000 --verbose +``` diff --git a/evals/capture.eval.ts b/evals/capture.eval.ts new file mode 100644 index 0000000..3dfd34a --- /dev/null +++ b/evals/capture.eval.ts @@ -0,0 +1,30 @@ +import { defineEval } from "eve/evals"; +import { equals, includes } from "eve/evals/expect"; + +import { waitForDocumentContent } from "./helpers.js"; + +export default defineEval({ + async test(t) { + const update = "The staging deployment completed successfully."; + const turn = await t.send(`Acknowledge this update in one short sentence: ${update}`); + turn.expectOk(); + turn.usedNoTools(); + + const customId = `conv_${turn.sessionId}`; + const document = await waitForDocumentContent(customId, update); + const metadata = document?.metadata; + + t.check(document?.customId, equals(customId)); + t.check(document?.content, includes(update)); + t.check(document?.content, includes("[user]")); + t.check( + metadata && typeof metadata === "object" ? Reflect.get(metadata, "source_type") : null, + equals("conversation"), + ); + t.check( + metadata && typeof metadata === "object" ? Reflect.get(metadata, "session_id") : null, + equals(turn.sessionId), + ); + t.succeeded(); + }, +}); diff --git a/evals/evals.config.ts b/evals/evals.config.ts new file mode 100644 index 0000000..3a1a10d --- /dev/null +++ b/evals/evals.config.ts @@ -0,0 +1,3 @@ +import { defineEvalConfig } from "eve/evals"; + +export default defineEvalConfig({}); diff --git a/evals/extract-file.eval.ts b/evals/extract-file.eval.ts new file mode 100644 index 0000000..63088a7 --- /dev/null +++ b/evals/extract-file.eval.ts @@ -0,0 +1,29 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineEval } from "eve/evals"; +import { includes } from "eve/evals/expect"; + +export default defineEval({ + async test(t) { + const fixturePath = resolve( + dirname(fileURLToPath(import.meta.url)), + "fixtures/extraction-source.txt", + ); + const session = await t.session(); + const turn = await session.sendFile( + "Add this migration brief to Supermemory so we can search it in later sessions. Once it is indexed, read it back and confirm when the production freeze begins and who owns the rollback.", + fixturePath, + "text/plain", + ); + + turn.expectOk(); + turn.calledTool("supermemory__extract", { input: { kind: "file" } }); + turn.calledTool("supermemory__read_document", { + input: { container: "agent_extraction" }, + }); + t.check(turn.message, includes("October 14")); + t.check(turn.message, includes("Priya Nair")); + t.succeeded(); + }, +}); diff --git a/evals/fixtures/extraction-source.txt b/evals/fixtures/extraction-source.txt new file mode 100644 index 0000000..f5b66b4 --- /dev/null +++ b/evals/fixtures/extraction-source.txt @@ -0,0 +1,9 @@ +Northstar vendor migration brief + +The production migration freeze begins on October 14 at 6:00 PM Pacific. During +the freeze, the support team should route urgent customer-impacting changes to +the incident channel instead of deploying them directly. + +Priya Nair owns the rollback decision. The database team should preserve the +pre-migration snapshot until Priya confirms that the first integrity check has +passed. diff --git a/evals/forget-exact.eval.ts b/evals/forget-exact.eval.ts new file mode 100644 index 0000000..75ec8b4 --- /dev/null +++ b/evals/forget-exact.eval.ts @@ -0,0 +1,29 @@ +import { defineEval } from "eve/evals"; + +import { createMemories, waitForForgottenMemory } from "./helpers.js"; + +export default defineEval({ + async test(t) { + const fact = "daily standups at 8:30 AM"; + const created = await createMemories([ + { + content: `The current user prefers ${fact}.`, + metadata: { source: "user_preference" }, + }, + ]); + const memory = created.memories[0]; + if (!memory) throw new Error("Direct memory creation returned no memory."); + + const turn = await t.send( + `I no longer want you to remember that I prefer ${fact}. Forget that one preference.`, + ); + turn.expectOk(); + turn.calledTool("supermemory__search", { input: { scope: "memories" } }); + turn.calledTool("supermemory__forget", { + input: { memoryId: memory.id }, + count: 1, + }); + await waitForForgottenMemory(memory.id); + t.succeeded(); + }, +}); diff --git a/evals/forget-matching.eval.ts b/evals/forget-matching.eval.ts new file mode 100644 index 0000000..4072c83 --- /dev/null +++ b/evals/forget-matching.eval.ts @@ -0,0 +1,52 @@ +import { defineEval } from "eve/evals"; + +import { createMemories, waitForForgottenMemory } from "./helpers.js"; + +export default defineEval({ + async test(t) { + const topic = "Juniper relocation"; + const created = await createMemories([ + { + content: `The current user plans to relocate to Portland in October as part of the ${topic}.`, + metadata: { source: "relocation_planning" }, + }, + { + content: `The current user prefers morning apartment tours for the ${topic}.`, + metadata: { source: "relocation_planning" }, + }, + ]); + const ids = created.memories.map((memory) => memory.id); + if (ids.length !== 2) throw new Error("Direct memory creation did not return two memories."); + + const preview = await t.send( + `I'm no longer planning the ${topic}. Forget everything you remember about that relocation, but show me what would be removed before deleting anything.`, + ); + preview.expectOk(); + preview.calledTool("supermemory__forget_matching", { + input: { dryRun: true }, + count: 1, + }); + + const request = preview.session.requireInputRequest({ toolName: "ask_question" }); + const confirm = request.options?.find((option) => + /remove|forget|yes|confirm|approve/i.test(`${option.label} ${option.id}`), + ); + if (!confirm) { + throw new Error("ask_question did not offer a confirm option."); + } + + const finalized = await preview.session.respond([ + { requestId: request.requestId, optionId: confirm.id }, + ]); + finalized.expectOk(); + finalized.calledTool("supermemory__forget_matching", { + input: { + dryRun: false, + ids: (value) => Array.isArray(value) && ids.every((id) => value.includes(id)), + }, + count: 1, + }); + await Promise.all(ids.map((id) => waitForForgottenMemory(id))); + t.succeeded(); + }, +}); diff --git a/evals/helpers.ts b/evals/helpers.ts new file mode 100644 index 0000000..ab7f1d0 --- /dev/null +++ b/evals/helpers.ts @@ -0,0 +1,268 @@ +import { z } from "zod"; + +import { resolveContainerTags } from "../src/lib/container-tags.js"; +import { formatConversationTurn } from "../src/lib/conversation-format.js"; +import { memoryScopeKey } from "./scope-key.js"; + +const API_BASE_URL = ( + process.env.SUPERMEMORY_BASE_URL?.trim() || "https://api.supermemory.ai" +).replace(/\/$/, ""); +const POLL_INTERVAL_MILLISECONDS = 1_000; +const POLL_TIMEOUT_MILLISECONDS = 90_000; + +const memorySchema = z.object({ + id: z.string(), + memory: z.string(), + isStatic: z.boolean().optional(), + isForgotten: z.boolean().optional(), +}); + +const createMemoriesResponseSchema = z.object({ + documentId: z.string().nullable(), + memories: z.array(memorySchema), +}); + +const memoryListResponseSchema = z.object({ + memoryEntries: z.array( + memorySchema.extend({ + createdAt: z.string(), + isLatest: z.boolean(), + }), + ), +}); + +const documentAddResponseSchema = z.object({ + id: z.string(), + status: z.string(), +}); + +export const documentSchema = z.object({ + id: z.string(), + content: z.string().nullable(), + customId: z.string().nullable(), + metadata: z.unknown(), + status: z.enum([ + "unknown", + "queued", + "extracting", + "chunking", + "embedding", + "indexing", + "done", + "failed", + ]), + summary: z.string().nullable(), +}); + +const profileResponseSchema = z.object({ + profile: z.object({ + dynamic: z.array(z.string()), + static: z.array(z.string()), + }), +}); + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required for Supermemory E2E tests.`); + return value; +} + +export const runId = requiredEnvironment("SUPERMEMORY_E2E_RUN_ID"); +export const containerPrefix = requiredEnvironment("SUPERMEMORY_E2E_PREFIX"); +const namespace = requiredEnvironment("SUPERMEMORY_E2E_NAMESPACE"); +const scope = process.env.SUPERMEMORY_E2E_SCOPE?.trim() || "e2e"; + +const tags = (async () => { + const scopeKey = await memoryScopeKey(namespace, scope); + return resolveContainerTags(scopeKey, containerPrefix); +})(); + +function apiKey(): string { + return requiredEnvironment("SUPERMEMORY_API_KEY"); +} + +function headers(): Record { + return { + Authorization: `Bearer ${apiKey()}`, + "Content-Type": "application/json", + }; +} + +async function jsonRequest(path: string, init: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { ...headers(), ...init.headers }, + }); + const text = await response.text(); + + if (!response.ok) { + throw new Error(`${init.method ?? "GET"} ${path} failed (${response.status}): ${text}`); + } + + return text ? JSON.parse(text) : null; +} + +export function marker(name: string): string { + return `${name}_${runId}`.replace(/[^a-zA-Z0-9_.-]/g, "_"); +} + +export function conversationTranscript( + turns: readonly { assistant: string; user: string }[], +): string { + return turns + .map((turn) => { + const content = formatConversationTurn({ + assistantMessage: turn.assistant, + usedSupermemoryTool: false, + userMessage: turn.user, + }); + if (!content) throw new Error("A seeded conversation turn was empty."); + return content; + }) + .join(""); +} + +export async function createMemories( + memories: Array<{ + content: string; + isStatic?: boolean; + metadata?: Record; + }>, +) { + const { context } = await tags; + const response = await jsonRequest("/v4/memories", { + method: "POST", + body: JSON.stringify({ containerTag: context, memories }), + }); + + return createMemoriesResponseSchema.parse(response); +} + +export async function listMemories() { + const { context } = await tags; + const response = await jsonRequest("/v4/memories/list", { + method: "POST", + body: JSON.stringify({ + containerTags: [context], + page: 1, + limit: 100, + sort: "createdAt", + order: "desc", + }), + }); + + return memoryListResponseSchema.parse(response).memoryEntries; +} + +export async function createConversationDocument(input: { + content: string; + customId: string; + sessionId: string; + taskType?: "memory" | "superrag"; +}) { + const { context } = await tags; + const response = await jsonRequest("/v3/documents", { + method: "POST", + body: JSON.stringify({ + containerTag: context, + content: input.content, + customId: input.customId, + entityContext: + "Conversation between a user and an agent. Extract durable facts, preferences, decisions, projects, and ongoing context explicitly stated by the user. Do not infer user information from assistant responses.", + metadata: { + source_id: input.customId, + source_type: "conversation", + session_id: input.sessionId, + source: "eve-agent", + e2e_run: runId, + }, + taskType: input.taskType ?? "memory", + }), + }); + + return documentAddResponseSchema.parse(response); +} + +export async function getDocument(idOrCustomId: string) { + const response = await fetch(`${API_BASE_URL}/v3/documents/${encodeURIComponent(idOrCustomId)}`, { + headers: headers(), + }); + + if (response.status === 404) return null; + const text = await response.text(); + if (!response.ok) { + throw new Error(`GET /v3/documents/${idOrCustomId} failed (${response.status}): ${text}`); + } + + return documentSchema.parse(JSON.parse(text)); +} + +export async function waitForDocument(idOrCustomId: string) { + return waitFor( + () => getDocument(idOrCustomId), + (document) => document?.status === "done", + `document ${idOrCustomId} to finish`, + ); +} + +export async function waitForDocumentContent(idOrCustomId: string, token: string) { + return waitFor( + () => getDocument(idOrCustomId), + (document) => document?.content?.includes(token) === true, + `document ${idOrCustomId} to contain ${token}`, + ); +} + +export async function loadProfile() { + const { context } = await tags; + const response = await jsonRequest("/v4/profile", { + method: "POST", + body: JSON.stringify({ containerTag: context }), + }); + + return profileResponseSchema.parse(response).profile; +} + +export async function waitForProfileFact(fact: string) { + return waitFor( + loadProfile, + (profile) => [...profile.static, ...profile.dynamic].some((entry) => entry.includes(fact)), + `profile to contain ${fact}`, + ); +} + +export async function waitForMemoryFact(fact: string) { + return waitFor( + listMemories, + (memories) => memories.some((entry) => !entry.isForgotten && entry.memory.includes(fact)), + `memory list to contain ${fact}`, + ); +} + +export async function waitForForgottenMemory(memoryId: string) { + return waitFor( + listMemories, + (memories) => { + const memory = memories.find((entry) => entry.id === memoryId); + return memory === undefined || memory.isForgotten === true; + }, + `memory ${memoryId} to be forgotten`, + ); +} + +async function waitFor( + read: () => Promise, + ready: (value: T) => boolean, + label: string, +): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MILLISECONDS; + let latest = await read(); + + while (!ready(latest)) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}.`); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MILLISECONDS)); + latest = await read(); + } + + return latest; +} diff --git a/evals/profile-context.eval.ts b/evals/profile-context.eval.ts new file mode 100644 index 0000000..3d7b637 --- /dev/null +++ b/evals/profile-context.eval.ts @@ -0,0 +1,71 @@ +import { defineEval } from "eve/evals"; +import { includes } from "eve/evals/expect"; + +import { + conversationTranscript, + createConversationDocument, + createMemories, + marker, + waitForDocument, + waitForMemoryFact, + waitForProfileFact, +} from "./helpers.js"; + +export default defineEval({ + async test(t) { + const name = "Maya Chen"; + const preference = "architecture diagrams before implementation details"; + const recentProject = "Project Lantern"; + + await createMemories([ + { + content: `The current user's name is ${name}.`, + isStatic: true, + metadata: { source: "user_profile" }, + }, + { + content: `${name} prefers ${preference}.`, + isStatic: false, + metadata: { source: "communication_preference" }, + }, + { + content: `${name} is currently preparing ${recentProject}'s accessibility launch checklist.`, + isStatic: false, + metadata: { source: "ongoing_project" }, + }, + ]); + const conversation = await createConversationDocument({ + content: conversationTranscript([ + { + user: `I'm preparing the accessibility launch checklist for ${recentProject}.`, + assistant: "Which part should we review first?", + }, + { + user: "Start with keyboard navigation, then check the screen-reader labels on the onboarding flow.", + assistant: "Understood. We'll review keyboard navigation before the onboarding labels.", + }, + ]), + customId: marker("conv_project_lantern"), + sessionId: marker("profile_session"), + taskType: "superrag", + }); + + await Promise.all([ + waitForProfileFact(name), + waitForMemoryFact(preference), + waitForMemoryFact(recentProject), + waitForDocument(conversation.id), + ]); + + const turn = await t.send( + "Don't search or use any tools for this answer. Based only on the context you already have, what do you know about me, how do I prefer technical explanations, and what am I currently working on?", + ); + + turn.expectOk(); + turn.usedNoTools(); + t.check(turn.message, includes(name)); + t.check(turn.message, includes(/architecture diagrams[\s\S]*before implementation details/iu)); + t.check(turn.message, includes(recentProject)); + t.succeeded(); + }, +}); diff --git a/evals/remember-routing.eval.ts b/evals/remember-routing.eval.ts new file mode 100644 index 0000000..24d65e9 --- /dev/null +++ b/evals/remember-routing.eval.ts @@ -0,0 +1,25 @@ +import { defineEval } from "eve/evals"; + +export default defineEval({ + async test(t) { + const correction = await t.send( + "What the hell are you doing? I already asked you to explain the cause before changing files, and you changed them first again. Do you fucking get it?", + ); + correction.expectOk(); + correction.calledTool("supermemory__remember", { + input: { + memory: (value) => + typeof value === "string" && + /explain|cause|diagnos/iu.test(value) && + /before|prior/iu.test(value), + }, + count: 1, + }); + + const transient = await t.session(); + const complaint = await transient.send("This compiler error is fucking annoying."); + complaint.expectOk(); + complaint.notCalledTool("supermemory__remember"); + t.succeeded(); + }, +}); diff --git a/evals/remember.eval.ts b/evals/remember.eval.ts new file mode 100644 index 0000000..2f6329f --- /dev/null +++ b/evals/remember.eval.ts @@ -0,0 +1,39 @@ +import { defineEval } from "eve/evals"; +import { includes } from "eve/evals/expect"; + +import { waitForDocumentContent, waitForMemoryFact } from "./helpers.js"; + +export default defineEval({ + async test(t) { + const preference = "list the failing check before the explanation"; + const turn = await t.send( + `Remember this for future sessions: when you report test failures, ${preference}.`, + ); + + turn.expectOk(); + turn.calledTool("supermemory__remember", { + input: { + memory: (value) => + typeof value === "string" && + /failing check[\s\S]*(?:before|followed by)[\s\S]*(?:explanation|details)/iu.test(value), + }, + count: 1, + }); + await Promise.all([ + waitForDocumentContent(`memory_${turn.sessionId}`, "failing check"), + waitForMemoryFact("failing check"), + ]); + + const fresh = await t.session(); + const recall = await fresh.send( + "Don't search or use any tools. How should you format test failures for me?", + ); + recall.expectOk(); + recall.usedNoTools(); + t.check( + recall.message, + includes(/failing check[\s\S]*(?:before|followed by)[\s\S]*(?:explanation|details)/iu), + ); + t.succeeded(); + }, +}); diff --git a/evals/scope-key.ts b/evals/scope-key.ts new file mode 100644 index 0000000..597fd31 --- /dev/null +++ b/evals/scope-key.ts @@ -0,0 +1,34 @@ +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +type MemoryLockModule = { + createMemoryLock(input: { + namespace: string; + scope: string; + slot: string; + turn: null; + visibility: "scope"; + }): { + scope: { + key: string; + }; + }; +}; + +export async function memoryScopeKey(namespace: string, scope: string): Promise { + const require = createRequire(import.meta.url); + const packageJsonPath = require.resolve("eve/package.json"); + const moduleUrl = pathToFileURL( + packageJsonPath.replace(/package\.json$/, "dist/src/shared/memory-state.js"), + ); + const memoryState = (await import(moduleUrl.href)) as MemoryLockModule; + const lock = memoryState.createMemoryLock({ + namespace, + scope, + slot: "supermemory", + turn: null, + visibility: "scope", + }); + + return lock.scope.key; +} diff --git a/evals/search-read.eval.ts b/evals/search-read.eval.ts new file mode 100644 index 0000000..d95e134 --- /dev/null +++ b/evals/search-read.eval.ts @@ -0,0 +1,46 @@ +import { defineEval } from "eve/evals"; +import { includes } from "eve/evals/expect"; + +import { + conversationTranscript, + createConversationDocument, + marker, + waitForDocument, +} from "./helpers.js"; + +export default defineEval({ + async test(t) { + const topic = "Project Atlas migration"; + const conversation = await createConversationDocument({ + content: conversationTranscript([ + { + user: `For the ${topic}, schedule the database cutover for Friday at 9:00 PM Pacific.`, + assistant: "Friday at 9:00 PM Pacific is recorded for the database cutover.", + }, + { + user: "Priya Nair owns the rollback, and writes should freeze 30 minutes before cutover.", + assistant: + "Understood. Priya owns rollback and the write freeze begins 30 minutes before cutover.", + }, + ]), + customId: marker("conv_project_atlas"), + sessionId: marker("project_atlas_session"), + taskType: "superrag", + }); + await waitForDocument(conversation.id); + + const turn = await t.send( + `What did we decide in our previous session about the ${topic}? Read that session and tell me the cutover time, who owns rollback, and when writes should freeze.`, + ); + + turn.expectOk(); + turn.calledTool("supermemory__search", { + input: { scope: "conversations" }, + }); + turn.calledTool("supermemory__read_session"); + t.check(turn.message, includes(/Friday[\s\S]*9(?::00)?\s*PM/iu)); + t.check(turn.message, includes("Priya Nair")); + t.check(turn.message, includes("30 minutes")); + t.succeeded(); + }, +}); diff --git a/package.json b/package.json index 65b0a23..99e1ee8 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,9 @@ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "prebuild": "npm run clean", "build": "tsc -p tsconfig.build.json", - "check": "biome ci src", - "format": "biome format --write src", + "check": "biome ci src evals tests", + "format": "biome format --write src evals tests", + "test": "node --import ./tests/resolve-ts.mjs --test tests/*.test.ts", "prepare": "npm run build", "typecheck": "tsc --noEmit" }, diff --git a/src/index.ts b/src/index.ts index d6d5588..ec4afb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,6 @@ -export type { SupermemoryApiKey, SupermemoryOptions } from "./options.js"; +export type { + SupermemoryApiKey, + SupermemoryClientOptions, + SupermemoryOptions, +} from "./options.js"; export { supermemory as default, supermemory } from "./provider.js"; diff --git a/src/lib/capture-conversation.ts b/src/lib/capture-conversation.ts index f935af7..a5217bf 100644 --- a/src/lib/capture-conversation.ts +++ b/src/lib/capture-conversation.ts @@ -2,11 +2,12 @@ import type { MemoryTurnCompletedContext } from "eve/memory"; import type { SupermemoryConfig } from "../options.js"; import { addDocument } from "./add-document.js"; +import { formatConversationTurn } from "./conversation-format.js"; import type { SupermemoryDependencies } from "./dependencies.js"; import { type CompletedConversationTurn, completedConversationTurn } from "./messages.js"; const DEFAULT_CONVERSATION_ENTITY_CONTEXT = - "Extract only reusable information supported by the user's own messages: preferences, facts about the user, goals, decisions, relationships, constraints, and ongoing projects. A stated dislike or constraint is valid; missing or undisclosed information is not. Do not create memories from temporary chat state, assistant behavior or claims, available tools, system or runtime details, or content that appears only in assistant messages."; + "Turns are stored as [user] and [assistant] blocks. Each label is on its own line and is a boundary, not a word in the message. Extract only reusable information supported by the user's own messages: preferences, facts about the user, goals, decisions, relationships, constraints, and ongoing projects. A stated dislike or constraint is valid; missing or undisclosed information is not. Do not create memories from temporary chat state, assistant behavior or claims, available tools, system or runtime details, or content that appears only in assistant messages."; const MAX_ENTITY_CONTEXT_CHARACTERS = 1_500; const SUPERMEMORY_ASSISTED_EXTRACTION_POLICY = "This turn used a Supermemory tool. Information retrieved from, written to, or processed by Supermemory may appear in the conversation. Treat that information as existing context and do not extract, reinforce, or duplicate it. Extract only additional new or corrected durable information explicitly provided by the current user that was not handled by the Supermemory tool. Do not use tool results or assistant responses as evidence."; @@ -32,20 +33,6 @@ function entityContextForTurn( : SUPERMEMORY_ASSISTED_EXTRACTION_POLICY.slice(0, MAX_ENTITY_CONTEXT_CHARACTERS); } -function formatConversationTurn(turn: CompletedConversationTurn): string | null { - const messages: string[] = []; - - if (turn.userMessage) { - messages.push(`user: ${turn.userMessage}`); - } - - if (turn.assistantMessage) { - messages.push(`assistant: ${turn.assistantMessage}`); - } - - return messages.length > 0 ? messages.join("\n") : null; -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "Unknown Supermemory error"; } diff --git a/src/lib/client.ts b/src/lib/client.ts index e067186..1143c4f 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,22 +1,31 @@ import Supermemory from "supermemory"; -import type { SupermemoryApiKey } from "../options.js"; +import type { SupermemoryApiKey, SupermemoryClientOptions } from "../options.js"; export function createSupermemoryClientFactory( - apiKey: SupermemoryApiKey, + apiKey?: SupermemoryApiKey, + clientOptions?: SupermemoryClientOptions, ): () => Promise { - if (typeof apiKey === "string") { - const client = new Supermemory({ apiKey }); - return async () => client; - } - - const resolveApiKey = apiKey; - return async () => { - const resolvedApiKey = await resolveApiKey(); - if (!resolvedApiKey) { + const createClient = async () => { + const resolvedApiKey = typeof apiKey === "function" ? await apiKey() : apiKey; + if (apiKey !== undefined && !resolvedApiKey) { throw new Error("The Supermemory API key resolver returned an empty value."); } - return new Supermemory({ apiKey: resolvedApiKey }); + return new Supermemory({ + ...clientOptions, + ...(resolvedApiKey ? { apiKey: resolvedApiKey } : {}), + }); + }; + + if (typeof apiKey === "function") return createClient; + + let pending: Promise | undefined; + return () => { + pending ??= createClient().catch((error: unknown) => { + pending = undefined; + throw error; + }); + return pending; }; } diff --git a/src/lib/conversation-format.ts b/src/lib/conversation-format.ts new file mode 100644 index 0000000..249d6e9 --- /dev/null +++ b/src/lib/conversation-format.ts @@ -0,0 +1,29 @@ +import type { CompletedConversationTurn } from "./messages.js"; + +export const USER_TURN_LABEL = "[user]"; +export const ASSISTANT_TURN_LABEL = "[assistant]"; + +export function formatConversationTurn(turn: CompletedConversationTurn): string | null { + const blocks: string[] = []; + + if (turn.userMessage) { + blocks.push(`${USER_TURN_LABEL}\n${turn.userMessage}`); + } + + if (turn.assistantMessage) { + blocks.push(`${ASSISTANT_TURN_LABEL}\n${turn.assistantMessage}`); + } + + if (blocks.length === 0) return null; + + // The blank line and trailing newline keep the next role label from reading as a word in the previous message. + return `${blocks.join("\n\n")}\n`; +} + +export function countConversationTurns(content?: string): number { + if (!content) return 0; + + const marked = content.match(/(^|\n)\[user\](?=\n|$)/g)?.length ?? 0; + const legacy = content.match(/(^|\n)user:/g)?.length ?? 0; + return marked + legacy; +} diff --git a/src/lib/profile-context.ts b/src/lib/profile-context.ts index 7edf365..7a601d3 100644 --- a/src/lib/profile-context.ts +++ b/src/lib/profile-context.ts @@ -1,6 +1,8 @@ import type Supermemory from "supermemory"; import { z } from "zod"; +import { countConversationTurns } from "./conversation-format.js"; + const PROFILE_ENTRY_LIMIT = 10; const RECENT_CONTEXT_LIMIT = 5; const RECENT_CONVERSATION_LIMIT = 10; @@ -105,11 +107,6 @@ function formattedDate(timestamp: string, timeZone: string): string { }).format(new Date(timestamp)); } -function conversationTurns(content?: string): number { - if (!content) return 0; - return content.match(/(^|\n)user:/g)?.length ?? 0; -} - function conversationSize(content?: string): string { return `${new Intl.NumberFormat("en-US").format(content?.length ?? 0)} chars`; } @@ -201,7 +198,7 @@ export async function loadProfileContext({ sessionsStartedYesterday += 1; } - const turns = conversationTurns(document.content); + const turns = countConversationTurns(document.content); const sessionId = conversationSessionId(document.metadata); const sessionCustomId = document.customId?.trim(); const header = [ diff --git a/src/options.ts b/src/options.ts index 4cb6621..72f3e1f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,9 +1,12 @@ +import type { ClientOptions } from "supermemory"; import { z } from "zod"; const metadataValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]); export type SupermemoryApiKey = string | (() => string | Promise); +export type SupermemoryClientOptions = Omit; + const apiKey = z.union([ z.string().min(1), z.custom<() => string | Promise>((value) => typeof value === "function"), @@ -18,8 +21,14 @@ function isTimeZone(value: string): boolean { } } +const clientOptions = z.custom( + (value) => value !== null && typeof value === "object" && !Array.isArray(value), + "Client options must be an object.", +); + const optionsSchema = z.object({ - apiKey, + apiKey: apiKey.optional(), + client: clientOptions.optional(), containerTagPrefix: z .string() .min(1) @@ -59,7 +68,8 @@ const optionsSchema = z.object({ export type SupermemoryMetadataValue = string | number | boolean | readonly string[]; export interface SupermemoryOptions { - readonly apiKey: SupermemoryApiKey; + readonly apiKey?: SupermemoryApiKey; + readonly client?: SupermemoryClientOptions; readonly containerTagPrefix?: string; readonly autoSearch?: { readonly enabled?: boolean; @@ -77,7 +87,8 @@ export interface SupermemoryOptions { } export interface SupermemoryConfig { - readonly apiKey: SupermemoryApiKey; + readonly apiKey?: SupermemoryApiKey; + readonly client?: SupermemoryClientOptions; readonly containerTagPrefix: string; readonly autoSearch: { readonly enabled: boolean; diff --git a/src/provider.ts b/src/provider.ts index 8d7cc22..0f05a38 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -24,9 +24,9 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "Unknown Supermemory error"; } -export function supermemory(options: SupermemoryOptions): MemoryProvider { +export function supermemory(options: SupermemoryOptions = {}): MemoryProvider { const config = resolveOptions(options); - const getClient = createSupermemoryClientFactory(config.apiKey); + const getClient = createSupermemoryClientFactory(config.apiKey, config.client); const dependencies = (scopeKey: string): SupermemoryDependencies => ({ getClient, containerTags: resolveContainerTags(scopeKey, config.containerTagPrefix), diff --git a/tests/client-options.test.ts b/tests/client-options.test.ts new file mode 100644 index 0000000..2cc17d7 --- /dev/null +++ b/tests/client-options.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createSupermemoryClientFactory } from "../src/lib/client.js"; +import { resolveOptions } from "../src/options.js"; + +const ENV_KEYS = ["SUPERMEMORY_API_KEY", "SUPERMEMORY_BASE_URL", "SUPERMEMORY_LOG"] as const; + +function withSupermemoryEnv( + values: Partial>, + run: () => Promise, +): Promise { + const previous = new Map(); + for (const key of ENV_KEYS) previous.set(key, process.env[key]); + + for (const key of ENV_KEYS) { + const value = values[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + + return run().finally(() => { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); +} + +test("omitted client fields leave the SDK environment defaults in place", async () => { + await withSupermemoryEnv( + { + SUPERMEMORY_API_KEY: "sm_from_env", + SUPERMEMORY_BASE_URL: "http://127.0.0.1:6767", + }, + async () => { + const client = await createSupermemoryClientFactory()(); + + assert.equal(client.apiKey, "sm_from_env"); + assert.equal(client.baseURL, "http://127.0.0.1:6767"); + }, + ); +}); + +test("explicit client options override the SDK environment defaults", async () => { + await withSupermemoryEnv( + { + SUPERMEMORY_API_KEY: "sm_from_env", + SUPERMEMORY_BASE_URL: "http://127.0.0.1:6767", + }, + async () => { + const client = await createSupermemoryClientFactory(() => "sm_from_code", { + baseURL: "http://localhost:9999", + timeout: 1234, + })(); + + assert.equal(client.apiKey, "sm_from_code"); + assert.equal(client.baseURL, "http://localhost:9999"); + assert.equal(client.timeout, 1234); + }, + ); +}); + +test("resolveOptions keeps the full client options object", () => { + const config = resolveOptions({ + client: { + baseURL: "http://localhost:6767", + maxRetries: 0, + timeout: 20_000, + }, + }); + + assert.equal(config.apiKey, undefined); + assert.deepEqual(config.client, { + baseURL: "http://localhost:6767", + maxRetries: 0, + timeout: 20_000, + }); +}); + +test("an empty API key resolver fails before a client is created", async () => { + await assert.rejects(createSupermemoryClientFactory(() => "")(), /empty value/); +}); diff --git a/tests/conversation-format.test.ts b/tests/conversation-format.test.ts new file mode 100644 index 0000000..87c65eb --- /dev/null +++ b/tests/conversation-format.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { countConversationTurns, formatConversationTurn } from "../src/lib/conversation-format.js"; + +test("role labels stay outside the previous message", () => { + const content = formatConversationTurn({ + assistantMessage: "Stored in team memory: “Wyn is the best.”", + usedSupermemoryTool: false, + userMessage: "store in my team memory: Wyn is the best", + }); + + assert.equal( + content, + `[user] +store in my team memory: Wyn is the best + +[assistant] +Stored in team memory: “Wyn is the best.” +`, + ); + assert.equal(content?.includes("best assistant"), false); +}); + +test("user text that mentions a role is kept verbatim", () => { + const content = formatConversationTurn({ + assistantMessage: "Noted.", + usedSupermemoryTool: false, + userMessage: "Call the assistant: Sam.", + }); + + assert.match(content ?? "", /\[user\]\nCall the assistant: Sam\./); +}); + +test("turn counts include the current labels and older user: transcripts", () => { + const current = `[user] +First + +[assistant] +Ok + +[user] +Second +`; + const legacy = "user: First\nassistant: Ok\nuser: Second\n"; + + assert.equal(countConversationTurns(current), 2); + assert.equal(countConversationTurns(legacy), 2); + assert.equal(countConversationTurns(`${legacy}${current}`), 4); + assert.equal(countConversationTurns(undefined), 0); +}); diff --git a/tests/resolve-ts.mjs b/tests/resolve-ts.mjs new file mode 100644 index 0000000..589b3cf --- /dev/null +++ b/tests/resolve-ts.mjs @@ -0,0 +1,16 @@ +import { registerHooks } from "node:module"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + const relative = specifier.startsWith("./") || specifier.startsWith("../"); + if (relative && specifier.endsWith(".js")) { + try { + return nextResolve(`${specifier.slice(0, -3)}.ts`, context); + } catch { + return nextResolve(specifier, context); + } + } + + return nextResolve(specifier, context); + }, +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index d5a42b9..9fa7a0b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,5 +5,6 @@ "noEmit": false, "outDir": "dist", "rootDir": "src" - } + }, + "include": ["src/**/*.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 7b6fdb7..fccd071 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "evals/**/*.ts", "tests/**/*.ts"] } From 0216c803769a92ba65c9abd599c89c77002c6f07 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Thu, 1 Oct 2026 09:58:06 -0500 Subject: [PATCH 2/2] Add a local Supermemory Docker server for development. --- README.md | 18 +++ docker-compose.yml | 36 +++++ docker/Dockerfile | 40 ++++++ evals/README.md | 2 + package.json | 2 + scripts/azure-chat-shim.mjs | 76 +++++++++++ scripts/local-supermemory.mjs | 249 ++++++++++++++++++++++++++++++++++ 7 files changed, 423 insertions(+) create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 scripts/azure-chat-shim.mjs create mode 100644 scripts/local-supermemory.mjs diff --git a/README.md b/README.md index 5613023..1c62ab4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,24 @@ The provider supports Eve 0.47.3 and newer 0.x releases. Set `SUPERMEMORY_API_KE `SUPERMEMORY_BASE_URL` (for example `http://localhost:6767`). Both are read when the client is first used. `SUPERMEMORY_LOG` sets the SDK log level. +## Local server + +```bash +npm run supermemory:up +``` + +That builds official `supermemory-server` 0.0.8, starts it on `http://127.0.0.1:6767`, and writes +`SUPERMEMORY_API_KEY` and `SUPERMEMORY_BASE_URL` to gitignored `.env.local`. `npm run supermemory:down` +stops the container and keeps its data volume. + +Put provider settings in `.env` or `.env.local`. A custom `OPENAI_BASE_URL` makes the server +call `{base}/chat/completions`. For Azure AI Foundry, set that base URL to +`https://.services.ai.azure.com/openai/v1` and `OPENAI_MODEL` to the deployment name. +Foundry chat models reject `max_tokens`, so an Azure base URL also starts a small rewrite proxy. +A trailing `/responses` on the base URL is removed. Embeddings stay on the local model. For +Ollama on this machine, set `OPENAI_BASE_URL=http://host.docker.internal:11434/v1` and any +non-empty `OPENAI_API_KEY`. + Create a memory slot in the consuming Eve agent. `supermemory(...)` configures the provider; `defineMemory(...)` binds it to an Eve-managed scope. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4996be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + # Foundry chat deployments reject max_tokens. This rewrites that field and + # forwards to OPENAI_BASE_URL. Started only with the azure compose profile. + azure-shim: + profiles: ["azure"] + image: node:24-bookworm-slim + command: ["node", "/shim/azure-chat-shim.mjs"] + environment: + PORT: "8080" + AZURE_UPSTREAM: ${OPENAI_BASE_URL} + volumes: + - ./scripts/azure-chat-shim.mjs:/shim/azure-chat-shim.mjs:ro + + supermemory: + build: + context: ./docker + image: eve-supermemory-local:0.0.8 + ports: + - "127.0.0.1:6767:6767" + environment: + PORT: "6767" + SUPERMEMORY_DATA_DIR: /data + SUPERMEMORY_EMBEDDING_PROVIDER: local + SUPERMEMORY_DISABLE_TELEMETRY: "1" + env_file: + - path: .env + required: false + - path: .env.local + required: false + - path: .env.supermemory + required: false + volumes: + - supermemory-data:/data + +volumes: + supermemory-data: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..1c836bd --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,40 @@ +# Official self-hosted server. There is no upstream image; the release is one binary. +# https://github.com/supermemoryai/supermemory/releases/tag/server-v0.0.8 +FROM debian:bookworm-slim + +ARG SUPERMEMORY_VERSION=0.0.8 +ARG TARGETARCH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/supermemory + +RUN set -eu; \ + case "$TARGETARCH" in \ + amd64) PLATFORM=linux-x64 ;; \ + arm64) PLATFORM=linux-arm64 ;; \ + *) echo "unsupported architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + BASE="https://github.com/supermemoryai/supermemory/releases/download/server-v${SUPERMEMORY_VERSION}"; \ + curl -fsSL "$BASE/manifest.json" -o /tmp/manifest.json; \ + curl -fsSL "$BASE/supermemory-server-${PLATFORM}" -o /opt/supermemory/supermemory-server; \ + EXPECTED="$(grep -A2 "\"${PLATFORM}\"" /tmp/manifest.json | grep -oE '[a-f0-9]{64}' | head -n 1)"; \ + test -n "$EXPECTED"; \ + echo "${EXPECTED} /opt/supermemory/supermemory-server" | sha256sum -c -; \ + chmod 755 /opt/supermemory/supermemory-server; \ + rm /tmp/manifest.json + +ENV PORT=6767 \ + SUPERMEMORY_DATA_DIR=/data \ + SUPERMEMORY_EMBEDDING_PROVIDER=local \ + SUPERMEMORY_DISABLE_TELEMETRY=1 + +EXPOSE 6767 +VOLUME ["/data"] + +HEALTHCHECK --interval=5s --timeout=3s --start-period=120s --retries=30 \ + CMD curl -sS -o /dev/null http://127.0.0.1:6767/ || exit 1 + +ENTRYPOINT ["/opt/supermemory/supermemory-server"] diff --git a/evals/README.md b/evals/README.md index 7b6ee8a..a6b2422 100644 --- a/evals/README.md +++ b/evals/README.md @@ -31,6 +31,8 @@ SUPERMEMORY_E2E_RUN_ID `SUPERMEMORY_E2E_SCOPE` defaults to `e2e`. `SUPERMEMORY_BASE_URL` points the helpers at a self-hosted server. Omit it for `https://api.supermemory.ai`. +`npm run supermemory:up` writes `SUPERMEMORY_API_KEY` and `SUPERMEMORY_BASE_URL=http://127.0.0.1:6767` into `.env.local`. The server and the Eve agent each need a model key. One `OPENAI_API_KEY` covers both. + ```bash eve eval --max-concurrency 1 --timeout 180000 --verbose ``` diff --git a/package.json b/package.json index 99e1ee8..23fe700 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "check": "biome ci src evals tests", "format": "biome format --write src evals tests", "test": "node --import ./tests/resolve-ts.mjs --test tests/*.test.ts", + "supermemory:up": "node scripts/local-supermemory.mjs up", + "supermemory:down": "node scripts/local-supermemory.mjs down", "prepare": "npm run build", "typecheck": "tsc --noEmit" }, diff --git a/scripts/azure-chat-shim.mjs b/scripts/azure-chat-shim.mjs new file mode 100644 index 0000000..24951b2 --- /dev/null +++ b/scripts/azure-chat-shim.mjs @@ -0,0 +1,76 @@ +import { createServer } from "node:http"; +import { Readable } from "node:stream"; + +const port = Number(process.env.PORT || 8080); +const upstream = normalize(process.env.AZURE_UPSTREAM || ""); +if (!upstream) { + console.error("AZURE_UPSTREAM is required"); + process.exit(1); +} + +// Foundry chat models reject max_tokens. The server's OpenAI-compatible client sends it. +function rewrite(body, contentType) { + if (!body.length || !contentType.includes("json")) return body; + try { + const json = JSON.parse(body.toString("utf8")); + if (!json || typeof json !== "object" || Array.isArray(json)) return body; + if (json.max_tokens != null && json.max_completion_tokens == null) { + json.max_completion_tokens = json.max_tokens; + delete json.max_tokens; + return Buffer.from(JSON.stringify(json)); + } + } catch { + return body; + } + return body; +} + +function normalize(value) { + let url; + try { + url = new URL(value); + } catch { + return ""; + } + let path = url.pathname.replace(/\/+$/, ""); + if (path.endsWith("/responses")) path = path.slice(0, -"/responses".length); + url.pathname = path || "/"; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + +const hop = new Set(["host", "connection", "content-length", "transfer-encoding"]); + +createServer(async (req, res) => { + try { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = rewrite(Buffer.concat(chunks), String(req.headers["content-type"] || "")); + const incoming = new URL(req.url || "/", "http://shim"); + const suffix = incoming.pathname.replace(/^\/v1(?=\/|$)/, "") + incoming.search; + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value == null || hop.has(key.toLowerCase())) continue; + headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + const upstreamResponse = await fetch(upstream + suffix, { + method: req.method, + headers, + body: req.method === "GET" || req.method === "HEAD" ? undefined : body, + }); + const responseHeaders = {}; + upstreamResponse.headers.forEach((value, key) => { + if (!hop.has(key)) responseHeaders[key] = value; + }); + res.writeHead(upstreamResponse.status, responseHeaders); + if (!upstreamResponse.body || req.method === "HEAD") { + res.end(); + return; + } + Readable.fromWeb(upstreamResponse.body).pipe(res); + } catch (error) { + res.writeHead(502, { "content-type": "text/plain" }); + res.end(error instanceof Error ? error.message : String(error)); + } +}).listen(port, "0.0.0.0"); diff --git a/scripts/local-supermemory.mjs b/scripts/local-supermemory.mjs new file mode 100644 index 0000000..c51636d --- /dev/null +++ b/scripts/local-supermemory.mjs @@ -0,0 +1,249 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import Supermemory from "supermemory"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const envFile = join(root, ".env.local"); +const dotenvFile = join(root, ".env"); +const overrideFile = join(root, ".env.supermemory"); +const baseURL = "http://127.0.0.1:6767"; +const forwardedKeys = [ + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_MODEL", + "OPENAI_FAST_MODEL", + "OPENAI_TEXT_MODEL", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GROQ_API_KEY", +]; + +const command = process.argv[2] ?? "up"; +const azureShimURL = "http://azure-shim:8080/v1"; +let composePrefix = []; + +function compose(args, options = {}) { + return execFileSync("docker", ["compose", ...composePrefix, ...args], { + cwd: root, + encoding: "utf8", + stdio: options.silent ? ["ignore", "pipe", "pipe"] : "inherit", + }); +} + +function redact(text) { + let redacted = text + .replace(/\bsm_[A-Za-z0-9_-]+\b/g, "sm_…") + .replace(/\bsk-[A-Za-z0-9_-]+\b/g, "sk-…"); + for (const path of [dotenvFile, envFile, overrideFile]) { + for (const [key, value] of readEnv(path)) { + if (/KEY|TOKEN|SECRET/i.test(key) && value.length > 8) { + redacted = redacted.split(value).join(`${key}=…`); + } + } + } + return redacted; +} + +// A custom base URL makes the server use its OpenAI-compatible client, which +// posts to `{base}/chat/completions`. Portal samples often include `/responses`. +function normalizeOpenAIBaseURL(value) { + let url; + try { + url = new URL(value); + } catch { + return value.trim().replace(/\/+$/, ""); + } + let path = url.pathname.replace(/\/+$/, ""); + if (path.endsWith("/responses")) path = path.slice(0, -"/responses".length); + url.pathname = path || "/"; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + +function fail(message) { + let logs = ""; + try { + logs = compose(["logs", "--tail", "80", "supermemory"], { silent: true }); + } catch { + logs = ""; + } + console.error(`FAILED: ${message}`); + if (logs.trim()) console.error(redact(logs).trim()); + process.exit(1); +} + +function readEnv(path) { + if (!existsSync(path)) return new Map(); + const values = new Map(); + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + values.set(trimmed.slice(0, eq), trimmed.slice(eq + 1)); + } + return values; +} + +function writeEnv(path, values) { + const lines = [...values.entries()].map(([key, value]) => `${key}=${value}`); + writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 }); +} + +function scrapeApiKey(logs) { + return logs.match(/\bsm_[A-Za-z0-9_-]+\b/)?.[0] ?? ""; +} + +async function waitForServer() { + const deadline = Date.now() + 8 * 60 * 1000; + while (Date.now() < deadline) { + let state = ""; + try { + state = compose(["ps", "-a", "--format", "{{.Service}} {{.State}}"], { silent: true }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + const line = state + .split("\n") + .map((entry) => entry.trim()) + .find((entry) => entry.startsWith("supermemory ")); + if (line && /\b(exited|dead|removing)\b/.test(line)) { + fail(`container stopped (${line})`); + } + try { + await fetch(baseURL, { signal: AbortSignal.timeout(2000) }); + return; + } catch { + await delay(2000); + } + } + fail("timed out waiting for http://127.0.0.1:6767"); +} + +async function smoke(apiKey) { + process.env.SUPERMEMORY_API_KEY = apiKey; + process.env.SUPERMEMORY_BASE_URL = baseURL; + const client = new Supermemory(); + const marker = `local-docker-${Date.now()}`; + const added = await client.documents.add({ + content: `Local docker smoke marker ${marker}.`, + containerTag: "eve_local_docker", + }); + const document = await client.documents.get(added.id); + if (!document.content?.includes(marker)) { + throw new Error(`stored document ${added.id} did not contain the smoke marker`); + } + + const deadline = Date.now() + 180_000; + let lastStatus = document.status; + while (Date.now() < deadline) { + const current = await client.documents.get(added.id); + lastStatus = current.status; + if (current.status === "failed") break; + const found = await client.search.documents({ + q: marker, + containerTags: ["eve_local_docker"], + }); + if (found.results.some((result) => result.documentId === added.id)) { + return { id: added.id, status: current.status, searchable: true }; + } + if (current.status === "done") break; + await delay(2000); + } + return { id: added.id, status: lastStatus, searchable: false }; +} + +function providerEnv() { + const values = readEnv(dotenvFile); + for (const [key, value] of readEnv(envFile)) values.set(key, value); + for (const key of forwardedKeys) { + const value = process.env[key]?.trim(); + if (value) values.set(key, value); + } + return values; +} + +async function up() { + const provider = providerEnv(); + const rawBase = provider.get("OPENAI_BASE_URL")?.trim() ?? ""; + const normalized = rawBase ? normalizeOpenAIBaseURL(rawBase) : ""; + const azure = /services\.ai\.azure\.com|openai\.azure\.com/i.test(normalized); + const overrides = new Map(); + if (azure) overrides.set("OPENAI_BASE_URL", azureShimURL); + else if (normalized && normalized !== rawBase.replace(/\/+$/, "")) { + overrides.set("OPENAI_BASE_URL", normalized); + } + const model = provider.get("OPENAI_MODEL")?.trim(); + if (provider.get("OPENAI_API_KEY") && azure && !model) { + fail( + "Azure Foundry needs OPENAI_MODEL set to a deployment name. The server default gpt-5.1 is not a deployment.", + ); + } + composePrefix = azure ? ["--profile", "azure"] : []; + if (overrides.size > 0) writeEnv(overrideFile, overrides); + else if (existsSync(overrideFile)) unlinkSync(overrideFile); + + const values = readEnv(envFile); + let changed = false; + for (const key of forwardedKeys) { + const value = process.env[key]?.trim(); + if (value && values.get(key) !== value) { + values.set(key, value); + changed = true; + } + } + if (changed) writeEnv(envFile, values); + + try { + compose(["up", "-d", "--build"]); + } catch { + fail("docker compose up failed"); + } + + await waitForServer(); + + let logs = ""; + try { + logs = compose(["logs", "supermemory"], { silent: true }); + } catch { + logs = ""; + } + const apiKey = scrapeApiKey(logs) || values.get("SUPERMEMORY_API_KEY") || ""; + if (!apiKey) fail("server did not print an sm_ API key"); + + values.set("SUPERMEMORY_API_KEY", apiKey); + values.set("SUPERMEMORY_BASE_URL", baseURL); + writeEnv(envFile, values); + const tail = apiKey.slice(-4); + + let result; + try { + result = await smoke(apiKey); + } catch (error) { + fail(`API smoke failed: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!result.searchable) { + console.log( + `READY: server is up and document ${result.id} round-tripped, status ${result.status}. Search did not find it. Check docker compose logs supermemory. Key saved as sm_…${tail}.`, + ); + return; + } + + console.log( + `READY: http://127.0.0.1:6767 stored and searched document ${result.id}. Key saved as sm_…${tail} in .env.local.`, + ); +} + +if (command === "down") { + compose(["down"]); +} else if (command === "up") { + await up(); +} else { + console.error("usage: node scripts/local-supermemory.mjs [up|down]"); + process.exit(1); +}