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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
158 changes: 158 additions & 0 deletions src/integrations/index.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(
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<T extends object>(
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<any> } {
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<T extends object>(
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.',
)
}
Loading
Loading