@tanstack/ai-memory 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,9 +85,11 @@ function memoryMiddleware(options) {
85
85
  };
86
86
  const additions = [result.toolGuidance ?? "", result.systemPrompt].filter((p) => p.length > 0);
87
87
  if (additions.length === 0 && tools.length === 0) return;
88
+ const existingToolNames = new Set(config.tools.map((tool) => tool.name));
89
+ const extraTools = tools.filter((tool) => !existingToolNames.has(tool.name));
88
90
  return {
89
91
  systemPrompts: [...config.systemPrompts, ...additions],
90
- tools: [...config.tools, ...tools]
92
+ tools: extraTools.length > 0 ? [...config.tools, ...extraTools] : config.tools
91
93
  };
92
94
  },
93
95
  onChunk(ctx, chunk) {
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.js","names":[],"sources":["../../src/middleware.ts"],"sourcesContent":["import { aiEventClient } from '@tanstack/ai-event-client'\nimport type {\n ChatMiddleware,\n ChatMiddlewareConfig,\n ChatMiddlewareContext,\n ModelMessage,\n StreamChunk,\n} from '@tanstack/ai'\nimport type {\n MemoryAdapter,\n MemoryFact,\n MemoryScope,\n MemoryTurn,\n RecallResult,\n SaveReceipt,\n} from './types'\n\n/**\n * CUSTOM stream-event name carrying server-side memory state to the browser.\n * The middleware injects one of these per turn (via `onChunk`); the client\n * devtools bridge (`@tanstack/ai-client`) recognizes it and re-emits `memory:*`\n * on the browser event bus. This is how server-side memory reaches the browser\n * DevTools panel — server-emitted `aiEventClient` events never cross runtimes;\n * everything the panel shows is re-derived client-side from the chat stream\n * (mirrors how generation results ride `CUSTOM` events — see `GENERATION_EVENTS`).\n */\nexport const MEMORY_STATE_EVENT = 'memory:state'\n\n/** Payload of the {@link MEMORY_STATE_EVENT} CUSTOM chunk. Captures memory state\n * as of the turn's START — the snapshot reflects every prior turn's save; this\n * turn's own save (deferred) surfaces in the next turn's snapshot. */\nexport interface MemoryStateEventValue {\n scope: MemoryScope\n adapter: string\n /** The recall query (last user text). */\n query: string\n /** Recall metrics for the operations timeline. */\n recall: {\n fragmentCount: number\n hasTools: boolean\n systemPromptChars: number\n durationMs: number\n }\n /** Live store snapshot, when the adapter supports `inspect`/`listFacts`. */\n snapshot?: {\n takenAt: string\n data: unknown\n facts: Array<MemoryFact>\n }\n}\n\n/**\n * How the middleware participates in the run:\n * - `'recall+save'` (default): recall on init (inject prompt + tools), save on finish.\n * - `'save-only'`: skip recall entirely — persist the turn but never read/inject.\n */\nexport type MemoryMiddlewareRole = 'recall+save' | 'save-only'\n\nexport interface MemoryRecallInfo {\n scope: MemoryScope\n query: string\n result: RecallResult\n}\n\nexport interface MemorySaveInfo {\n scope: MemoryScope\n turn: MemoryTurn\n receipts: Array<SaveReceipt>\n}\n\nexport interface MemoryMiddlewareOptions {\n /** The memory backend to recall from / save to. */\n adapter: MemoryAdapter\n /**\n * Scope for every adapter call. The function form is the safer default for\n * multi-tenant apps: derive scope per request from trusted, server-validated\n * chat context — never from client input.\n */\n scope:\n | MemoryScope\n | ((ctx: ChatMiddlewareContext) => MemoryScope | Promise<MemoryScope>)\n /** Participation role. Defaults to `'recall+save'`. */\n role?: MemoryMiddlewareRole\n /** Fired after `recall` completes (post-injection), for app telemetry. */\n onRecall?: (info: MemoryRecallInfo) => void | Promise<void>\n /** Fired after the deferred `save` completes, for app telemetry. */\n onSave?: (info: MemorySaveInfo) => void | Promise<void>\n}\n\n/** Per-request scratch state, keyed by context in a module-level WeakMap so the\n * same middleware instance is safe across concurrent `chat()` calls. */\ninterface MemoryRequestState {\n resolvedScope?: MemoryScope\n lastUserText: string\n /** Pending devtools transport chunk, injected once by the first `onChunk`. */\n stateChunk?: { emitted: boolean; value: MemoryStateEventValue }\n}\n\nconst stateByCtx = new WeakMap<ChatMiddlewareContext, MemoryRequestState>()\n\n/**\n * Server-side memory middleware. Recalls relevant memory into the prompt before\n * the model runs, then defers `save` of the completed turn after it finishes.\n * All extraction/ranking/rendering lives in the adapter — this middleware only\n * wires `recall`/`save` into the chat lifecycle and emits devtools events.\n */\nexport function memoryMiddleware(\n options: MemoryMiddlewareOptions,\n): ChatMiddleware {\n const role = options.role ?? 'recall+save'\n\n async function resolveScope(\n ctx: ChatMiddlewareContext,\n state: MemoryRequestState,\n ): Promise<MemoryScope> {\n if (state.resolvedScope) return state.resolvedScope\n state.resolvedScope =\n typeof options.scope === 'function'\n ? await options.scope(ctx)\n : options.scope\n return state.resolvedScope\n }\n\n return {\n name: `memory:${options.adapter.id}`,\n\n async onConfig(ctx, config) {\n if (ctx.phase !== 'init') return\n\n const state: MemoryRequestState = { lastUserText: '' }\n stateByCtx.set(ctx, state)\n\n state.lastUserText = getMessageText(findLastUserMessage(config.messages))\n if (!state.lastUserText || role === 'save-only') return\n\n const startedAt = Date.now()\n let scope: MemoryScope\n let result: RecallResult\n try {\n scope = await resolveScope(ctx, state)\n safeEmit('memory:retrieve:started', {\n scope,\n adapter: options.adapter.id,\n query: state.lastUserText,\n timestamp: startedAt,\n })\n result = await options.adapter.recall(scope, state.lastUserText)\n } catch (error) {\n safeEmit('memory:error', {\n // Only attach scope when resolve already succeeded; otherwise omit\n // (no empty-string / partial fake identity).\n ...(state.resolvedScope ? { scope: state.resolvedScope } : {}),\n adapter: options.adapter.id,\n phase: 'recall',\n error: errorInfo(error),\n timestamp: Date.now(),\n })\n return\n }\n\n const tools = result.tools ?? []\n const recallMetrics = {\n fragmentCount: result.fragments?.length ?? 0,\n hasTools: tools.length > 0,\n systemPromptChars: result.systemPrompt.length,\n durationMs: Date.now() - startedAt,\n }\n safeEmit('memory:retrieve:completed', {\n scope,\n adapter: options.adapter.id,\n ...recallMetrics,\n timestamp: Date.now(),\n })\n await options.onRecall?.({ scope, query: state.lastUserText, result })\n\n // Stage the devtools transport chunk (recall metrics + current store\n // snapshot). Injected into the stream by `onChunk` so it reaches the\n // browser panel; see MEMORY_STATE_EVENT.\n const snapshot = await gatherSnapshot(options.adapter, scope)\n state.stateChunk = {\n emitted: false,\n value: {\n scope,\n adapter: options.adapter.id,\n query: state.lastUserText,\n recall: recallMetrics,\n ...(snapshot ? { snapshot } : {}),\n },\n }\n\n const memoryPrompts = [result.toolGuidance ?? '', result.systemPrompt]\n const additions = memoryPrompts.filter((p) => p.length > 0)\n if (additions.length === 0 && tools.length === 0) return\n\n return {\n systemPrompts: [...config.systemPrompts, ...additions],\n tools: [...config.tools, ...tools],\n } satisfies Partial<ChatMiddlewareConfig>\n },\n\n onChunk(ctx, chunk) {\n // Inject the staged memory-state chunk exactly once, riding alongside the\n // first stream chunk (typically RUN_STARTED) so the browser devtools sees\n // it. Returning an array expands the stream; see ChatMiddleware.onChunk.\n const state = stateByCtx.get(ctx)\n if (!state?.stateChunk || state.stateChunk.emitted) return\n state.stateChunk.emitted = true\n const custom: StreamChunk = {\n type: 'CUSTOM',\n name: MEMORY_STATE_EVENT,\n value: state.stateChunk.value,\n timestamp: Date.now(),\n }\n return [chunk, custom]\n },\n\n onFinish(ctx, info) {\n const state = stateByCtx.get(ctx)\n stateByCtx.delete(ctx)\n const userText =\n state?.lastUserText || getMessageText(findLastUserMessage(ctx.messages))\n const assistant = info.content\n if (!userText || !assistant) return\n const scope = state?.resolvedScope\n\n ctx.defer(\n (async () => {\n // Resolve scope defensively — a throwing resolver must not escape the\n // terminal hook. Memory failures are always non-fatal + observable.\n let resolved: MemoryScope\n try {\n resolved =\n scope ?? (await resolveScope(ctx, { lastUserText: userText }))\n } catch (error) {\n safeEmit('memory:error', {\n adapter: options.adapter.id,\n phase: 'save',\n error: errorInfo(error),\n timestamp: Date.now(),\n })\n return\n }\n\n const turn: MemoryTurn = { user: userText, assistant }\n const startedAt = Date.now()\n safeEmit('memory:persist:started', {\n scope: resolved,\n adapter: options.adapter.id,\n timestamp: startedAt,\n })\n let receipts: Array<SaveReceipt>\n try {\n receipts = await options.adapter.save(resolved, turn)\n } catch (error) {\n receipts = [{ ok: false, error: String(error) }]\n safeEmit('memory:error', {\n scope: resolved,\n adapter: options.adapter.id,\n phase: 'save',\n error: errorInfo(error),\n timestamp: Date.now(),\n })\n }\n safeEmit('memory:persist:completed', {\n scope: resolved,\n adapter: options.adapter.id,\n receiptCount: receipts.length,\n okCount: receipts.filter((r) => r.ok).length,\n durationMs: Date.now() - startedAt,\n timestamp: Date.now(),\n })\n await emitSnapshot(options.adapter, resolved)\n await options.onSave?.({ scope: resolved, turn, receipts })\n })(),\n )\n },\n }\n}\n\n// ===========================\n// Internals\n// ===========================\n\n/**\n * Read the adapter's current stored state via the optional `inspect`/`listFacts`\n * introspection methods. Returns `undefined` for adapters that don't implement\n * `inspect` (they degrade to the metrics-only timeline). Fully guarded:\n * introspection must never affect chat.\n */\nasync function gatherSnapshot(\n adapter: MemoryAdapter,\n scope: MemoryScope,\n): Promise<\n { takenAt: string; data: unknown; facts: Array<MemoryFact> } | undefined\n> {\n if (!adapter.inspect) return undefined\n try {\n const snapshot = await adapter.inspect(scope)\n const facts = (await adapter.listFacts?.(scope)) ?? []\n return { takenAt: snapshot.takenAt, data: snapshot.data, facts }\n } catch {\n // ignored — introspection is best-effort telemetry.\n return undefined\n }\n}\n\n/**\n * DevTools-only: after a save, emit the adapter's current stored state on the\n * (in-process) event bus, so a devtools consumer running in the SAME runtime as\n * the chat (client-side execution / server-side listener) sees \"what's in\n * memory\". For the standard server-side topology, the browser panel instead\n * gets state via the {@link MEMORY_STATE_EVENT} stream chunk (see `onChunk`).\n */\nasync function emitSnapshot(\n adapter: MemoryAdapter,\n scope: MemoryScope,\n): Promise<void> {\n const snapshot = await gatherSnapshot(adapter, scope)\n if (!snapshot) return\n safeEmit('memory:snapshot', {\n scope,\n adapter: adapter.id,\n ...snapshot,\n timestamp: Date.now(),\n })\n}\n\nfunction findLastUserMessage(\n messages: ReadonlyArray<ModelMessage>,\n): ModelMessage | undefined {\n for (let i = messages.length - 1; i >= 0; i--) {\n const message = messages[i]\n if (message && message.role === 'user') return message\n }\n return undefined\n}\n\n/**\n * Extract plain text from a `ModelMessage`. Text lives on `part.content` for\n * `TextPart`; bare strings in the content array are tolerated. All other\n * content kinds (tool-call, image, …) yield '' so they don't pollute the\n * recall query.\n */\nfunction getMessageText(message?: ModelMessage): string {\n if (!message) return ''\n if (typeof message.content === 'string') return message.content\n if (Array.isArray(message.content)) {\n return message.content\n .map((part) => {\n if (typeof part === 'string') return part\n if (part.type === 'text' && typeof part.content === 'string') {\n return part.content\n }\n return ''\n })\n .filter(Boolean)\n .join('\\n')\n }\n return ''\n}\n\nfunction errorInfo(error: unknown): { name: string; message: string } {\n if (error instanceof Error)\n return { name: error.name, message: error.message }\n if (\n error &&\n typeof error === 'object' &&\n 'name' in error &&\n typeof error.name === 'string'\n ) {\n return {\n name: error.name,\n message: String((error as { message?: unknown }).message ?? error),\n }\n }\n return { name: 'Error', message: String(error) }\n}\n\n/** Fire-and-forget devtools emit — telemetry failures must never affect chat. */\nfunction safeEmit(...args: Parameters<typeof aiEventClient.emit>): void {\n try {\n aiEventClient.emit(...args)\n } catch {\n // ignored — telemetry must not affect chat behaviour\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"middleware.js","names":[],"sources":["../../src/middleware.ts"],"sourcesContent":["import { aiEventClient } from '@tanstack/ai-event-client'\nimport type {\n ChatMiddleware,\n ChatMiddlewareConfig,\n ChatMiddlewareContext,\n ModelMessage,\n StreamChunk,\n} from '@tanstack/ai'\nimport type {\n MemoryAdapter,\n MemoryFact,\n MemoryScope,\n MemoryTurn,\n RecallResult,\n SaveReceipt,\n} from './types'\n\n/**\n * CUSTOM stream-event name carrying server-side memory state to the browser.\n * The middleware injects one of these per turn (via `onChunk`); the client\n * devtools bridge (`@tanstack/ai-client`) recognizes it and re-emits `memory:*`\n * on the browser event bus. This is how server-side memory reaches the browser\n * DevTools panel — server-emitted `aiEventClient` events never cross runtimes;\n * everything the panel shows is re-derived client-side from the chat stream\n * (mirrors how generation results ride `CUSTOM` events — see `GENERATION_EVENTS`).\n */\nexport const MEMORY_STATE_EVENT = 'memory:state'\n\n/** Payload of the {@link MEMORY_STATE_EVENT} CUSTOM chunk. Captures memory state\n * as of the turn's START — the snapshot reflects every prior turn's save; this\n * turn's own save (deferred) surfaces in the next turn's snapshot. */\nexport interface MemoryStateEventValue {\n scope: MemoryScope\n adapter: string\n /** The recall query (last user text). */\n query: string\n /** Recall metrics for the operations timeline. */\n recall: {\n fragmentCount: number\n hasTools: boolean\n systemPromptChars: number\n durationMs: number\n }\n /** Live store snapshot, when the adapter supports `inspect`/`listFacts`. */\n snapshot?: {\n takenAt: string\n data: unknown\n facts: Array<MemoryFact>\n }\n}\n\n/**\n * How the middleware participates in the run:\n * - `'recall+save'` (default): recall on init (inject prompt + tools), save on finish.\n * - `'save-only'`: skip recall entirely — persist the turn but never read/inject.\n */\nexport type MemoryMiddlewareRole = 'recall+save' | 'save-only'\n\nexport interface MemoryRecallInfo {\n scope: MemoryScope\n query: string\n result: RecallResult\n}\n\nexport interface MemorySaveInfo {\n scope: MemoryScope\n turn: MemoryTurn\n receipts: Array<SaveReceipt>\n}\n\nexport interface MemoryMiddlewareOptions {\n /** The memory backend to recall from / save to. */\n adapter: MemoryAdapter\n /**\n * Scope for every adapter call. The function form is the safer default for\n * multi-tenant apps: derive scope per request from trusted, server-validated\n * chat context — never from client input.\n */\n scope:\n | MemoryScope\n | ((ctx: ChatMiddlewareContext) => MemoryScope | Promise<MemoryScope>)\n /** Participation role. Defaults to `'recall+save'`. */\n role?: MemoryMiddlewareRole\n /** Fired after `recall` completes (post-injection), for app telemetry. */\n onRecall?: (info: MemoryRecallInfo) => void | Promise<void>\n /** Fired after the deferred `save` completes, for app telemetry. */\n onSave?: (info: MemorySaveInfo) => void | Promise<void>\n}\n\n/** Per-request scratch state, keyed by context in a module-level WeakMap so the\n * same middleware instance is safe across concurrent `chat()` calls. */\ninterface MemoryRequestState {\n resolvedScope?: MemoryScope\n lastUserText: string\n /** Pending devtools transport chunk, injected once by the first `onChunk`. */\n stateChunk?: { emitted: boolean; value: MemoryStateEventValue }\n}\n\nconst stateByCtx = new WeakMap<ChatMiddlewareContext, MemoryRequestState>()\n\n/**\n * Server-side memory middleware. Recalls relevant memory into the prompt before\n * the model runs, then defers `save` of the completed turn after it finishes.\n * All extraction/ranking/rendering lives in the adapter — this middleware only\n * wires `recall`/`save` into the chat lifecycle and emits devtools events.\n */\nexport function memoryMiddleware(\n options: MemoryMiddlewareOptions,\n): ChatMiddleware {\n const role = options.role ?? 'recall+save'\n\n async function resolveScope(\n ctx: ChatMiddlewareContext,\n state: MemoryRequestState,\n ): Promise<MemoryScope> {\n if (state.resolvedScope) return state.resolvedScope\n state.resolvedScope =\n typeof options.scope === 'function'\n ? await options.scope(ctx)\n : options.scope\n return state.resolvedScope\n }\n\n return {\n name: `memory:${options.adapter.id}`,\n\n async onConfig(ctx, config) {\n if (ctx.phase !== 'init') return\n\n const state: MemoryRequestState = { lastUserText: '' }\n stateByCtx.set(ctx, state)\n\n state.lastUserText = getMessageText(findLastUserMessage(config.messages))\n if (!state.lastUserText || role === 'save-only') return\n\n const startedAt = Date.now()\n let scope: MemoryScope\n let result: RecallResult\n try {\n scope = await resolveScope(ctx, state)\n safeEmit('memory:retrieve:started', {\n scope,\n adapter: options.adapter.id,\n query: state.lastUserText,\n timestamp: startedAt,\n })\n result = await options.adapter.recall(scope, state.lastUserText)\n } catch (error) {\n safeEmit('memory:error', {\n // Only attach scope when resolve already succeeded; otherwise omit\n // (no empty-string / partial fake identity).\n ...(state.resolvedScope ? { scope: state.resolvedScope } : {}),\n adapter: options.adapter.id,\n phase: 'recall',\n error: errorInfo(error),\n timestamp: Date.now(),\n })\n return\n }\n\n const tools = result.tools ?? []\n const recallMetrics = {\n fragmentCount: result.fragments?.length ?? 0,\n hasTools: tools.length > 0,\n systemPromptChars: result.systemPrompt.length,\n durationMs: Date.now() - startedAt,\n }\n safeEmit('memory:retrieve:completed', {\n scope,\n adapter: options.adapter.id,\n ...recallMetrics,\n timestamp: Date.now(),\n })\n await options.onRecall?.({ scope, query: state.lastUserText, result })\n\n // Stage the devtools transport chunk (recall metrics + current store\n // snapshot). Injected into the stream by `onChunk` so it reaches the\n // browser panel; see MEMORY_STATE_EVENT.\n const snapshot = await gatherSnapshot(options.adapter, scope)\n state.stateChunk = {\n emitted: false,\n value: {\n scope,\n adapter: options.adapter.id,\n query: state.lastUserText,\n recall: recallMetrics,\n ...(snapshot ? { snapshot } : {}),\n },\n }\n\n const memoryPrompts = [result.toolGuidance ?? '', result.systemPrompt]\n const additions = memoryPrompts.filter((p) => p.length > 0)\n if (additions.length === 0 && tools.length === 0) return\n\n const existingToolNames = new Set(config.tools.map((tool) => tool.name))\n const extraTools = tools.filter(\n (tool) => !existingToolNames.has(tool.name),\n )\n\n return {\n systemPrompts: [...config.systemPrompts, ...additions],\n tools:\n extraTools.length > 0\n ? [...config.tools, ...extraTools]\n : config.tools,\n } satisfies Partial<ChatMiddlewareConfig>\n },\n\n onChunk(ctx, chunk) {\n // Inject the staged memory-state chunk exactly once, riding alongside the\n // first stream chunk (typically RUN_STARTED) so the browser devtools sees\n // it. Returning an array expands the stream; see ChatMiddleware.onChunk.\n const state = stateByCtx.get(ctx)\n if (!state?.stateChunk || state.stateChunk.emitted) return\n state.stateChunk.emitted = true\n const custom: StreamChunk = {\n type: 'CUSTOM',\n name: MEMORY_STATE_EVENT,\n value: state.stateChunk.value,\n timestamp: Date.now(),\n }\n return [chunk, custom]\n },\n\n onFinish(ctx, info) {\n const state = stateByCtx.get(ctx)\n stateByCtx.delete(ctx)\n const userText =\n state?.lastUserText || getMessageText(findLastUserMessage(ctx.messages))\n const assistant = info.content\n if (!userText || !assistant) return\n const scope = state?.resolvedScope\n\n ctx.defer(\n (async () => {\n // Resolve scope defensively — a throwing resolver must not escape the\n // terminal hook. Memory failures are always non-fatal + observable.\n let resolved: MemoryScope\n try {\n resolved =\n scope ?? (await resolveScope(ctx, { lastUserText: userText }))\n } catch (error) {\n safeEmit('memory:error', {\n adapter: options.adapter.id,\n phase: 'save',\n error: errorInfo(error),\n timestamp: Date.now(),\n })\n return\n }\n\n const turn: MemoryTurn = { user: userText, assistant }\n const startedAt = Date.now()\n safeEmit('memory:persist:started', {\n scope: resolved,\n adapter: options.adapter.id,\n timestamp: startedAt,\n })\n let receipts: Array<SaveReceipt>\n try {\n receipts = await options.adapter.save(resolved, turn)\n } catch (error) {\n receipts = [{ ok: false, error: String(error) }]\n safeEmit('memory:error', {\n scope: resolved,\n adapter: options.adapter.id,\n phase: 'save',\n error: errorInfo(error),\n timestamp: Date.now(),\n })\n }\n safeEmit('memory:persist:completed', {\n scope: resolved,\n adapter: options.adapter.id,\n receiptCount: receipts.length,\n okCount: receipts.filter((r) => r.ok).length,\n durationMs: Date.now() - startedAt,\n timestamp: Date.now(),\n })\n await emitSnapshot(options.adapter, resolved)\n await options.onSave?.({ scope: resolved, turn, receipts })\n })(),\n )\n },\n }\n}\n\n// ===========================\n// Internals\n// ===========================\n\n/**\n * Read the adapter's current stored state via the optional `inspect`/`listFacts`\n * introspection methods. Returns `undefined` for adapters that don't implement\n * `inspect` (they degrade to the metrics-only timeline). Fully guarded:\n * introspection must never affect chat.\n */\nasync function gatherSnapshot(\n adapter: MemoryAdapter,\n scope: MemoryScope,\n): Promise<\n { takenAt: string; data: unknown; facts: Array<MemoryFact> } | undefined\n> {\n if (!adapter.inspect) return undefined\n try {\n const snapshot = await adapter.inspect(scope)\n const facts = (await adapter.listFacts?.(scope)) ?? []\n return { takenAt: snapshot.takenAt, data: snapshot.data, facts }\n } catch {\n // ignored — introspection is best-effort telemetry.\n return undefined\n }\n}\n\n/**\n * DevTools-only: after a save, emit the adapter's current stored state on the\n * (in-process) event bus, so a devtools consumer running in the SAME runtime as\n * the chat (client-side execution / server-side listener) sees \"what's in\n * memory\". For the standard server-side topology, the browser panel instead\n * gets state via the {@link MEMORY_STATE_EVENT} stream chunk (see `onChunk`).\n */\nasync function emitSnapshot(\n adapter: MemoryAdapter,\n scope: MemoryScope,\n): Promise<void> {\n const snapshot = await gatherSnapshot(adapter, scope)\n if (!snapshot) return\n safeEmit('memory:snapshot', {\n scope,\n adapter: adapter.id,\n ...snapshot,\n timestamp: Date.now(),\n })\n}\n\nfunction findLastUserMessage(\n messages: ReadonlyArray<ModelMessage>,\n): ModelMessage | undefined {\n for (let i = messages.length - 1; i >= 0; i--) {\n const message = messages[i]\n if (message && message.role === 'user') return message\n }\n return undefined\n}\n\n/**\n * Extract plain text from a `ModelMessage`. Text lives on `part.content` for\n * `TextPart`; bare strings in the content array are tolerated. All other\n * content kinds (tool-call, image, …) yield '' so they don't pollute the\n * recall query.\n */\nfunction getMessageText(message?: ModelMessage): string {\n if (!message) return ''\n if (typeof message.content === 'string') return message.content\n if (Array.isArray(message.content)) {\n return message.content\n .map((part) => {\n if (typeof part === 'string') return part\n if (part.type === 'text' && typeof part.content === 'string') {\n return part.content\n }\n return ''\n })\n .filter(Boolean)\n .join('\\n')\n }\n return ''\n}\n\nfunction errorInfo(error: unknown): { name: string; message: string } {\n if (error instanceof Error)\n return { name: error.name, message: error.message }\n if (\n error &&\n typeof error === 'object' &&\n 'name' in error &&\n typeof error.name === 'string'\n ) {\n return {\n name: error.name,\n message: String((error as { message?: unknown }).message ?? error),\n }\n }\n return { name: 'Error', message: String(error) }\n}\n\n/** Fire-and-forget devtools emit — telemetry failures must never affect chat. */\nfunction safeEmit(...args: Parameters<typeof aiEventClient.emit>): void {\n try {\n aiEventClient.emit(...args)\n } catch {\n // ignored — telemetry must not affect chat behaviour\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-memory",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Pluggable memory adapters for TanStack AI memoryMiddleware",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -53,14 +53,14 @@
53
53
  "tanstack-intent"
54
54
  ],
55
55
  "dependencies": {
56
- "@tanstack/ai-event-client": "^0.8.0"
56
+ "@tanstack/ai-event-client": "^0.9.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@honcho-ai/sdk": ">=2.0.0",
60
60
  "@vectorize-io/hindsight-client": ">=0.6.0",
61
61
  "ioredis": ">=5.0.0",
62
62
  "redis": ">=4.0.0",
63
- "@tanstack/ai": "^0.45.0"
63
+ "@tanstack/ai": "^0.46.0"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "ioredis": {
@@ -82,7 +82,7 @@
82
82
  "@vitest/coverage-v8": "4.1.10",
83
83
  "ioredis-mock": "^8.9.0",
84
84
  "redis": "^4.7.0",
85
- "@tanstack/ai": "0.45.0"
85
+ "@tanstack/ai": "0.46.0"
86
86
  },
87
87
  "scripts": {
88
88
  "build": "vite build",
package/src/middleware.ts CHANGED
@@ -192,9 +192,17 @@ export function memoryMiddleware(
192
192
  const additions = memoryPrompts.filter((p) => p.length > 0)
193
193
  if (additions.length === 0 && tools.length === 0) return
194
194
 
195
+ const existingToolNames = new Set(config.tools.map((tool) => tool.name))
196
+ const extraTools = tools.filter(
197
+ (tool) => !existingToolNames.has(tool.name),
198
+ )
199
+
195
200
  return {
196
201
  systemPrompts: [...config.systemPrompts, ...additions],
197
- tools: [...config.tools, ...tools],
202
+ tools:
203
+ extraTools.length > 0
204
+ ? [...config.tools, ...extraTools]
205
+ : config.tools,
198
206
  } satisfies Partial<ChatMiddlewareConfig>
199
207
  },
200
208