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()