From 51221a3239b5b5ce9efd4263b0bc93d5246e36d1 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 1 Sep 2026 12:57:37 -0400 Subject: [PATCH] feat(sdk): ship the integration layer, so memory is automatic instead of remembered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This SDK has 14 resources and ~40 memory methods. It is a very complete API client and it has no integration layer at all — no OpenAI/Anthropic/AI-SDK wrapper, no per-turn hook. The examples are demos, not integrations. That means every customer writes the same forty lines of glue (pull a query out of the conversation, call search, decide how much fits, prepend a system message, remember to write the turn back), and memory quality varies with how well each of them wrote it. The most common version of those forty lines is none, which is how a real deployment reaches 182k memories against 98 subjects. Handing the hard part to the caller is what makes memory a feature people have to remember to use rather than something that just works. const openai = withMemory(new OpenAI(), mm, { subject, sessionId }) Every existing call site is unchanged and now retrieves, injects within a token budget, runs, and writes the turn back. Anthropic gets the same treatment (context goes on `system`, since a {role:'system'} entry in `messages` is rejected by that API), the Vercel AI SDK gets a wrapLanguageModel middleware, and MemoryMiddleware is the provider-agnostic core for anything else. Client detection is structural, not instanceof — depending on three provider packages to support three provider packages is how an integration layer becomes unusable to everyone on the fourth. IT CANNOT BREAK THE HOST'S TURN. Every memory call is wrapped: an outage, timeout, 5xx or malformed response degrades to "no context this turn" and the model runs exactly as it would have without any of this. Failures surface through onError, never as a throw on the caller's request path. That is the contract, not defensive coding — the moment adding memory can take an app down, nobody leaves it on. Two things the wrapper deliberately does not pretend to do. Injected context lands AFTER the caller's own system prompt, because their system prompt is their product and memory is context, not policy. And a streaming call captures only the user half, because the assistant text does not exist when the call returns and consuming the stream to get it would break the caller's — the README says so and shows the tee. Also here: - context.forConversation(), the client for the new POST /memory/context. The subject-keyed build() needs you to know WHO before you can ask what we know; this answers "what should the model know before it replies to this". - admin.merge(), dry-run by default — the measurement that gates whether cluster merging is safe to enable server-side. - Corrected two docstrings that made false claims. `memory.delete` is PROJECT-scoped, not "one of your own memory items": your API key can delete any memory in its project, which is right for a backend and wrong behind an end user's "forget this" button. And `subject` was documented as "not needed with text (the engine resolves subjects during extraction)", which it did not do. Requires the server-side POST /memory/context and /admin/memory/merge endpoints; do not publish ahead of those. Minor version bump: new public surface, nothing removed. --- README.md | 92 ++++++++++ package.json | 7 +- src/index.ts | 19 ++ src/integrations/index.ts | 158 +++++++++++++++++ src/integrations/memory-middleware.ts | 203 +++++++++++++++++++++ src/integrations/messages.ts | 60 +++++++ src/integrations/proxy.ts | 56 ++++++ src/resources/context.ts | 79 +++++++++ src/resources/memory.ts | 39 +++- src/types/memory.ts | 16 +- test/integrations.test.ts | 244 ++++++++++++++++++++++++++ 11 files changed, 966 insertions(+), 7 deletions(-) create mode 100644 src/integrations/index.ts create mode 100644 src/integrations/memory-middleware.ts create mode 100644 src/integrations/messages.ts create mode 100644 src/integrations/proxy.ts create mode 100644 test/integrations.test.ts diff --git a/README.md b/README.md index 1eb8b28..66b1d5c 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,98 @@ for (const b of page.data) console.log(b.externalId, b.status) --- +## Automatic memory (integrations) + +Everything under `Resources` is the API. This section is the part that makes +memory *automatic* — capture and injection on every turn, without the model +having to decide to call a tool and without you writing the glue. + +### Three lines + +```ts +import OpenAI from 'openai' +import { withMemory } from '@memmesh/sdk' + +// `mm` is the MemMesh client from Quick start above. +const openai = withMemory(new OpenAI(), mm, { + subject: { kind: 'user', externalId: currentUser.id }, + sessionId: conversationId, +}) +``` + +Every existing `openai.chat.completions.create(...)` call site is unchanged and +now: retrieves relevant memory, injects it within a token budget, runs, and +writes the turn back. `withMemory` also accepts an Anthropic client. + +### What it does per turn + +1. **Retrieve** — `context.forConversation()` derives the query from the recent + turns (plus `intent` if you pass one), ranks, and fits the result to + `tokenBudget` (default 1200). +2. **Inject** — as a system message placed *after* your own system prompt. + Your system prompt is your product; memory is context, not policy. +3. **Capture** — the user turn and the assistant reply go back via `observe()`, + so the store holds what was asked *and* what they were told. + +### It cannot break your app + +Every memory call is wrapped. An outage, timeout, 5xx or malformed response +degrades that turn to "no context" and the model runs exactly as it would have +without any of this. Failures surface through `onError`, never as a thrown +exception on your request path. + +```ts +withMemory(client, mm, { + onError: (err, phase) => logger.warn({ err, phase }, 'memmesh degraded'), +}) +``` + +### Vercel AI SDK + +```ts +import { wrapLanguageModel } from 'ai' +import { memoryMiddleware } from '@memmesh/sdk' + +const model = wrapLanguageModel({ + model: openai('gpt-4o'), + middleware: memoryMiddleware(mm, { subject }), +}) +``` + +`transformParams` is an injection point, not a completion point, so this +captures the **user** half of each turn. To capture the assistant half, call +`mw.capture()` from your own `onFinish`. + +### Streaming + +Injection works normally. The assistant half is **not** captured, because the +text does not exist when the call returns and consuming the stream to get it +would break yours. Capture it yourself once the stream completes: + +```ts +const mw = new MemoryMiddleware(mm, { subject }) +const messages = await mw.withContext(yourMessages) +const stream = await openai.chat.completions.create({ messages, stream: true }) +// ...after you have the full text: +await mw.capture(yourMessages, fullText) +``` + +### Anything else + +`MemoryMiddleware` is the provider-agnostic core — `withContext(messages)`, +`capture(messages, replyText)`, `context(messages, intent)`. Use it directly +for any client the adapters do not cover. + +### Options worth knowing + +| Option | Default | Why you would change it | +| --- | --- | --- | +| `subject` | inferred server-side | Pass it. Without it the server guesses from the turn, which is usually right and is still a guess. A declared subject makes the memory reachable by prediction and profiling. | +| `tokenBudget` | `1200` | Raise it if bundles report `droppedForBudget > 0`. | +| `awaitCapture` | `true` | Set `false` only if your runtime survives a floating promise. A serverless function that freezes on response return will silently drop every write. | +| `excludeCategories` | none | Withhold categories (medical, HR) from a surface that has no business seeing them. | +| `brainId` | none | Required when the conversation consumes a published brain. | + ## Predict anything (v2) Don't pick a model. Declare *what* to predict — a churn event, a next-order diff --git a/package.json b/package.json index 7f64695..a6e2ecb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@memmesh/sdk", - "version": "0.9.0", - "description": "TypeScript SDK for app.memmesh.ai — admin + project memory CRUD, semantic search, feedback, and Lattice behavioral patterns", + "version": "0.10.0", + "description": "TypeScript SDK for app.memmesh.ai \u2014 admin + project memory CRUD, semantic search, feedback, and Lattice behavioral patterns", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -32,7 +32,8 @@ "seed": "tsx scripts/seed-memory.ts", "seed:lattice": "tsx scripts/seed-lattice.ts", "demo:financial": "tsx examples/financial-demo.ts", - "demo:nbo": "tsx examples/next-best-offer.ts" + "demo:nbo": "tsx examples/next-best-offer.ts", + "test:integrations": "tsx test/integrations.test.ts" }, "devDependencies": { "@anthropic-ai/sdk": "^0.109.1", diff --git a/src/index.ts b/src/index.ts index 43aafa3..f771ab9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -264,3 +264,22 @@ export type { TraverseParams, EntityWithEdges, } from './types/graph.js' + +// Integrations — the layer that makes memory automatic rather than something +// each caller has to remember to call. See src/integrations/. +export { + withMemory, + withOpenAI, + withAnthropic, + memoryMiddleware, + MemoryMiddleware, + normalizeMessages, + lastHumanTurn, + type MemoryMiddlewareOptions, +} from './integrations/index.js' + +export type { + ConversationTurn, + ConversationContextRequest, + ConversationContextBundle, +} from './resources/context.js' diff --git a/src/integrations/index.ts b/src/integrations/index.ts new file mode 100644 index 0000000..9ed7afb --- /dev/null +++ b/src/integrations/index.ts @@ -0,0 +1,158 @@ +import type { ThinkFleetMemory } from '../client.js' +import { MemoryMiddleware, type MemoryMiddlewareOptions } from './memory-middleware.js' +import { hasMethodAt, proxyPath } from './proxy.js' + +export { MemoryMiddleware, type MemoryMiddlewareOptions } from './memory-middleware.js' +export { normalizeMessages, lastHumanTurn } from './messages.js' + +const OPENAI_PATH = ['chat', 'completions', 'create'] +const ANTHROPIC_PATH = ['messages', 'create'] + +/** Pull the assistant's text out of an OpenAI chat completion. */ +function openAiText(result: unknown): string | undefined { + const choices = (result as { choices?: Array<{ message?: { content?: unknown } }> })?.choices + const content = choices?.[0]?.message?.content + return typeof content === 'string' && content.length > 0 ? content : undefined +} + +/** Pull the assistant's text out of an Anthropic message. */ +function anthropicText(result: unknown): string | undefined { + const content = (result as { content?: Array<{ type?: string, text?: string }> })?.content + if (!Array.isArray(content)) return undefined + const text = content + .filter((p) => p?.type === 'text' && typeof p.text === 'string') + .map((p) => p.text as string) + .join('\n') + .trim() + return text.length > 0 ? text : undefined +} + +/** + * A streaming call returns an async iterator, not a message. Injection still + * works (it happens before the call); capturing the assistant half does not, + * because the text does not exist yet and consuming the stream to get it + * would break the caller's stream. + * + * So streaming turns capture the USER half only, and say so here rather than + * appearing to work. Capturing the assistant half of a stream needs a tee, + * which belongs to the caller's code, not to a proxy. + */ +function isStreaming(params: unknown): boolean { + return (params as { stream?: unknown })?.stream === true +} + +/** + * Wrap an OpenAI client so every `chat.completions.create` retrieves context + * first and writes the turn back after. + * + * @example + * ```ts + * const openai = withOpenAI(new OpenAI(), mm, { subject: { kind: 'user', externalId: userId } }) + * // ...every existing call site is unchanged, and now has memory. + * ``` + */ +export function withOpenAI( + client: T, + mm: ThinkFleetMemory, + options: MemoryMiddlewareOptions = {}, +): T { + const mw = new MemoryMiddleware(mm, options) + return proxyPath(client, OPENAI_PATH, (create) => async (params: any, ...rest: any[]) => { + const messages = Array.isArray(params?.messages) ? params.messages : [] + const prepared = await mw.withContext(messages) + const result = await create({ ...params, messages: prepared }, ...rest) + await mw.capture(messages, isStreaming(params) ? undefined : openAiText(result)) + return result + }) +} + +/** + * Wrap an Anthropic client so every `messages.create` retrieves context first + * and writes the turn back after. + * + * Anthropic carries the system prompt in its own top-level `system` field + * rather than as a message, so the context block is appended THERE — a + * `{role:'system'}` entry in `messages` is rejected by the API. + */ +export function withAnthropic( + client: T, + mm: ThinkFleetMemory, + options: MemoryMiddlewareOptions = {}, +): T { + const mw = new MemoryMiddleware(mm, options) + return proxyPath(client, ANTHROPIC_PATH, (create) => async (params: any, ...rest: any[]) => { + const messages = Array.isArray(params?.messages) ? params.messages : [] + const bundle = await mw.context(messages) + + let next = params + if (bundle != null) { + const block = mw.block(bundle) + // Append after the caller's own system prompt, never before it: their + // system prompt is their product, and memory is context, not policy. + const system = typeof params?.system === 'string' && params.system.length > 0 + ? `${params.system}\n\n${block}` + : block + next = { ...params, system } + } + + const result = await create(next, ...rest) + await mw.capture(messages, isStreaming(params) ? undefined : anthropicText(result)) + return result + }) +} + +/** + * Middleware for the Vercel AI SDK's `wrapLanguageModel`. + * + * @example + * ```ts + * const model = wrapLanguageModel({ + * model: openai('gpt-4o'), + * middleware: memoryMiddleware(mm, { subject }), + * }) + * ``` + * + * Implemented as `transformParams` only. The AI SDK calls it before every + * generate AND every stream, which is exactly the injection point; it is not + * a capture point, so capture rides the same hook off the incoming prompt + * (the user half is fully present there). The assistant half needs `onFinish` + * in the caller's own `streamText`/`generateText` call, which is where the + * completed text actually lands — see the README. + */ +export function memoryMiddleware( + mm: ThinkFleetMemory, + options: MemoryMiddlewareOptions = {}, +): { transformParams: (opts: { params: any }) => Promise } { + const mw = new MemoryMiddleware(mm, options) + return { + async transformParams({ params }: { params: any }) { + const prompt = Array.isArray(params?.prompt) ? params.prompt : [] + const prepared = await mw.withContext(prompt) + await mw.capture(prompt) + return { ...params, prompt: prepared } + }, + } +} + +/** + * Detect which client this is and wrap it. + * + * Shape detection rather than `instanceof`, so the SDK takes no dependency on + * any provider package — a hard dependency on three provider SDKs to support + * three provider SDKs is how an integration layer becomes unusable to + * everyone using the fourth. + */ +export function withMemory( + client: T, + mm: ThinkFleetMemory, + options: MemoryMiddlewareOptions = {}, +): T { + if (hasMethodAt(client, OPENAI_PATH)) return withOpenAI(client, mm, options) + if (hasMethodAt(client, ANTHROPIC_PATH)) return withAnthropic(client, mm, options) + throw new Error( + 'withMemory: unrecognised client. Expected an OpenAI-shaped client ' + + '(chat.completions.create) or an Anthropic-shaped one (messages.create). ' + + 'For the Vercel AI SDK use memoryMiddleware() with wrapLanguageModel(); ' + + 'for anything else use the MemoryMiddleware class directly.', + ) +} diff --git a/src/integrations/memory-middleware.ts b/src/integrations/memory-middleware.ts new file mode 100644 index 0000000..a5b853c --- /dev/null +++ b/src/integrations/memory-middleware.ts @@ -0,0 +1,203 @@ +import type { ThinkFleetMemory } from '../client.js' +import type { ConversationContextBundle, ConversationTurn } from '../resources/context.js' +import { lastHumanTurn, normalizeMessages } from './messages.js' + +/** + * The provider-agnostic half of the integration layer. + * + * WHY THIS EXISTS + * + * Before it, every integrator wrote the same forty lines: pull a query out of + * the conversation, call search, decide how much fits, prepend a system + * message, and remember to write the turn back. Memory quality therefore + * varied with how well each of them wrote those forty lines, and the most + * common version of them was "nothing" — which is why a real deployment can + * hold 182k memories and 98 subjects. An SDK that only exposes the API has + * handed the hard part to the caller. + * + * WHAT IT GUARANTEES + * + * Memory NEVER breaks the host's turn. Every call in here is wrapped: a + * memory outage, a timeout, a 500, a malformed response all degrade to "no + * context this turn" and the model runs exactly as it would have without any + * of this. That is not defensive coding, it is the contract — the moment + * adding memory can take an app down, nobody leaves it switched on. + */ + +export interface MemoryMiddlewareOptions { + /** + * WHO the conversation is about. Strongly recommended: without it the + * server infers a subject from the turn, which is right most of the time + * and is still a guess. Passing it makes every memory this writes reachable + * by prediction, profiling and cohorting. + */ + subject?: { kind: string, externalId: string } + /** Groups turns into one session for later session-level extraction. */ + sessionId?: string + /** Who is SPEAKING (distinct from `subject`, who it is ABOUT). */ + userId?: string + agentId?: string + /** Max tokens of memory injected per turn. Default 1200. */ + tokenBudget?: number + /** Candidate pool before budgeting. Default 25. */ + limit?: number + /** Withhold these categories from retrieval on this surface. */ + excludeCategories?: string[] + /** Required when the conversation is consuming a published brain. */ + brainId?: string + /** Inject retrieved context into the prompt. Default true. */ + inject?: boolean + /** Write the conversation back to memory. Default true. */ + capture?: boolean + /** + * Await the capture write before returning the model's response. + * + * Default TRUE, which costs the turn one round trip. Set false only if you + * are certain your runtime survives a floating promise — a serverless + * function that freezes on response return will silently drop every write, + * which looks exactly like memory not working and is invisible in logs. + */ + awaitCapture?: boolean + /** + * How the context block is worded when injected. The default names memory + * as the source, which measurably matters: a model handed unattributed + * facts treats them as its own assumptions and will defend them, where one + * told they came from a memory store will say "your saved preference is X, + * is that still right?" — which is the behaviour you want when a stored + * fact has gone stale. + */ + systemPreamble?: string + /** Called on any memory-side failure. Default: console.warn once per phase. */ + onError?: (error: unknown, phase: 'context' | 'capture') => void +} + +const DEFAULT_PREAMBLE = + 'The following facts come from the user\'s persistent memory store. ' + + 'Treat them as background you already know, and say so if you rely on one. ' + + 'If something here conflicts with what the user says now, the user is right — ' + + 'prefer the live turn and note the discrepancy.' + +export class MemoryMiddleware { + private warned = new Set() + + constructor( + private readonly mm: ThinkFleetMemory, + private readonly options: MemoryMiddlewareOptions = {}, + ) {} + + private fail(error: unknown, phase: 'context' | 'capture'): void { + if (this.options.onError) { + this.options.onError(error, phase) + return + } + // Warn ONCE per phase. A broken key on a chat surface would otherwise + // print on every keystroke and get muted wholesale, taking the useful + // first warning with it. + if (this.warned.has(phase)) return + this.warned.add(phase) + console.warn(`[memmesh] ${phase} failed; continuing without memory.`, error) + } + + /** + * Retrieve the context bundle for a conversation. Returns null on any + * failure and on "nothing relevant", which the caller treats identically. + */ + async context(messages: unknown, intent?: string): Promise { + if (this.options.inject === false) return null + const turns = normalizeMessages(messages) + if (turns.length === 0) return null + try { + const bundle = await this.mm.context.forConversation({ + turns, + intent, + tokenBudget: this.options.tokenBudget, + limit: this.options.limit, + excludeCategories: this.options.excludeCategories, + brainId: this.options.brainId, + }) + return bundle.context ? bundle : null + } + catch (error) { + this.fail(error, 'context') + return null + } + } + + /** The system-message text for a bundle, preamble included. */ + block(bundle: ConversationContextBundle): string { + const preamble = this.options.systemPreamble ?? DEFAULT_PREAMBLE + return `${preamble}\n\n${bundle.context}` + } + + /** + * Return a copy of `messages` with the context injected as a system turn. + * + * APPENDED after any existing system messages, not prepended: the host's own + * system prompt is its product, and a memory block that lands above it reads + * to the model as the higher-priority instruction. Memory is context, not + * policy. + */ + async withContext(messages: T, intent?: string): Promise { + const bundle = await this.context(messages, intent) + if (bundle == null) return messages + const injected = [...messages] as unknown[] + let insertAt = 0 + while ( + insertAt < injected.length + && (injected[insertAt] as { role?: string })?.role === 'system' + ) insertAt++ + injected.splice(insertAt, 0, { role: 'system', content: this.block(bundle) }) + return injected as T + } + + /** + * Write the turn back to memory. + * + * Sends BOTH sides when an assistant reply is supplied. A store that holds + * only the user's half records questions without answers — "asked about the + * refund policy" with no trace of what they were told — which is the half + * that later turns actually need. + */ + async capture(messages: unknown, assistantText?: string): Promise { + if (this.options.capture === false) return + const turns = normalizeMessages(messages) + const human = lastHumanTurn(turns) + if (human == null && !assistantText) return + + const text = [ + human ? human.content : null, + assistantText ? `assistant: ${assistantText}` : null, + ].filter(Boolean).join('\n') + if (text.trim().length === 0) return + + const write = this.mm.memory.observe({ + text, + role: 'user', + userId: this.options.userId, + agentId: this.options.agentId, + sessionId: this.options.sessionId, + subject: this.options.subject, + }).then(() => undefined).catch((error: unknown) => this.fail(error, 'capture')) + + if (this.options.awaitCapture === false) { + // Explicitly opted out of durability. Attach the handler anyway so the + // rejection is reported rather than becoming an unhandled rejection. + void write + return + } + await write + } + + /** One call for the common case: inject, run, capture. */ + async run( + messages: ConversationTurn[] | unknown[], + call: (messages: unknown[]) => Promise, + extract: (result: T) => string | undefined, + intent?: string, + ): Promise { + const prepared = await this.withContext(messages as unknown[], intent) + const result = await call(prepared) + await this.capture(messages, extract(result)) + return result + } +} diff --git a/src/integrations/messages.ts b/src/integrations/messages.ts new file mode 100644 index 0000000..c229d0b --- /dev/null +++ b/src/integrations/messages.ts @@ -0,0 +1,60 @@ +import type { ConversationTurn } from '../resources/context.js' + +/** + * Normalise the message shapes the major chat SDKs use into plain turns. + * + * Every provider has converged on `{role, content}` and then diverged on what + * `content` is: a string (OpenAI's simple form, Anthropic's simple form), an + * array of typed parts (OpenAI vision, Anthropic tool use, the Vercel AI + * SDK's prompt format), or a mix within one conversation. Memory only ever + * wants the text, so this flattens parts and drops everything it does not + * understand rather than throwing — an unrecognised content part is a reason + * to have slightly less context, never a reason to fail the caller's turn. + */ + +type UnknownPart = { type?: string, text?: string, content?: unknown } + +function partToText(part: unknown): string { + if (typeof part === 'string') return part + if (part == null || typeof part !== 'object') return '' + const p = part as UnknownPart + // OpenAI: {type:'text', text}. Anthropic: {type:'text', text}. + // Vercel AI SDK: {type:'text', text}. Tool calls/images have no text and + // deliberately contribute nothing. + if (typeof p.text === 'string') return p.text + if (typeof p.content === 'string') return p.content + return '' +} + +function contentToText(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content.map(partToText).filter((t) => t.length > 0).join('\n') + } + return partToText(content) +} + +export function normalizeMessages(messages: unknown): ConversationTurn[] { + if (!Array.isArray(messages)) return [] + const turns: ConversationTurn[] = [] + for (const m of messages) { + if (m == null || typeof m !== 'object') continue + const msg = m as { role?: unknown, content?: unknown } + const content = contentToText(msg.content).trim() + if (content.length === 0) continue + turns.push({ + role: typeof msg.role === 'string' ? msg.role : undefined, + content, + }) + } + return turns +} + +/** The most recent turn written by a person, which is what memory captures. */ +export function lastHumanTurn(turns: ConversationTurn[]): ConversationTurn | null { + for (let i = turns.length - 1; i >= 0; i--) { + const role = (turns[i].role ?? 'user').toLowerCase() + if (role !== 'assistant' && role !== 'system' && role !== 'tool') return turns[i] + } + return null +} diff --git a/src/integrations/proxy.ts b/src/integrations/proxy.ts new file mode 100644 index 0000000..36f316d --- /dev/null +++ b/src/integrations/proxy.ts @@ -0,0 +1,56 @@ +/** + * Wrap ONE deeply-nested method on a client without mutating it. + * + * `withOpenAI(client)` has to intercept `client.chat.completions.create` and + * leave the other ~200 methods untouched, on a client the caller may also be + * using unwrapped elsewhere. Monkey-patching the original is out (it is not + * ours to modify, and it is not reversible); reconstructing the client is out + * (we would have to know its constructor). A proxy chain along one path is + * the remaining option. + * + * THE PRIVATE-FIELD TRAP, which is why every function here is bound. + * + * Modern provider SDKs use real `#private` class fields. Those are keyed to + * the instance, not looked up on the prototype chain, so a method invoked + * with `this` set to a PROXY throws `TypeError: Cannot read private member`. + * Returning methods unbound (or passing the proxy as the Reflect.get + * receiver) does exactly that. Every function is therefore bound to its real + * owner before it leaves this file, and `Reflect.get` is called WITHOUT a + * receiver argument for the same reason. + */ +export function proxyPath( + target: T, + path: string[], + wrapFn: (original: (...args: any[]) => any) => (...args: any[]) => any, +): T { + if (path.length === 0) return target + const [head, ...rest] = path + + return new Proxy(target, { + get(obj, prop) { + const value = Reflect.get(obj, prop) + + if (prop !== head) { + return typeof value === 'function' ? value.bind(obj) : value + } + + if (rest.length === 0) { + if (typeof value !== 'function') return value + return wrapFn(value.bind(obj)) + } + + if (value == null || typeof value !== 'object') return value + return proxyPath(value as object, rest, wrapFn) + }, + }) as T +} + +/** True when `path` resolves to a function on `obj`. Used for shape detection. */ +export function hasMethodAt(obj: unknown, path: string[]): boolean { + let cursor: any = obj + for (const segment of path) { + if (cursor == null || (typeof cursor !== 'object' && typeof cursor !== 'function')) return false + cursor = cursor[segment] + } + return typeof cursor === 'function' +} diff --git a/src/resources/context.ts b/src/resources/context.ts index b0a0510..122017c 100644 --- a/src/resources/context.ts +++ b/src/resources/context.ts @@ -1,6 +1,60 @@ import type { HttpClient } from '../core/http-client.js' import type { RequestOptions } from '../core/types.js' +/** A conversation turn, in the shape every chat SDK already uses. */ +export interface ConversationTurn { + role?: string + content: string +} + +export interface ConversationContextRequest { + /** + * The conversation. Prefer TURNS over a joined string: subject attribution + * reads the last human turn, and concatenation destroys the boundary that + * makes that possible. + */ + turns?: ConversationTurn[] + /** Alternative to `turns` when all you have is text. */ + conversation?: string + /** + * What you are about to do ("draft the renewal reply"). Weighted AHEAD of + * the conversation, because it describes the turn the context is for. + */ + intent?: string + /** Hard cap on the assembled block. Default 1200. */ + tokenBudget?: number + /** Candidate pool before budgeting. Default 25. */ + limit?: number + /** Min-necessary access — withhold these categories from this query. */ + excludeCategories?: string[] + /** Brain isolation. Required when consuming a published brain. */ + brainId?: string +} + +export interface ConversationContextBundle { + /** Ready to inject into a system prompt. Null when nothing applies. */ + context: string | null + /** Who we decided this is about, and how the decision was made. */ + subject: { + kind: string + externalId: string + origin: 'explicit' | 'speaker-first-person' | 'speaker' | 'user' + confidence: number + } | null + /** Every row that reached the block. Pass any to `memory.explain()`. */ + memoryIds: string[] + tokensEstimate: number + tokenBudget: number + /** + * Retrieved candidates the budget could not fit. Non-zero means "raise + * tokenBudget", NOT "we know nothing" — the two are otherwise + * indistinguishable from an empty-looking block. + */ + droppedForBudget: number + /** The query retrieval actually ran on. For debugging a bad bundle. */ + query: string +} + export type ContextSection = | 'profile' | 'patterns' @@ -169,6 +223,31 @@ export interface BatchContextBuildRequest { export class ContextResource { constructor(private readonly http: HttpClient) {} + /** + * Assemble injectable context FOR A CONVERSATION — no subject id required. + * + * The subject-keyed `build()` answers "what do we know about this subject", which needs + * you to know who the subject IS. This answers "what should the model know + * before it replies to this", which is the question a host application + * actually has, once per turn, with a conversation in hand and no id. + * + * @example + * ```ts + * const { context } = await mm.context.forConversation({ + * turns: messages, + * intent: 'answer the billing question', + * tokenBudget: 800, + * }) + * if (context) messages.unshift({ role: 'system', content: context }) + * ``` + */ + async forConversation( + body: ConversationContextRequest, + options?: RequestOptions, + ): Promise { + return this.http.post('/memory/context', body, options) + } + async build( body: ContextBuildRequest, options?: RequestOptions, diff --git a/src/resources/memory.ts b/src/resources/memory.ts index 876bbf9..81211ac 100644 --- a/src/resources/memory.ts +++ b/src/resources/memory.ts @@ -342,7 +342,13 @@ export class MemoryResource { } /** - * Delete one of your own memory items. + * Delete a memory item in this project. + * + * PROJECT-SCOPED, not owner-scoped — this was previously documented as + * "one of your own memory items", which it has never been. Your API key can + * delete any memory in the project it is scoped to. That is the intended + * behaviour for a backend managing its own data; it is the wrong thing to + * put behind an end user's "forget this about me" button. */ async delete(memoryId: string, options?: RequestOptions): Promise { return this.http.delete(`/memory/${memoryId}`, options) @@ -696,6 +702,37 @@ export class AdminMemoryResource { * keep the strongest, supersede the rest. Distinct from consolidate(), which * is the LLM-driven deductive-observations pass. */ + /** + * Collapse clusters of near-duplicate memories into ONE lossless survivor. + * + * Unlike `dedup()`, which keeps the newer of a pair and supersedes the + * older — losing any detail only the older one carried — this writes a NEW + * memory covering the union and supersedes the cluster onto it. Originals + * stay verbatim behind it. + * + * DEFAULTS TO A DRY RUN. Call it that way first: it reports how much of the + * corpus is near-duplicate, and in what cluster sizes, without invoking a + * model or writing anything. A real run also needs merge writes enabled + * server-side, and returns an abstention count — read that before trusting + * the merges. + */ + async merge( + body?: { + dryRun?: boolean + threshold?: number + maxClusters?: number + maxClusterSize?: number + }, + options?: RequestOptions, + ): Promise<{ + clustersFound: number + clustersMerged: number + clustersAbstained: number + memoriesSuperseded: number + }> { + return this.http.post('/admin/memory/merge', body ?? {}, options) + } + async dedup( body: DedupRequest = {}, options?: RequestOptions, diff --git a/src/types/memory.ts b/src/types/memory.ts index 637fac1..cb169bd 100644 --- a/src/types/memory.ts +++ b/src/types/memory.ts @@ -100,9 +100,19 @@ export interface ObserveRequest { /** Conversation/thread id, so turns from one session stay linkable. */ sessionId?: string /** - * Subject this observation applies to. Used with the legacy `content` path so - * mining can attribute the observation; not needed with `text` (the engine - * resolves subjects during extraction). + * WHO this observation is ABOUT — distinct from `userId`, who was SPEAKING. + * + * SEND IT. The claim that this is "not needed with `text`" was wrong: the + * engine did not resolve subjects during extraction, and a memory written + * without one is searchable but behaviourally invisible — predict, profile, + * cohort and behaviour discovery all resolve a subject before they can use + * a row. That is how a real deployment reaches 182k memories against 98 + * subjects with nothing reporting a problem. + * + * The server now INFERS a subject when you omit it (from a speaker tag in + * the text, first-person phrasing, or `userId`) and records how it decided. + * That is a good safety net and a poor substitute for the caller, which + * usually knows the answer exactly. */ subject?: { kind: string; externalId: string } /** diff --git a/test/integrations.test.ts b/test/integrations.test.ts new file mode 100644 index 0000000..2cccadf --- /dev/null +++ b/test/integrations.test.ts @@ -0,0 +1,244 @@ +/** + * Runnable checks for the integration layer: `npm run test:integrations`. + * + * These run against FAKE provider clients rather than the real SDKs, on + * purpose — the things most likely to break here are the proxy mechanics and + * the failure contract, and both are testable without a network. + * + * The private-field client below is the important one. Provider SDKs use real + * `#private` fields, and the natural proxy implementation (return methods + * unbound, or pass the proxy as the Reflect.get receiver) throws on them at + * runtime while typechecking perfectly. + */ +import assert from 'node:assert/strict' + +import { withAnthropic, withMemory, withOpenAI, MemoryMiddleware, normalizeMessages } from '../src/integrations/index.js' + +let failures = 0 +async function test(name: string, fn: () => Promise | void): Promise { + try { + await fn() + console.log(` ok ${name}`) + } + catch (err) { + failures++ + console.error(`FAIL ${name}`) + console.error(err) + } +} + +/** Records what the middleware asked memory for, and what it wrote back. */ +function fakeMemory(overrides: { context?: string | null, throwOn?: 'context' | 'capture' } = {}) { + const calls = { context: [] as any[], observe: [] as any[] } + return { + calls, + client: { + context: { + async forConversation(body: any) { + calls.context.push(body) + if (overrides.throwOn === 'context') throw new Error('memory down') + const context = overrides.context === undefined ? 'user prefers Postgres' : overrides.context + return { + context, + subject: null, + memoryIds: context ? ['m1'] : [], + tokensEstimate: 12, + tokenBudget: 1200, + droppedForBudget: 0, + query: 'q', + } + }, + }, + memory: { + async observe(body: any) { + calls.observe.push(body) + if (overrides.throwOn === 'capture') throw new Error('memory down') + return { saved: [], candidateCount: 0 } + }, + }, + } as any, + } +} + +/** An OpenAI-shaped client using a real #private field, like the real one. */ +class FakeOpenAI { + #instanceLabel = 'inner-state' + lastParams: any = null + chat = { + completions: { + create: async (params: any) => { + // Touching a #private field is what breaks under a naive proxy. + void openAiSelf!.label() + openAiSelf!.lastParams = params + return { choices: [{ message: { content: 'the answer' } }] } + }, + }, + } + label(): string { return this.#instanceLabel } +} +let openAiSelf: FakeOpenAI | null = null + +class FakeAnthropic { + lastParams: any = null + messages = { + create: async (params: any) => { + anthropicSelf!.lastParams = params + return { content: [{ type: 'text', text: 'the answer' }] } + }, + } +} +let anthropicSelf: FakeAnthropic | null = null + +async function main(): Promise { + console.log('integrations') + + await test('withOpenAI injects context as a system message and captures both halves', async () => { + const mem = fakeMemory() + const raw = new FakeOpenAI() + openAiSelf = raw + const client = withOpenAI(raw, mem.client, { subject: { kind: 'user', externalId: 'u1' } }) + + const messages = [{ role: 'user', content: 'which database should I use?' }] + const result: any = await (client as any).chat.completions.create({ model: 'gpt-4o', messages }) + + assert.equal(result.choices[0].message.content, 'the answer') + const sent = raw.lastParams.messages + assert.equal(sent.length, 2, 'a system message should have been inserted') + assert.equal(sent[0].role, 'system') + assert.ok(sent[0].content.includes('user prefers Postgres')) + // The caller's own array must not be mutated. + assert.equal(messages.length, 1) + // Capture sends the user turn AND the assistant reply. + assert.equal(mem.calls.observe.length, 1) + assert.ok(mem.calls.observe[0].text.includes('which database')) + assert.ok(mem.calls.observe[0].text.includes('the answer')) + assert.deepEqual(mem.calls.observe[0].subject, { kind: 'user', externalId: 'u1' }) + }) + + await test('the proxy does not break #private field access', async () => { + const mem = fakeMemory() + const raw = new FakeOpenAI() + openAiSelf = raw + const client = withOpenAI(raw, mem.client, {}) + // Throws "Cannot read private member" if the method is invoked with the + // proxy as `this` instead of the real instance. + await (client as any).chat.completions.create({ messages: [{ role: 'user', content: 'hi' }] }) + assert.equal((client as any).label(), 'inner-state') + }) + + await test('a memory outage never breaks the model call', async () => { + const mem = fakeMemory({ throwOn: 'context' }) + const raw = new FakeOpenAI() + openAiSelf = raw + const errors: string[] = [] + const client = withOpenAI(raw, mem.client, { onError: (_e, phase) => errors.push(phase) }) + + const result: any = await (client as any).chat.completions.create({ + messages: [{ role: 'user', content: 'hi' }], + }) + assert.equal(result.choices[0].message.content, 'the answer') + assert.deepEqual(raw.lastParams.messages, [{ role: 'user', content: 'hi' }], 'no context injected') + assert.ok(errors.includes('context')) + }) + + await test('a capture failure is reported, not thrown', async () => { + const mem = fakeMemory({ throwOn: 'capture' }) + const raw = new FakeOpenAI() + openAiSelf = raw + const errors: string[] = [] + const client = withOpenAI(raw, mem.client, { onError: (_e, phase) => errors.push(phase) }) + await (client as any).chat.completions.create({ messages: [{ role: 'user', content: 'hi' }] }) + assert.ok(errors.includes('capture')) + }) + + await test('nothing relevant means the prompt is left exactly as it was', async () => { + const mem = fakeMemory({ context: null }) + const raw = new FakeOpenAI() + openAiSelf = raw + const client = withOpenAI(raw, mem.client, {}) + await (client as any).chat.completions.create({ messages: [{ role: 'user', content: 'hi' }] }) + assert.equal(raw.lastParams.messages.length, 1) + }) + + await test('context lands AFTER the caller\'s own system prompt, never before', async () => { + const mem = fakeMemory() + const raw = new FakeOpenAI() + openAiSelf = raw + const client = withOpenAI(raw, mem.client, {}) + await (client as any).chat.completions.create({ + messages: [ + { role: 'system', content: 'YOU ARE ACME SUPPORT' }, + { role: 'user', content: 'hi' }, + ], + }) + const sent = raw.lastParams.messages + assert.equal(sent[0].content, 'YOU ARE ACME SUPPORT', 'the host system prompt must stay first') + assert.ok(sent[1].content.includes('user prefers Postgres')) + }) + + await test('withAnthropic appends to `system`, not to `messages`', async () => { + const mem = fakeMemory() + const raw = new FakeAnthropic() + anthropicSelf = raw + const client = withAnthropic(raw, mem.client, {}) + await (client as any).messages.create({ + model: 'claude-sonnet-5', + system: 'YOU ARE ACME SUPPORT', + messages: [{ role: 'user', content: 'hi' }], + }) + // A {role:'system'} entry in `messages` is rejected by the Anthropic API. + assert.equal(raw.lastParams.messages.length, 1) + assert.ok(raw.lastParams.system.startsWith('YOU ARE ACME SUPPORT')) + assert.ok(raw.lastParams.system.includes('user prefers Postgres')) + }) + + await test('streaming injects but does not fabricate an assistant capture', async () => { + const mem = fakeMemory() + const raw = new FakeOpenAI() + openAiSelf = raw + const client = withOpenAI(raw, mem.client, {}) + await (client as any).chat.completions.create({ + stream: true, + messages: [{ role: 'user', content: 'stream me' }], + }) + assert.equal(raw.lastParams.messages.length, 2, 'injection still happens') + assert.ok(mem.calls.observe[0].text.includes('stream me')) + assert.ok(!mem.calls.observe[0].text.includes('assistant:'), 'no assistant half is invented') + }) + + await test('withMemory detects the client shape', async () => { + const mem = fakeMemory() + const oa = new FakeOpenAI() + openAiSelf = oa + assert.doesNotThrow(() => withMemory(oa, mem.client, {})) + const an = new FakeAnthropic() + anthropicSelf = an + assert.doesNotThrow(() => withMemory(an, mem.client, {})) + assert.throws(() => withMemory({} as any, mem.client, {}), /unrecognised client/) + }) + + await test('normalizeMessages flattens content parts and drops non-text', async () => { + const turns = normalizeMessages([ + { role: 'user', content: 'plain' }, + { role: 'user', content: [{ type: 'text', text: 'part one' }, { type: 'image_url', image_url: {} }] }, + { role: 'assistant', content: [] }, + null, + ]) + assert.deepEqual(turns, [ + { role: 'user', content: 'plain' }, + { role: 'user', content: 'part one' }, + ]) + }) + + await test('MemoryMiddleware can be used directly for unsupported clients', async () => { + const mem = fakeMemory() + const mw = new MemoryMiddleware(mem.client, {}) + const prepared = await mw.withContext([{ role: 'user', content: 'hi' }]) + assert.equal(prepared.length, 2) + }) + + console.log(failures === 0 ? '\nall integration checks passed' : `\n${failures} failed`) + process.exit(failures === 0 ? 0 : 1) +} + +void main()