feat(ai, ai-memory): server-side memory middleware with pluggable adapters - #541
Conversation
- WeakMap-keyed per-request state to prevent cross-request leak
when memoryMiddleware is reused (matches otel middleware pattern)
- scopeMatches treats empty scope as 'no match' to prevent
clear({}) / search({scope:{}}) cross-tenant wipes
- Wrap deferred persist + tool-result writes so strict-mode failures
surface via Promise.allSettled instead of being silently swallowed
- applyOps applies ops in array order; updates after adds in the
same batch now find the inserted record
- shouldRemember gates the entire turn (including extractMemories)
matching its documented JSDoc
- Add empty-scope safety tests to the shared adapter contract suite
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds Memory documentation, navigation, release metadata, public API declarations, devtools event declarations, and workspace configuration for the new memory middleware and adapter package. ChangesMemory Documentation and Release Metadata
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🚀 Changeset Version Preview13 package(s) bumped directly, 36 bumped as dependents. 🟥 Major bumps
🟨 Minor bumps
🟩 Patch bumps
|
|
View your CI Pipeline Execution ↗ for commit 12946cc
☁️ Nx Cloud last updated this comment at |
@tanstack/ai
@tanstack/ai-acp
@tanstack/ai-angular
@tanstack/ai-anthropic
@tanstack/ai-bedrock
@tanstack/ai-claude-code
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-codex
@tanstack/ai-devtools-core
@tanstack/ai-durable-stream
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-grok-build
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-mcp
@tanstack/ai-memory
@tanstack/ai-mistral
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-opencode
@tanstack/ai-openrouter
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-sandbox
@tanstack/ai-sandbox-cloudflare
@tanstack/ai-sandbox-daytona
@tanstack/ai-sandbox-docker
@tanstack/ai-sandbox-local-process
@tanstack/ai-sandbox-sprites
@tanstack/ai-sandbox-vercel
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-utils
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/openai-base
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
There was a problem hiding this comment.
Actionable comments posted: 12
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/guides/memory-quickstart.md`:
- Around line 31-34: The blockquote contains an internal blank line triggering
MD028; remove the blank line so the two adapter descriptions are contiguous
within the same blockquote. Edit the block that mentions inMemoryMemoryAdapter()
and redisMemoryAdapter({ redis }) to combine the paragraphs (no empty line
between them) so the blockquote has no blank lines while preserving both
descriptions and formatting.
- Around line 135-136: The two markdown links titled "[In-memory adapter skill]"
and "[Redis adapter skill]" currently point to the repository root; update their
hrefs to the specific adapter skill documentation pages for the packages
`tanstack-ai-memory-in-memory` and `tanstack-ai-memory-redis` respectively so
the links go directly to each adapter's docs (replace the root URLs in those
link entries with the correct doc targets) and run the pnpm test:docs validation
to confirm they are valid.
In `@packages/typescript/ai-memory/package.json`:
- Line 45: The dependency for `@tanstack/ai` in packages/typescript/ai-memory's
package.json uses the workspace:^ protocol; update that dependency string to use
the workspace:* protocol instead (i.e., change the version specifier for
"@tanstack/ai" to "workspace:*") so it conforms to the repo guideline for
internal package dependencies.
- Around line 15-20: The package.json currently only exports the package root;
add a `/adapters` subpath export so provider adapter packages can be
tree-shaken. Update the "exports" object to include an entry for "./adapters/*"
that maps "types" to "./dist/esm/adapters/*.d.ts" and "import" to
"./dist/esm/adapters/*.js" (mirroring the root export shape), ensuring adapter
consumers can import specific adapters like "ai-memory/adapters/<adapter>".
In `@packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md`:
- Around line 35-38: The fenced code block containing the Redis key patterns
({prefix}:record:{memoryId} and
{prefix}:index:{tenantId}:{userId}:{sessionId}:{threadId}:{namespace}) should
include a language tag to satisfy MD040; update the fence in SKILL.md so the
opening triple backticks are followed by "text" (i.e., ```text) so the block is
treated as plain text and the lint warning stops firing.
In `@packages/typescript/ai-memory/src/adapters/redis.ts`:
- Around line 207-215: The delete method currently removes index entries using
the query scope key (indexKey(scope)) which is incorrect; update the loop in
async delete(ids, scope) to call redis.srem(indexKey(r.scope), id) after loading
the record (r) and verifying scopeMatches(r.scope, scope), so the removal
targets the record's actual index key; ensure you still call await
redis.del(recordKey(id)) and only call srem when r exists and scopeMatches
passes.
In `@packages/typescript/ai-memory/tests/in-memory.test.ts`:
- Around line 1-2: Imports are out of order per the project's import/order rule;
swap the two import statements so inMemoryMemoryAdapter (from
'../src/adapters/in-memory') is imported before runMemoryAdapterContract (from
'./contract') to satisfy ESLint; locate the imports for runMemoryAdapterContract
and inMemoryMemoryAdapter at the top of
packages/typescript/ai-memory/tests/in-memory.test.ts and reorder them
accordingly.
In `@packages/typescript/ai-memory/tests/redis.test.ts`:
- Around line 5-6: The imports in the test file violate the import/order rule;
reorder them so external and shared-module imports come before local relative
imports — specifically import runMemoryAdapterContract before importing
redisMemoryAdapter — ensuring the import of runMemoryAdapterContract appears
above the import of ../src/adapters/redis (symbols: runMemoryAdapterContract,
redisMemoryAdapter) to satisfy the configured ordering.
In `@packages/typescript/ai-memory/tsconfig.json`:
- Around line 6-7: The tsconfig currently includes "vite.config.ts" but the
exclude pattern "**/*.config.ts" removes it; update tsconfig.json so the include
and exclude don't conflict—either remove "**/*.config.ts" from the "exclude"
array or narrow the exclude pattern so it doesn't match "vite.config.ts" (i.e.
keep "include": ["vite.config.ts", "./src", "./tests"] and adjust the "exclude":
["node_modules", "dist", ...] accordingly) to ensure vite.config.ts is
type-checked.
In `@packages/typescript/ai/src/memory/middleware.ts`:
- Around line 152-181: onAfterToolCall currently defers deferredApplyOps
immediately, so tool-result memories are written even when shouldRemember later
blocks turn persistence; change this to collect and store the ops instead of
applying them: parse and normalize the returned ops from options.onToolResult
using normalizeOps(out), assign them to a new/existing state field (e.g.,
state.pendingToolOps or merge into state.pendingToolOps array), and remove the
immediate ctx.defer(deferredApplyOps(...)) call; then update persistTurn to
accept pendingToolOps and only call/ctx.defer
deferredApplyOps(normalizeOps(pendingToolOps)) when shouldRemember passes
(ensuring the wrapper that emits memory:error and fires events.onError remains
intact). Apply the same change to the other handler mentioned (the block around
the other deferredApplyOps at lines 347-353).
- Around line 80-87: The devtools event payload leaks the full prompt because
safeEmit('memory:retrieve:started', ...) uses state.lastUserText directly;
truncate that value to a 200-character preview before emitting. Update the
memory middleware so the emitted query uses a capped string (e.g., preview =
state.lastUserText?.slice(0,200) or similar) and keep the original
state.lastUserText unchanged for internal logic; change only the emitted payload
passed to safeEmit.
In `@packages/typescript/ai/tests/middlewares/memory.test.ts`:
- Around line 1-567: Reorder and alphabetize the imports so the test helper
import (the named import containing ev, collectChunks and createMockAdapter)
comes before the type imports and ensure createMockAdapter is alphabetized
inside that import, and convert all bracket-style array types to generic form
(e.g. MemoryRecord[] → Array<MemoryRecord>, MemoryHit[] → Array<MemoryHit>,
MemoryQuery[] → Array<MemoryQuery>, MemoryListResult[] →
Array<MemoryListResult>, MemorySearchResult[] → Array<MemorySearchResult>,
StreamChunk[] → Array<StreamChunk>, etc.) across the file (look for usages in
fakeAdapter, rec, tests, and type annotations such as the return types and
variables referencing store/hits/iterations) to satisfy the project's
Array<Type> style rule.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d4b7f25f-3933-42c9-b1e8-0d0003ed96af
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (27)
.changeset/memory-middleware.mddocs/config.jsondocs/guides/memory-quickstart.mddocs/middlewares/memory.mdknip.jsonpackages/typescript/ai-event-client/src/index.tspackages/typescript/ai-memory/package.jsonpackages/typescript/ai-memory/project.jsonpackages/typescript/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.mdpackages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.mdpackages/typescript/ai-memory/src/adapters/in-memory.tspackages/typescript/ai-memory/src/adapters/redis.tspackages/typescript/ai-memory/src/index.tspackages/typescript/ai-memory/tests/contract.tspackages/typescript/ai-memory/tests/in-memory.test.tspackages/typescript/ai-memory/tests/redis.test.tspackages/typescript/ai-memory/tsconfig.jsonpackages/typescript/ai-memory/vite.config.tspackages/typescript/ai/package.jsonpackages/typescript/ai/skills/tanstack-ai-memory/SKILL.mdpackages/typescript/ai/src/memory/helpers.tspackages/typescript/ai/src/memory/index.tspackages/typescript/ai/src/memory/middleware.tspackages/typescript/ai/src/memory/types.tspackages/typescript/ai/tests/memory/helpers.test.tspackages/typescript/ai/tests/middlewares/memory.test.tspackages/typescript/ai/vite.config.ts
| - [In-memory adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-in-memory` (when to use, capacity limits) | ||
| - [Redis adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-redis` (vector search, key layout, ops) |
There was a problem hiding this comment.
Replace placeholder repo-root links with direct targets.
Line 135-136 currently send users to the repository root, not the adapter skill docs.
Proposed fix
-- [In-memory adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-in-memory` (when to use, capacity limits)
-- [Redis adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-redis` (vector search, key layout, ops)
+- [In-memory adapter skill](https://github.com/TanStack/ai/blob/main/packages/typescript/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md) — `tanstack-ai-memory-in-memory` (when to use, capacity limits)
+- [Redis adapter skill](https://github.com/TanStack/ai/blob/main/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md) — `tanstack-ai-memory-redis` (vector search, key layout, ops)As per coding guidelines, "Verify documentation links are valid via pnpm test:docs command."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| - [In-memory adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-in-memory` (when to use, capacity limits) | |
| - [Redis adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-redis` (vector search, key layout, ops) | |
| - [In-memory adapter skill](https://github.com/TanStack/ai/blob/main/packages/typescript/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md) — `tanstack-ai-memory-in-memory` (when to use, capacity limits) | |
| - [Redis adapter skill](https://github.com/TanStack/ai/blob/main/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md) — `tanstack-ai-memory-redis` (vector search, key layout, ops) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/guides/memory-quickstart.md` around lines 135 - 136, The two markdown
links titled "[In-memory adapter skill]" and "[Redis adapter skill]" currently
point to the repository root; update their hrefs to the specific adapter skill
documentation pages for the packages `tanstack-ai-memory-in-memory` and
`tanstack-ai-memory-redis` respectively so the links go directly to each
adapter's docs (replace the root URLs in those link entries with the correct doc
targets) and run the pnpm test:docs validation to confirm they are valid.
| "rag" | ||
| ], | ||
| "peerDependencies": { | ||
| "@tanstack/ai": "workspace:^", |
There was a problem hiding this comment.
Use workspace:* for internal package dependency.
Line 45 uses workspace:^ for @tanstack/ai; repo guidelines require workspace:*.
Proposed fix
- "@tanstack/ai": "workspace:^",
+ "@tanstack/ai": "workspace:*",As per coding guidelines, "packages/**/package.json: Use workspace:* protocol for internal package dependencies in package.json."
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "@tanstack/ai": "workspace:^", | |
| "@tanstack/ai": "workspace:*", |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/typescript/ai-memory/package.json` at line 45, The dependency for
`@tanstack/ai` in packages/typescript/ai-memory's package.json uses the
workspace:^ protocol; update that dependency string to use the workspace:*
protocol instead (i.e., change the version specifier for "@tanstack/ai" to
"workspace:*") so it conforms to the repo guideline for internal package
dependencies.
| // packages/typescript/ai/tests/middlewares/memory.test.ts | ||
| import { describe, expect, it, vi } from 'vitest' | ||
| import { aiEventClient } from '@tanstack/ai-event-client' | ||
| import { chat } from '../../src/activities/chat/index' | ||
| import { memoryMiddleware } from '../../src/memory' | ||
| import type { | ||
| MemoryAdapter, | ||
| MemoryHit, | ||
| MemoryListResult, | ||
| MemoryQuery, | ||
| MemoryRecord, | ||
| MemoryScope, | ||
| MemorySearchResult, | ||
| } from '../../src/memory' | ||
| import type { StreamChunk } from '../../src/types' | ||
| import { ev, createMockAdapter, collectChunks } from '../test-utils' | ||
|
|
||
| // Local test double — keeps tests isolated from @tanstack/ai-memory. | ||
| function fakeAdapter(seed: MemoryRecord[] = []): MemoryAdapter & { | ||
| store: Map<string, MemoryRecord> | ||
| searchCalls: MemoryQuery[] | ||
| } { | ||
| const store = new Map<string, MemoryRecord>() | ||
| for (const r of seed) store.set(r.id, r) | ||
| const searchCalls: MemoryQuery[] = [] | ||
| return { | ||
| name: 'fake', | ||
| store, | ||
| searchCalls, | ||
| async add(input) { | ||
| const list = Array.isArray(input) ? input : [input] | ||
| for (const r of list) store.set(r.id, { ...r, updatedAt: Date.now() }) | ||
| }, | ||
| async get(id, scope) { | ||
| const r = store.get(id) | ||
| if (!r) return undefined | ||
| // simple scope check | ||
| for (const k of Object.keys(scope) as Array<keyof MemoryScope>) { | ||
| if (scope[k] && r.scope[k] !== scope[k]) return undefined | ||
| } | ||
| return r | ||
| }, | ||
| async update(id, scope, patch) { | ||
| const existing = await this.get(id, scope) | ||
| if (!existing) return undefined | ||
| const next = { ...existing, ...patch, updatedAt: Date.now() } | ||
| store.set(id, next) | ||
| return next | ||
| }, | ||
| async search(query): Promise<MemorySearchResult> { | ||
| searchCalls.push(query) | ||
| const hits: MemoryHit[] = [] | ||
| for (const r of store.values()) { | ||
| let match = true | ||
| for (const k of Object.keys(query.scope) as Array<keyof MemoryScope>) { | ||
| if (query.scope[k] && r.scope[k] !== query.scope[k]) { | ||
| match = false | ||
| break | ||
| } | ||
| } | ||
| if (!match) continue | ||
| if (query.kinds && !query.kinds.includes(r.kind)) continue | ||
| hits.push({ record: r, score: 0.9 }) | ||
| } | ||
| return { hits: hits.slice(0, query.topK ?? 6) } | ||
| }, | ||
| async list(scope, options): Promise<MemoryListResult> { | ||
| const items: MemoryRecord[] = [] | ||
| for (const r of store.values()) { | ||
| let match = true | ||
| for (const k of Object.keys(scope) as Array<keyof MemoryScope>) { | ||
| if (scope[k] && r.scope[k] !== scope[k]) { | ||
| match = false | ||
| break | ||
| } | ||
| } | ||
| if (match) items.push(r) | ||
| } | ||
| return { items: items.slice(0, options?.limit ?? items.length) } | ||
| }, | ||
| async delete(ids) { | ||
| for (const id of ids) store.delete(id) | ||
| }, | ||
| async clear() { | ||
| store.clear() | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| const baseScope: MemoryScope = { tenantId: 't1', userId: 'u1' } | ||
|
|
||
| function rec(over: Partial<MemoryRecord> = {}): MemoryRecord { | ||
| return { | ||
| id: over.id ?? crypto.randomUUID(), | ||
| scope: over.scope ?? baseScope, | ||
| text: over.text ?? 'sample', | ||
| kind: over.kind ?? 'fact', | ||
| createdAt: over.createdAt ?? Date.now(), | ||
| ...over, | ||
| } | ||
| } | ||
|
|
||
| describe('memoryMiddleware — retrieval', () => { | ||
| it('is a no-op when there is no user message', async () => { | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('hi'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const memory = fakeAdapter([rec({ text: 'X' })]) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [], | ||
| middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(memory.searchCalls).toHaveLength(0) | ||
| }) | ||
|
|
||
| it('retrieves at init and injects a memory system prompt', async () => { | ||
| const memory = fakeAdapter([ | ||
| rec({ text: 'User likes TS.', kind: 'preference' }), | ||
| ]) | ||
| const { adapter, calls } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'hi' }], | ||
| middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| const first = calls[0] as { systemPrompts?: string[] } | ||
| expect(first.systemPrompts?.some((p) => p.includes('User likes TS.'))).toBe( | ||
| true, | ||
| ) | ||
| }) | ||
|
|
||
| it('does not re-inject across agent-loop iterations', async () => { | ||
| const memory = fakeAdapter([rec({ text: 'X' })]) | ||
| const { adapter, calls } = createMockAdapter({ | ||
| iterations: [ | ||
| [ | ||
| ev.runStarted(), | ||
| ev.toolStart('c1', 't'), | ||
| ev.toolArgs('c1', '{}'), | ||
| ev.toolEnd('c1', 't'), | ||
| ev.runFinished('tool_calls'), | ||
| ], | ||
| [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'hi' }], | ||
| tools: [{ name: 't', description: 'noop', execute: async () => ({}) }], | ||
| middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| const iter1 = | ||
| (calls[0] as { systemPrompts?: string[] }).systemPrompts?.length ?? 0 | ||
| const iter2 = | ||
| (calls[1] as { systemPrompts?: string[] }).systemPrompts?.length ?? 0 | ||
| expect(iter1).toBe(iter2) | ||
| }) | ||
|
|
||
| it('skips retrieval and injection when shouldRetrieve returns false', async () => { | ||
| const memory = fakeAdapter([rec({ text: 'X' })]) | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'hi' }], | ||
| middleware: [ | ||
| memoryMiddleware({ | ||
| adapter: memory, | ||
| scope: baseScope, | ||
| shouldRetrieve: () => false, | ||
| }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(memory.searchCalls).toHaveLength(0) | ||
| }) | ||
|
|
||
| it('calls rerank between search and render', async () => { | ||
| const memory = fakeAdapter([ | ||
| rec({ id: 'a', text: 'A' }), | ||
| rec({ id: 'b', text: 'B' }), | ||
| ]) | ||
| const { adapter, calls } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const rerank = vi.fn(async (hits: MemoryHit[]) => [...hits].reverse()) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'hi' }], | ||
| middleware: [ | ||
| memoryMiddleware({ adapter: memory, scope: baseScope, rerank }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(rerank).toHaveBeenCalledTimes(1) | ||
| const promptText = ( | ||
| calls[0] as { systemPrompts: string[] } | ||
| ).systemPrompts.join('\n') | ||
| expect(promptText.indexOf('B')).toBeLessThan(promptText.indexOf('A')) | ||
| }) | ||
|
|
||
| it('resolves function-form scope once and caches it', async () => { | ||
| const memory = fakeAdapter([rec({ text: 'X' })]) | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const scopeFn = vi.fn(() => baseScope) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'hi' }], | ||
| middleware: [memoryMiddleware({ adapter: memory, scope: scopeFn })], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(scopeFn).toHaveBeenCalledTimes(1) | ||
| }) | ||
| }) | ||
|
|
||
| describe('memoryMiddleware — persistence', () => { | ||
| it('persists user and assistant messages on finish', async () => { | ||
| const memory = fakeAdapter() | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('Pong.'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'Ping' }], | ||
| middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| const texts = [...memory.store.values()].map((r) => r.text).sort() | ||
| expect(texts).toEqual(['Ping', 'Pong.']) | ||
| }) | ||
|
|
||
| it('shouldRemember=false skips the entire turn (base records and extractMemories)', async () => { | ||
| // Per-turn semantics: shouldRemember is evaluated ONCE per turn and | ||
| // gates the whole persist path. The user message is short ("hi", 2 | ||
| // chars) so the gate returns false and NOTHING is persisted — the | ||
| // assistant message is dropped too, and `extractMemories` is never | ||
| // called. | ||
| const memory = fakeAdapter() | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ | ||
| ev.runStarted(), | ||
| ev.textContent('long enough response text'), | ||
| ev.runFinished('stop'), | ||
| ], | ||
| ], | ||
| }) | ||
| const extractMemories = vi.fn(async () => [ | ||
| rec({ text: 'should not run', kind: 'fact' }), | ||
| ]) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'hi' }], | ||
| middleware: [ | ||
| memoryMiddleware({ | ||
| adapter: memory, | ||
| scope: baseScope, | ||
| shouldRemember: ({ message }) => message.content.length > 10, | ||
| extractMemories, | ||
| }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect([...memory.store.values()]).toEqual([]) | ||
| expect(extractMemories).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('shouldRemember=true persists user, assistant, and extracted records', async () => { | ||
| const memory = fakeAdapter() | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ | ||
| ev.runStarted(), | ||
| ev.textContent('long enough response text'), | ||
| ev.runFinished('stop'), | ||
| ], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'a meaningful user message' }], | ||
| middleware: [ | ||
| memoryMiddleware({ | ||
| adapter: memory, | ||
| scope: baseScope, | ||
| // 25-char user message + non-empty response — gate keeps the turn. | ||
| shouldRemember: ({ message }) => message.content.length > 10, | ||
| }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| const texts = [...memory.store.values()].map((r) => r.text).sort() | ||
| expect(texts).toEqual([ | ||
| 'a meaningful user message', | ||
| 'long enough response text', | ||
| ]) | ||
| }) | ||
|
|
||
| it('extractMemories returning records adds them as kind: fact', async () => { | ||
| const memory = fakeAdapter() | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const extractMemories = vi.fn(async () => [ | ||
| rec({ text: 'extracted', kind: 'fact' }), | ||
| ]) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'U' }], | ||
| middleware: [ | ||
| memoryMiddleware({ | ||
| adapter: memory, | ||
| scope: baseScope, | ||
| extractMemories, | ||
| }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(extractMemories).toHaveBeenCalledTimes(1) | ||
| const kinds = [...memory.store.values()].map((r) => r.kind).sort() | ||
| expect(kinds).toEqual(['fact', 'message', 'message']) | ||
| }) | ||
|
|
||
| it('extractMemories MemoryOp[] dispatches to add/update/delete', async () => { | ||
| const existing = rec({ id: 'old', text: 'old text', kind: 'fact' }) | ||
| const memory = fakeAdapter([existing]) | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'U' }], | ||
| middleware: [ | ||
| memoryMiddleware({ | ||
| adapter: memory, | ||
| scope: baseScope, | ||
| extractMemories: () => [ | ||
| { op: 'add', record: rec({ text: 'new fact', kind: 'fact' }) }, | ||
| { op: 'update', id: 'old', patch: { text: 'updated text' } }, | ||
| ], | ||
| }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(memory.store.get('old')?.text).toBe('updated text') | ||
| expect([...memory.store.values()].some((r) => r.text === 'new fact')).toBe( | ||
| true, | ||
| ) | ||
| }) | ||
|
|
||
| it('applies ops in array order: update after add in same batch sees the add', async () => { | ||
| // Order-sensitivity regression test. Previously, all `add` ops were | ||
| // batched and flushed at the END after updates/deletes, meaning an | ||
| // `update` of an id added in the SAME batch silently no-op'd. With | ||
| // strict in-order dispatch the update now sees the just-added record. | ||
| const memory = fakeAdapter() | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'U' }], | ||
| middleware: [ | ||
| memoryMiddleware({ | ||
| adapter: memory, | ||
| scope: baseScope, | ||
| extractMemories: () => [ | ||
| { | ||
| op: 'add', | ||
| record: rec({ id: 'X', text: 'initial', kind: 'fact' }), | ||
| }, | ||
| { op: 'update', id: 'X', patch: { text: 'patched' } }, | ||
| ], | ||
| }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(memory.store.get('X')?.text).toBe('patched') | ||
| }) | ||
|
|
||
| it('afterPersist receives newly-added records (not updates/deletes)', async () => { | ||
| const memory = fakeAdapter() | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const afterPersist = vi.fn() | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'U' }], | ||
| middleware: [ | ||
| memoryMiddleware({ adapter: memory, scope: baseScope, afterPersist }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(afterPersist).toHaveBeenCalledTimes(1) | ||
| const arg = afterPersist.mock.calls[0]?.[0] as | ||
| | { newRecords: MemoryRecord[] } | ||
| | undefined | ||
| expect(arg?.newRecords.length).toBe(2) // user + assistant | ||
| }) | ||
|
|
||
| it('onToolResult persists kind: tool-result records', async () => { | ||
| const memory = fakeAdapter() | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ | ||
| ev.runStarted(), | ||
| ev.toolStart('c1', 'echo'), | ||
| ev.toolArgs('c1', '{}'), | ||
| ev.toolEnd('c1', 'echo'), | ||
| ev.runFinished('tool_calls'), | ||
| ], | ||
| [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'U' }], | ||
| tools: [ | ||
| { name: 'echo', description: 'noop', execute: async () => ({ ok: 1 }) }, | ||
| ], | ||
| middleware: [ | ||
| memoryMiddleware({ | ||
| adapter: memory, | ||
| scope: baseScope, | ||
| onToolResult: ({ toolName, result }) => [ | ||
| rec({ | ||
| text: `${toolName}:${JSON.stringify(result)}`, | ||
| kind: 'tool-result', | ||
| role: 'tool', | ||
| }), | ||
| ], | ||
| }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| const toolResults = [...memory.store.values()].filter( | ||
| (r) => r.kind === 'tool-result', | ||
| ) | ||
| expect(toolResults).toHaveLength(1) | ||
| expect(toolResults[0]?.text).toContain('echo') | ||
| }) | ||
| }) | ||
|
|
||
| describe('memoryMiddleware — failure handling', () => { | ||
| it('non-strict: retrieval failure does not abort chat', async () => { | ||
| const memory = fakeAdapter() | ||
| memory.search = async () => { | ||
| throw new Error('boom') | ||
| } | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'hi' }], | ||
| middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], | ||
| }) | ||
| const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(chunks.some((c) => c.type === 'TEXT_MESSAGE_CONTENT')).toBe(true) | ||
| }) | ||
|
|
||
| it('strict: retrieval failure rejects the stream', async () => { | ||
| const memory = fakeAdapter() | ||
| memory.search = async () => { | ||
| throw new Error('boom') | ||
| } | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'hi' }], | ||
| middleware: [ | ||
| memoryMiddleware({ adapter: memory, scope: baseScope, strict: true }), | ||
| ], | ||
| }) | ||
| await expect( | ||
| collectChunks(stream as AsyncIterable<StreamChunk>), | ||
| ).rejects.toThrow('boom') | ||
| }) | ||
| }) | ||
|
|
||
| describe('memoryMiddleware — devtools events', () => { | ||
| it('emits retrieve and persist events in order', async () => { | ||
| const memory = fakeAdapter([rec({ text: 'X' })]) | ||
| const { adapter } = createMockAdapter({ | ||
| iterations: [ | ||
| [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], | ||
| ], | ||
| }) | ||
| const seen: string[] = [] | ||
| const opts = { withEventTarget: true } as const | ||
| const off1 = aiEventClient.on( | ||
| 'memory:retrieve:started', | ||
| () => seen.push('retrieve:started'), | ||
| opts, | ||
| ) | ||
| const off2 = aiEventClient.on( | ||
| 'memory:retrieve:completed', | ||
| () => seen.push('retrieve:completed'), | ||
| opts, | ||
| ) | ||
| const off3 = aiEventClient.on( | ||
| 'memory:persist:started', | ||
| () => seen.push('persist:started'), | ||
| opts, | ||
| ) | ||
| const off4 = aiEventClient.on( | ||
| 'memory:persist:completed', | ||
| () => seen.push('persist:completed'), | ||
| opts, | ||
| ) | ||
| try { | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'U' }], | ||
| middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(seen).toEqual([ | ||
| 'retrieve:started', | ||
| 'retrieve:completed', | ||
| 'persist:started', | ||
| 'persist:completed', | ||
| ]) | ||
| } finally { | ||
| off1() | ||
| off2() | ||
| off3() | ||
| off4() | ||
| } | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Fix ESLint violations for array types and import order.
Static analysis flagged multiple style violations:
- Import order (line 16): The
test-utilsimport should come before type imports. - Import sorting (line 16):
createMockAdaptershould be sorted alphabetically. - Array type style (19 instances): Use
Array<Type>instead ofType[]to match project conventions.
📝 Proposed fixes
Fix import order and sorting (lines 2-16):
import { describe, expect, it, vi } from 'vitest'
import { aiEventClient } from '@tanstack/ai-event-client'
+import { collectChunks, createMockAdapter, ev } from '../test-utils'
import { chat } from '../../src/activities/chat/index'
import { memoryMiddleware } from '../../src/memory'
import type {
MemoryAdapter,
MemoryHit,
MemoryListResult,
MemoryQuery,
MemoryRecord,
MemoryScope,
MemorySearchResult,
} from '../../src/memory'
import type { StreamChunk } from '../../src/types'
-import { ev, createMockAdapter, collectChunks } from '../test-utils'Fix array types (apply throughout file):
-function fakeAdapter(seed: MemoryRecord[] = []): MemoryAdapter & {
+function fakeAdapter(seed: Array<MemoryRecord> = []): MemoryAdapter & {
store: Map<string, MemoryRecord>
- searchCalls: MemoryQuery[]
+ searchCalls: Array<MemoryQuery>
} {Repeat this pattern for all 19 flagged array type violations.
🧰 Tools
🪛 ESLint
[error] 16-16: ../test-utils import should occur before type import of ../../src/memory
(import/order)
[error] 16-16: Member 'createMockAdapter' of the import declaration should be sorted alphabetically.
(sort-imports)
[error] 19-19: Array type using 'MemoryRecord[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 21-21: Array type using 'MemoryQuery[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 25-25: Array type using 'MemoryQuery[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 52-52: Array type using 'MemoryHit[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 68-68: Array type using 'MemoryRecord[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 135-135: Array type using 'string[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 163-163: Array type using 'string[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 165-165: Array type using 'string[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 201-201: Array type using 'MemoryHit[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 212-212: Array type using 'string[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 426-426: Array type using 'MemoryRecord[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
[error] 525-525: Array type using 'string[]' is forbidden. Use 'Array' instead.
(@typescript-eslint/array-type)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/typescript/ai/tests/middlewares/memory.test.ts` around lines 1 -
567, Reorder and alphabetize the imports so the test helper import (the named
import containing ev, collectChunks and createMockAdapter) comes before the type
imports and ensure createMockAdapter is alphabetized inside that import, and
convert all bracket-style array types to generic form (e.g. MemoryRecord[] →
Array<MemoryRecord>, MemoryHit[] → Array<MemoryHit>, MemoryQuery[] →
Array<MemoryQuery>, MemoryListResult[] → Array<MemoryListResult>,
MemorySearchResult[] → Array<MemorySearchResult>, StreamChunk[] →
Array<StreamChunk>, etc.) across the file (look for usages in fakeAdapter, rec,
tests, and type annotations such as the return types and variables referencing
store/hits/iterations) to satisfy the project's Array<Type> style rule.
- getMessageText reads ContentPart.content (not .text); structured user messages now feed retrieval and persistence correctly - extractMemories strict-mode failure no longer double-emits memory:error or drops base user/assistant records - defaultScoreHit threads its 'now' parameter through to recencyScore so callers can score deterministically - Default importance contribution drops from 0.5 to 0 so a recent record with zero lexical/semantic match no longer clears the default minScore=0.15 floor - MemoryAdapter.clear/search/list JSDoc fixed: empty scope matches NOTHING (matches scopeMatches and the contract suite)
- search/list/clear with a partial scope now traverse all matching index buckets via SCAN instead of just the exact-match bucket - delete srem now keys off record scope (not caller scope) so ids in narrower index buckets are properly cleaned up - add upsert removes the id from the old scope's index when the scope of an existing record changes - skill troubleshooting drops the false SerializationError claim; malformed rows now log once per process via console.warn - Contract suite gains 5 partial-scope tests covering search, list, clear, delete, and upsert; in-memory and redis must both pass them
The RedisLike interface is lowercase to match ioredis directly. node-redis v4+ uses camelCase by default, which previously required users to enable legacyMode to wire it up. The new nodeRedisAsRedisLike(client) helper translates camelCase to the RedisLike shape so users can wire node-redis v4+ default-mode clients with a one-line wrapper. Skill and quickstart docs updated with separate ioredis vs node-redis wiring examples. ioredis added as a parallel optional peer dep alongside redis. New unit test for the helper.
- Move docs/middlewares/memory.md -> docs/memory/overview.md (rename frontmatter title to 'Overview') - Move docs/guides/memory-quickstart.md -> docs/memory/quickstart.md - Add docs/memory/custom-adapter.md authoring guide covering the 8-member contract, the three isolation invariants, the shared contract suite, common pitfalls (delimiter escaping, atomicity, partial-scope cascade), and packaging conventions - Replace single-child 'Middlewares' and 'Guides' sidebar sections with a unified 'Memory' section - Rewire internal cross-links between the three pages
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (3)
docs/memory/quickstart.md (1)
31-33:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winRemove the blank line inside this blockquote.
This still triggers MD028 /
no-blanks-blockquote, so docs lint will keep flagging the section until the two quoted paragraphs are contiguous.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/memory/quickstart.md` around lines 31 - 33, Remove the blank line between the two blockquote paragraphs so they become a single contiguous blockquote; specifically edit the section containing the references to inMemoryMemoryAdapter() and redisMemoryAdapter({ redis }) and delete the empty line separating those two quoted paragraphs so MD028/no-blanks-blockquote is no longer triggered.packages/typescript/ai-memory/src/adapters/redis.ts (2)
446-458:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winSnapshot
nowonce before scoring.
defaultScoreHit()falls back toDate.now(), so later candidates in the same search get a slightly smaller recency contribution than earlier ones. That makes near-tie ordering depend on iteration speed and can destabilize cursor pagination.Suggested fix
async search(query: MemoryQuery): Promise<MemorySearchResult> { const records = await loadAllForScope(query.scope) + const now = Date.now() const candidates = records.filter((r) => { if (query.kinds?.length && !query.kinds.includes(r.kind)) return false return true }) @@ const scored = candidates .map((record) => ({ record, - score: defaultScoreHit({ record, query }), + score: defaultScoreHit({ record, query, now }), }))🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/typescript/ai-memory/src/adapters/redis.ts` around lines 446 - 458, In search() capture a single timestamp before scoring (e.g., const now = Date.now()) and use that same now when computing scores so all candidates use the same recency baseline; update the scored mapping to call defaultScoreHit with the captured now (defaultScoreHit({ record, query, now })) or, if its signature doesn't accept now yet, add an optional now parameter to defaultScoreHit and pass it through from search() so scoring is stable across the iteration.
365-375:⚠️ Potential issue | 🟠 Major | ⚡ Quick winSweep malformed rows from Redis instead of re-skipping them forever.
The catch path only warns and continues, so the bad payload and its stale set member survive every later
search()/list()call. Treat malformed rows like missing/expired rows here and reuse the existing cleanup path.Suggested fix
} catch (err) { warnMalformedRowOnce(id, err) - /* skip malformed */ + markExpired(id) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/typescript/ai-memory/src/adapters/redis.ts` around lines 365 - 375, The catch block currently calls warnMalformedRowOnce(id, err) and continues, leaving the bad Redis payload in place; change the catch path to treat malformed rows like missing/expired ones by calling markExpired(id) after warnMalformedRowOnce(id, err) (then continue) so the stale set member is cleaned up; locate this in the search()/list() loop where JSON.parse(raw) is wrapped and update the catch to call markExpired with the same id used elsewhere.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/memory/custom-adapter.md`:
- Around line 242-244: The docs currently claim that runMemoryAdapterContract()
covers middleware-level extractMemories/resolved-scope behavior but the shared
test suite doesn't exercise that; either remove or soften the sentence
referencing “the resolved-scope override for records returned by
extractMemories” from the paragraph, or add explicit tests to the contract suite
that invoke extractMemories through the middleware path and assert the
resolved-scope override behavior (i.e., extend runMemoryAdapterContract with a
new test that calls extractMemories and verifies resolved-scope on returned
records). Ensure you reference the runMemoryAdapterContract and extractMemories
symbols when implementing the change.
- Around line 190-198: The WHERE clause currently uses "IS NOT DISTINCT FROM $N"
for partial-scope parameters which enforces exact NULL-matching; replace those
conditions for tenant_id, user_id, session_id, thread_id, and namespace so each
reads like "(column = $N OR $N IS NULL)" (i.e., for $2..$6 use tenant_id,
user_id, session_id, thread_id, namespace respectively) to implement
partial-scope semantics; keep the existing expires_at and kind checks and the
rest of the query (ORDER BY score DESC OFFSET $9 LIMIT $10) unchanged.
In `@docs/memory/quickstart.md`:
- Line 75: The link text points to the repository root instead of the specific
node-redis example; update the markdown link target so the "Redis adapter skill"
reference goes directly to the node-redis integration example that demonstrates
nodeRedisAsRedisLike (the example showing how to wrap a node-redis client to
satisfy the RedisLike contract), ensuring the anchor/URL points to that example
file/section in the repo so readers find the node-redis usage and integration
steps.
---
Duplicate comments:
In `@docs/memory/quickstart.md`:
- Around line 31-33: Remove the blank line between the two blockquote paragraphs
so they become a single contiguous blockquote; specifically edit the section
containing the references to inMemoryMemoryAdapter() and redisMemoryAdapter({
redis }) and delete the empty line separating those two quoted paragraphs so
MD028/no-blanks-blockquote is no longer triggered.
In `@packages/typescript/ai-memory/src/adapters/redis.ts`:
- Around line 446-458: In search() capture a single timestamp before scoring
(e.g., const now = Date.now()) and use that same now when computing scores so
all candidates use the same recency baseline; update the scored mapping to call
defaultScoreHit with the captured now (defaultScoreHit({ record, query, now }))
or, if its signature doesn't accept now yet, add an optional now parameter to
defaultScoreHit and pass it through from search() so scoring is stable across
the iteration.
- Around line 365-375: The catch block currently calls warnMalformedRowOnce(id,
err) and continues, leaving the bad Redis payload in place; change the catch
path to treat malformed rows like missing/expired ones by calling
markExpired(id) after warnMalformedRowOnce(id, err) (then continue) so the stale
set member is cleaned up; locate this in the search()/list() loop where
JSON.parse(raw) is wrapped and update the catch to call markExpired with the
same id used elsewhere.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 83bafe3d-ace3-4cc9-b73c-672c03e60d06
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (8)
docs/config.jsondocs/memory/custom-adapter.mddocs/memory/overview.mddocs/memory/quickstart.mdpackages/typescript/ai-memory/src/adapters/redis.tspackages/typescript/ai-memory/tests/contract.tspackages/typescript/ai/src/memory/middleware.tspackages/typescript/ai/tests/memory/helpers.test.ts
✅ Files skipped from review due to trivial changes (2)
- docs/config.json
- docs/memory/overview.md
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/typescript/ai/src/memory/middleware.ts
- middleware.ts: preview-cap memory:retrieve:started query payload (was emitting full lastUserText, breaking the documented 200-char preview contract for devtools events) - helpers.ts: JSON-stringify memory text in defaultRenderMemory so newline-or-instruction-shaped persisted memory cannot break out of the list structure and steer subsequent turns at system priority - middleware.ts: shouldRemember now gates tool-result memories from onToolResult; buffered ops flush inside persistTurn after the gate passes, matching the documented 'short-circuits the entire persist path for the current turn' contract. Persist events now fire once per turn (covers base + extracted + tool-result records together) - redis.ts: malformed JSON rows in loadAllForScope are now swept from the index and record key (was warned-and-skipped, leaving the bad payload to be reparsed on every subsequent read) - redis.ts and in-memory.ts: snapshot now once per search and thread through defaultScoreHit so recency ranking is stable across same- pass candidates
- custom-adapter.md: fix pgvector SQL example to use partial-scope semantics (($N IS NULL OR col = $N)) instead of IS NOT DISTINCT FROM, which had the wrong matching semantics for partial scopes - custom-adapter.md: soften contract suite coverage claim - the shared suite does not exercise middleware-level extractMemories resolved-scope override - quickstart.md: drop blank line in adapter blockquote (MD028); replace placeholder skill link with direct doc + repo SKILL.md link - redis SKILL.md: add 'text' language tag to storage model fence (MD040) - ai-memory package.json: add /adapters/in-memory and /adapters/redis subpath exports per repo convention - ai-memory tsconfig.json: drop **/*.config.ts exclude so vite.config.ts (which is in include) actually gets type-checked - in-memory.test.ts: reorder imports to satisfy import/order - memory.test.ts: tighten the re-inject regression test with expect(iter1).toBeGreaterThan(0) so the assertion catches the case where injection is fully disabled in both iterations
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
packages/typescript/ai-memory/package.json (1)
53-53:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUse
workspace:*for the internal peer dependency version.Line 53 still uses
workspace:^for@tanstack/ai; this should beworkspace:*to match repo policy.Suggested fix
- "@tanstack/ai": "workspace:^", + "@tanstack/ai": "workspace:*",As per coding guidelines, "
packages/**/package.json: Use workspace:* protocol for internal package dependencies in package.json".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/typescript/ai-memory/package.json` at line 53, Update the internal dependency entry for "@tanstack/ai" in packages/typescript/ai-memory/package.json from "workspace:^" to "workspace:*"; locate the dependency key "@tanstack/ai" in the package.json dependencies block and replace the version string so it follows the repo policy of using workspace:* for internal peer dependencies.
🧹 Nitpick comments (3)
packages/typescript/ai/tests/memory/helpers.test.ts (1)
1-10: ⚡ Quick winFix import member sorting to comply with ESLint.
The import members are not sorted alphabetically, violating the project's
sort-importsrule.📝 Proposed fix for import ordering
-import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { - scopeMatches, cosine, - lexicalOverlap, - recencyScore, defaultRenderMemory, defaultScoreHit, isExpired, + lexicalOverlap, + recencyScore, + scopeMatches, } from '../../src/memory/helpers'As per coding guidelines, this addresses the ESLint
sort-importsviolations flagged by static analysis.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/typescript/ai/tests/memory/helpers.test.ts` around lines 1 - 10, The import specifiers in tests/memory/helpers.test.ts are not alphabetically sorted causing an ESLint sort-imports violation; reorder the named imports from '../../src/memory/helpers' so they are alphabetized (for example ensure entries like cosine, defaultRenderMemory, defaultScoreHit, isExpired, lexicalOverlap, recencyScore, scopeMatches appear in lexical order) so the import line imports scopeMatches, cosine, lexicalOverlap, recencyScore, defaultRenderMemory, defaultScoreHit, isExpired in sorted order per project linting rules.packages/typescript/ai/tests/middlewares/memory.test.ts (2)
397-409: ⚡ Quick winThe delete branch is still uncovered here.
The test name says
add/update/delete, but this batch only exercisesaddandupdate. A regression that stops forwardingdeleteops to the adapter would still pass. Please seed a removable record, include adeleteop, and assert it is gone afterward.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/typescript/ai/tests/middlewares/memory.test.ts` around lines 397 - 409, The test currently exercises only add/update paths but should also cover delete; modify the test (the case that calls collectChunks and uses extractMemories) to seed a removable record in memory.store (e.g., insert a record with id 'toRemove' before calling collectChunks), include a delete op in the extractMemories array (op: 'delete', id: 'toRemove'), then after await collectChunks(...) assert that memory.store.get('toRemove') is undefined or that no record with that id exists to ensure the delete branch in the adapter is exercised.
536-549: ⚡ Quick winThis doesn't prove updates/deletes are excluded from
afterPersist.In this setup
afterPersistonly ever sees the two base inserts, so the test would still pass if updated or deleted records were incorrectly included innewRecords. Add at least one update/delete in the same turn and assert the callback receives only the newly added records.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/typescript/ai/tests/middlewares/memory.test.ts` around lines 536 - 549, The test currently only inserts the base two records so it doesn't verify updates/deletes are excluded from afterPersist; modify the test that creates the stream (using chat, memoryMiddleware, afterPersist) to perform at least one update or delete on the memory store within the same turn (e.g., call the memory adapter's upsert/update or delete method targeting one of the baseScope records before awaiting collectChunks), then assert afterPersist was called once and that arg.newRecords includes only the newly inserted user+assistant records (length === 2) and does NOT include the id(s) of the updated/deleted record(s); reference the existing symbols afterPersist, memoryMiddleware, memory (adapter), collectChunks, and MemoryRecord to locate and implement the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/typescript/ai/tests/middlewares/memory.test.ts`:
- Around line 362-379: The test currently uses rec() which pre-fills
kind:'fact', so change the mocked extractor to return a record object that omits
the kind property (e.g., replace rec({ text: 'extracted', kind: 'fact' }) with a
plain record without a kind field or use a helper that doesn't default kind) so
memoryMiddleware is forced to apply the default; keep the same assertions
(extractMemories, collectChunks, and the kinds check) and reference the existing
symbols extractMemories, rec, memoryMiddleware, and the kinds array to locate
and update the mock return value.
---
Duplicate comments:
In `@packages/typescript/ai-memory/package.json`:
- Line 53: Update the internal dependency entry for "@tanstack/ai" in
packages/typescript/ai-memory/package.json from "workspace:^" to "workspace:*";
locate the dependency key "@tanstack/ai" in the package.json dependencies block
and replace the version string so it follows the repo policy of using
workspace:* for internal peer dependencies.
---
Nitpick comments:
In `@packages/typescript/ai/tests/memory/helpers.test.ts`:
- Around line 1-10: The import specifiers in tests/memory/helpers.test.ts are
not alphabetically sorted causing an ESLint sort-imports violation; reorder the
named imports from '../../src/memory/helpers' so they are alphabetized (for
example ensure entries like cosine, defaultRenderMemory, defaultScoreHit,
isExpired, lexicalOverlap, recencyScore, scopeMatches appear in lexical order)
so the import line imports scopeMatches, cosine, lexicalOverlap, recencyScore,
defaultRenderMemory, defaultScoreHit, isExpired in sorted order per project
linting rules.
In `@packages/typescript/ai/tests/middlewares/memory.test.ts`:
- Around line 397-409: The test currently exercises only add/update paths but
should also cover delete; modify the test (the case that calls collectChunks and
uses extractMemories) to seed a removable record in memory.store (e.g., insert a
record with id 'toRemove' before calling collectChunks), include a delete op in
the extractMemories array (op: 'delete', id: 'toRemove'), then after await
collectChunks(...) assert that memory.store.get('toRemove') is undefined or that
no record with that id exists to ensure the delete branch in the adapter is
exercised.
- Around line 536-549: The test currently only inserts the base two records so
it doesn't verify updates/deletes are excluded from afterPersist; modify the
test that creates the stream (using chat, memoryMiddleware, afterPersist) to
perform at least one update or delete on the memory store within the same turn
(e.g., call the memory adapter's upsert/update or delete method targeting one of
the baseScope records before awaiting collectChunks), then assert afterPersist
was called once and that arg.newRecords includes only the newly inserted
user+assistant records (length === 2) and does NOT include the id(s) of the
updated/deleted record(s); reference the existing symbols afterPersist,
memoryMiddleware, memory (adapter), collectChunks, and MemoryRecord to locate
and implement the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e0f9355e-00e5-4596-96a9-d7c759a409de
📒 Files selected for processing (13)
docs/memory/custom-adapter.mddocs/memory/quickstart.mdpackages/typescript/ai-memory/package.jsonpackages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.mdpackages/typescript/ai-memory/src/adapters/in-memory.tspackages/typescript/ai-memory/src/adapters/redis.tspackages/typescript/ai-memory/tests/in-memory.test.tspackages/typescript/ai-memory/tsconfig.jsonpackages/typescript/ai/src/memory/helpers.tspackages/typescript/ai/src/memory/middleware.tspackages/typescript/ai/src/memory/types.tspackages/typescript/ai/tests/memory/helpers.test.tspackages/typescript/ai/tests/middlewares/memory.test.ts
✅ Files skipped from review due to trivial changes (4)
- packages/typescript/ai-memory/tsconfig.json
- packages/typescript/ai-memory/tests/in-memory.test.ts
- docs/memory/quickstart.md
- packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (5)
- packages/typescript/ai/src/memory/helpers.ts
- packages/typescript/ai-memory/src/adapters/in-memory.ts
- packages/typescript/ai/src/memory/types.ts
- packages/typescript/ai/src/memory/middleware.ts
- packages/typescript/ai-memory/src/adapters/redis.ts
| const extractMemories = vi.fn(async () => [ | ||
| rec({ text: 'extracted', kind: 'fact' }), | ||
| ]) | ||
| const stream = chat({ | ||
| adapter, | ||
| messages: [{ role: 'user', content: 'U' }], | ||
| middleware: [ | ||
| memoryMiddleware({ | ||
| adapter: memory, | ||
| scope: baseScope, | ||
| extractMemories, | ||
| }), | ||
| ], | ||
| }) | ||
| await collectChunks(stream as AsyncIterable<StreamChunk>) | ||
| expect(extractMemories).toHaveBeenCalledTimes(1) | ||
| const kinds = [...memory.store.values()].map((r) => r.kind).sort() | ||
| expect(kinds).toEqual(['fact', 'message', 'message']) |
There was a problem hiding this comment.
This test doesn't actually verify defaulting kind to fact.
rec() already injects kind: 'fact', so this still passes if middleware stops applying the default. Return a record without kind (or use a helper that doesn't prefill defaults) so the assertion protects the behavior named in the test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/typescript/ai/tests/middlewares/memory.test.ts` around lines 362 -
379, The test currently uses rec() which pre-fills kind:'fact', so change the
mocked extractor to return a record object that omits the kind property (e.g.,
replace rec({ text: 'extracted', kind: 'fact' }) with a plain record without a
kind field or use a helper that doesn't default kind) so memoryMiddleware is
forced to apply the default; keep the same assertions (extractMemories,
collectChunks, and the kinds check) and reference the existing symbols
extractMemories, rec, memoryMiddleware, and the kinds array to locate and update
the mock return value.
Resolves the monorepo flatten (packages/typescript/* -> packages/*) plus 263 commits of drift: - Relocated the new memory files into the flattened layout: packages/typescript/ai/src/memory -> packages/ai/src/memory, packages/typescript/ai-memory -> packages/ai-memory, plus the moved tests and skills (git rename detection only carried one test file across; the rest were stranded under the deleted path). - knip.json: kept main's entries, re-pointed the ai-memory workspace to packages/ai-memory. - pnpm-lock.yaml: regenerated from main's base via pnpm install. - ai-memory/tsconfig.json: fixed extends depth (../../tsconfig.base.json). - ai-memory/package.json: fixed repository.directory. - Adapted to main's stricter eslint config (exhaustive switch cases, dropped now-unnecessary type assertions incl. the node-redis scan cursor `as never`). SystemPrompt superset means the string prompt injection still type-checks; no source logic changed. Gates green post-merge: @tanstack/ai build + 1183 tests, @tanstack/ai-memory 71 tests, test:types, test:eslint (0 errors), knip, sherif. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Portable record ids - middleware.ts mints ids via crypto.randomUUID(), which is NOT a bare global on the declared Node 18 floor (Web Crypto became unflagged only in Node 19+) and threw ReferenceError there — silently dropping the whole turn's memory in non-strict mode. Add newRecordId() (real UUID when available, portable Date.now()+Math.random() fallback via try/catch). Close silent-failure gaps in the error plumbing - Wrap the scope resolver + shouldRetrieve (onConfig), the scope resolver (onAfterToolCall, onFinish), and shouldRemember (persistTurn) so a throwing user callback emits memory:error/onError and honours strict instead of escaping the hook and breaking chat in non-strict mode. - Make emitError defensive (like safeEmit) so a throwing onError handler can't break chat or mask the original failure. Honest strict-mode docs - Persistence runs via ctx.defer; the engine awaits deferred work with Promise.allSettled and discards results, so a strict-mode rethrow on the persist path does NOT abort the run. Correct the comments that claimed otherwise; memory:error is the observable signal in both modes. Redis: stop silently deleting malformed rows - loadAllForScope routed malformed JSON through the expired-sweep, permanently deleting possibly-recoverable rows behind a one-shot console.warn. Leave the row in place and skip it; warn per-distinct-id (bounded) so ongoing corruption keeps surfacing without spamming. Skill doc updated. Docs - Fix the memory overview's broken ../advanced/observability link (page moved on main) -> ../getting-started/devtools. Tests (+4) - Cross-turn persist->retrieve round trip (the feature's headline behaviour). - Throwing scope resolver / throwing shouldRemember don't break chat + emit memory:error. - Redis malformed row is skipped on read but NOT deleted. Gates: @tanstack/ai 1186 tests, @tanstack/ai-memory 72 tests, test:types, test:eslint (0 errors), knip, sherif, test:docs all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
The memory docs failed `kiira check` (89 errors) — the doc code-snippet type-checker in CI's Test job. Fixes: - quickstart: inline `messages` instead of an undeclared var; self-contain the embedder example (adapter + scope) and guard the possibly-undefined embedding vector; `ignore` the ioredis swap (ioredis's `Redis` type is structurally broader than the minimal `RedisLike` contract) and the server-side scope-derivation pattern (app-defined context). - overview: drop the `MemoryScope` import that conflicted with the local type illustration; `ignore` the scope-derivation pattern block. - custom-adapter: drop the `MemoryAdapter` import that conflicted with the local interface illustration; `ignore` the pgvector scaffold, the method- body fragments, the contract-test file, and the wire-in snippet — all depend on the `pg` peer dep, elided bodies, or relative modules. No behavior change; docs-only. `test:kiira` and `test:docs` pass locally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # pnpm-lock.yaml
…stack/ai-memory
Replace the CRUD MemoryAdapter contract with a single recall/save contract —
the shape every memory backend naturally exposes — and move the middleware,
contract, and helpers out of @tanstack/ai into @tanstack/ai-memory. Core no
longer carries any memory code (the unreleased @tanstack/ai/memory subpath is
removed).
- Contract: MemoryAdapter = { id, recall(scope, query), save(scope, turn),
inspect?, listFacts? } over a session-centric MemoryScope. recall returns a
rendered systemPrompt plus optional fragments and LLM tools/toolGuidance;
save persists a { user, assistant } turn. Extraction/ranking/rendering live
in the adapter, not the middleware.
- Thin memoryMiddleware: recall-on-init injects prompt + tools, deferred
save-on-finish, memory:* devtools events, onRecall/onSave callbacks, and a
save-only role. composeMemoryMiddleware stacks adapters.
- Adapters on flat subpaths: inMemory(), redis() (BYO client, preserves the
malformed-row + delimiter-escaping hardening), and vendor adapters
hindsight(), mem0(), honcho() with lazily-loaded optional-peer SDKs.
- Shared recall/save contract-test suite; redis hardening tests kept; new
middleware unit test; a memory scenario added to the e2e middleware harness.
- Docs rewritten (overview, quickstart, adapters, custom-adapter) with every
option documented and an example of each; skills moved/rewritten; changeset,
knip, and event payloads updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
… memory) (#980) feat(ai): add shared Scope identity type Introduce a single identity/isolation vocabulary (Scope) in @tanstack/ai for the subsystems that persist or recall per-conversation data — ai-persistence and ai-memory. threadId is the one conversation key (matching ChatMiddlewareContext.threadId); userId/tenantId/namespace are optional. Every field is an isolation boundary derived server-side, never from client input. Additive precursor: nothing consumes Scope yet. Landed ahead of the persistence (#785) and memory (#541) PRs so both build on one settled identity contract instead of diverging (sessionId vs threadId, bare threadId vs flat scope:string).
…tests Move the built-in inMemory()/redis() adapters into src/providers/ so all adapters share one layout, and mirror that structure under tests/providers/. Public subpaths (@tanstack/ai-memory/in-memory, /redis) are unchanged — only internal file locations moved (package.json exports + vite entries updated). Add non-networked unit tests for the vendor providers: hindsight (fake runtime driving makeHindsightTools), mem0 (stubbed fetch), and honcho (mocked SDK + pure representation parser). Drop composeMemoryMiddleware — the recall/save contract + save-only role cover multi-backend use without a dedicated combinator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `/memory` route to testing/panel that wires memoryMiddleware with
the in-memory adapter into a chat and shows what's stored, letting you watch
recall/save work end-to-end.
- Shared inMemory() singleton (src/lib/memory-store.ts) so the chat route
(writes via middleware) and the inspect route (reads via inspect/listFacts)
hit the same process-local store.
- api.memory-chat.ts: chat with memoryMiddleware, scope keyed on a client
sessionId; onRecall records the injected recall for display.
- api.memory-inspect.ts: GET returning { snapshot, facts, lastRecall }.
- memory.tsx: two-pane UI — chat on the left, live memory inspector on the
right (last recalled prompt, stored records, listFacts), sessionId persisted
in localStorage with New session / Refresh controls.
- Header nav link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
This panel for memory on the pr description looks really mice, I would love to see this in the devtools panel where in the iteration screen we see what was injected into ghe prompt as its in middleware that would already probably be easy fo do as middleware is shown as well |
AlemTuzlak
left a comment
There was a problem hiding this comment.
Left some comments. In general lgtm
| | `redis` | `RedisLike` | — (required) | Your Redis client (`ioredis`, or node-redis via `nodeRedisAsRedisLike`). | | ||
| | `prefix` | `string` | `'tanstack-ai:memory'` | Key namespace. | | ||
|
|
||
| ```ts ignore |
There was a problem hiding this comment.
Lets see if we can make this work by adding redis to 3rd party deps in kiira config
| ```ts ignore | ||
| // ignore: needs a live node-redis client. | ||
| import { createClient } from 'redis' | ||
| import { redis, nodeRedisAsRedisLike } from '@tanstack/ai-memory/redis' |
There was a problem hiding this comment.
Cqn we call this something nicer 😅
There was a problem hiding this comment.
Yup. Went with fromNodeRedis.
|
|
||
| Once the suite is green, the adapter is interchangeable with the built-ins: | ||
|
|
||
| ```ts ignore |
There was a problem hiding this comment.
Lets try to generally not ignore everything and properly fix it in kiira config
Add a Memory tab to the TanStack AI DevTools for chat hooks wired with `memoryMiddleware`. Per session scope it shows an operations timeline (each turn's recall — query, fragment count, injected prompt size, tools, duration) and the current stored records/facts when the adapter implements `inspect`/`listFacts`. The tab is chat-only (hidden for generation hooks). Server-side memory never reaches the browser event bus, so the middleware transports its state over the chat stream as a `memory:state` CUSTOM event (recall metrics + a start-of-turn snapshot). The chat client routes that event through its `onCustomEvent` handler — the designated path for CUSTOM stream events — to a first-class `ClientDevtoolsBridge.recordMemoryState`, which re-emits the browser `memory:*` events (mirroring how generation results reach the panel). The bridge caches the last state and replays it on `devtools:request-state`, so opening the panel mid-conversation isn't empty. - ai-event-client: add the `memory:snapshot` event. - ai-memory: inject the `memory:state` CUSTOM chunk from `onChunk`; export `MEMORY_STATE_EVENT` / `MemoryStateEventValue`. - ai-client: `recordMemoryState` bridge method (+ no-op parity), wired from `onCustomEvent`; replay on request-state. - ai-devtools-core: Memory tab + per-scope memory store slice. - testing/panel: mount `<TanStackDevtools>` so the panel exposes the DevTools (and the /memory demo hook is named). - e2e: devtools-memory route + spec covering the full browser flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Docs pass over docs/memory: lead each page with the reader's problem, drop em dashes, and un-ignore the Redis code samples so they typecheck. Add an Operating page and point its devtools section at the Memory Inspector. Rename the node-redis wrapper nodeRedisAsRedisLike -> fromNodeRedis across source, tests, skill, changeset, and docs, and add ioredis to the kiira dependency resolver so the un-ignored samples check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # knip.json # testing/e2e/src/routes/api.middleware-test.ts
Summary
Adds first-class server-side memory support to
chat()via a composable middleware plus a new@tanstack/ai-memorypackage shipping in-memory and Redis adapters.@tanstack/ai/memory(new subpath) —memoryMiddleware, theMemoryAdaptercontract (add/get/update/search/list/delete/clear with cursor pagination + TTL),MemoryOpADD/UPDATE/DELETE union, and extension hooks:shouldRetrieve,rerank,shouldRemember,extractMemories,onToolResult,afterPersist. Per-request state isolated viaWeakMap<ChatMiddlewareContext>so a single middleware instance is safe to reuse across concurrent tenants.@tanstack/ai-memory(new package) —inMemoryMemoryAdapter()for dev/tests andredisMemoryAdapter({ redis })for prod (withredisas an optional peer dep). Both pass a shared adapter contract suite covering scope isolation, expiry, pagination, kinds filtering, lexical-only and semantic ranking, and empty-scope safety.AIDevtoolsEventMap(memory:retrieve:started/completed,memory:persist:started/completed,memory:error) with 200-char preview caps; emits are fire-and-forget so telemetry never breaks chat.Three agent skills ship with the packages (umbrella
tanstack-ai-memory, plus per-adaptertanstack-ai-memory-in-memoryandtanstack-ai-memory-redis). User-facing docs added atdocs/middlewares/memory.md(concept) anddocs/guides/memory-quickstart.md(task flow). Spec and design rationale atdocs/superpowers/specs/2026-05-10-memory-middleware-design.md.Designed as additive-friendly: deferred items (hierarchical scopes, multi-embedding spaces, RediSearch/RedisVL adapter, summarization/fact-extraction reference impls,
useChat({ memory })client sugar, per-iteration text persistence) can land later without breaking changes.Test Plan
pnpm --filter @tanstack/ai test:lib(33 files, 785 tests, includes 17 memory middleware tests)pnpm --filter @tanstack/ai-memory test:lib(in-memory + Redis contract suites, 48 tests total viaioredis-mock)pnpm test:eslintpnpm test:typespnpm test:knippnpm test:sherifpnpm test:docs(link checker)pnpm build(all packages)pnpm test:build(publint)inMemoryMemoryAdapterinto an example app, send 3 turns with the same scope, confirm earlier turns surface in retrieved hits on turn 3redisMemoryAdapteragainst a local Redis, confirm scoped records persist across process restarts andclear(scope)removes themDemo — in-memory Memory in the test panel
The
testing/panelapp now has a/memorypage that wiresmemoryMiddleware({ adapter: inMemory() })into a live chat and shows exactly what the adapter stores and recalls. Turn 1 ("My name is Jack and I love guitars") is saved; on the next turn the adapter recalls it, injects it into the system prompt, and the model answers "Your name is Jack" from memory:The right pane surfaces the three read paths — Last recalled (the block injected into the prompt), Stored records (
inspect()), andlistFacts().Summary by CodeRabbit
New Features
Documentation