From 2ac15d109bec5438383bdcf393a9021e0719e2b9 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Tue, 16 Jun 2026 18:31:20 +0300 Subject: [PATCH 1/2] feat(agent-memory): Phase 1 remember() write path - MemoryStore.remember(): embed content, HSET {name}:mem:{id} hash with content, encoded vector, scope fields, importance (default 0.5), tags csv, source, created_at/last_accessed_at, access_count=0; returns the id - Validate embedding dimension on first write; mismatch throws - Extract pure buildMemoryRecord() and unit-test it standalone - Export memory types (EmbedFn, MemoryScope, RememberOptions, MemoryStoreOptions) --- packages/agent-memory/src/MemoryStore.ts | 44 ++++++++++- .../__tests__/MemoryStore.remember.test.ts | 76 +++++++++++++++++++ .../src/__tests__/buildMemoryRecord.test.ts | 62 +++++++++++++++ .../agent-memory/src/buildMemoryRecord.ts | 58 ++++++++++++++ packages/agent-memory/src/index.ts | 2 + packages/agent-memory/src/types.ts | 17 +++++ 6 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 packages/agent-memory/src/__tests__/MemoryStore.remember.test.ts create mode 100644 packages/agent-memory/src/__tests__/buildMemoryRecord.test.ts create mode 100644 packages/agent-memory/src/buildMemoryRecord.ts create mode 100644 packages/agent-memory/src/types.ts diff --git a/packages/agent-memory/src/MemoryStore.ts b/packages/agent-memory/src/MemoryStore.ts index 2a0d701f..d40d807f 100644 --- a/packages/agent-memory/src/MemoryStore.ts +++ b/packages/agent-memory/src/MemoryStore.ts @@ -1 +1,43 @@ -export class MemoryStore {} +import { randomUUID } from 'node:crypto'; +import { buildMemoryRecord } from './buildMemoryRecord'; +import type { EmbedFn, MemoryStoreClient, RememberOptions } from './types'; + +export interface MemoryStoreOptions { + client: MemoryStoreClient; + name: string; + embedFn: EmbedFn; +} + +export class MemoryStore { + private readonly client: MemoryStoreClient; + private readonly name: string; + private readonly embedFn: EmbedFn; + private dims?: number; + + constructor(options: MemoryStoreOptions) { + this.client = options.client; + this.name = options.name; + this.embedFn = options.embedFn; + } + + async remember(content: string, options: RememberOptions = {}): Promise { + const vector = await this.embed(content); + const id = randomUUID(); + const now = Date.now(); + const record = buildMemoryRecord(this.name, id, content, vector, options, now); + await this.client.call('HSET', record.key, ...record.fields); + return id; + } + + private async embed(content: string): Promise { + const vector = await this.embedFn(content); + if (this.dims === undefined) { + this.dims = vector.length; + } else if (vector.length !== this.dims) { + throw new Error( + `Embedding dimension mismatch: expected ${this.dims}, embedFn returned ${vector.length}`, + ); + } + return vector; + } +} diff --git a/packages/agent-memory/src/__tests__/MemoryStore.remember.test.ts b/packages/agent-memory/src/__tests__/MemoryStore.remember.test.ts new file mode 100644 index 00000000..1b0a2090 --- /dev/null +++ b/packages/agent-memory/src/__tests__/MemoryStore.remember.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest'; +import { encodeFloat32 } from '@betterdb/valkey-search-kit'; +import { MemoryStore } from '../MemoryStore'; +import { fakeEmbed } from './helpers/fakeEmbed'; +import { mockClient } from './helpers/mockClient'; + +function hsetFields(call: unknown[]): Record { + const fields = call.slice(2); + const out: Record = {}; + for (let i = 0; i < fields.length; i += 2) { + out[String(fields[i])] = fields[i + 1] as string | Buffer; + } + return out; +} + +describe('MemoryStore.remember', () => { + it('embeds content once, HSETs the memory hash, and returns an id', async () => { + const embedFn = vi.fn(fakeEmbed(8)); + const client = mockClient(); + const store = new MemoryStore({ client, name: 'mem', embedFn }); + + const id = await store.remember('the user prefers dark mode', { + threadId: 't1', + agentId: 'a1', + namespace: 'user:1', + tags: ['pref', 'ui'], + source: 'user', + }); + + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + expect(embedFn).toHaveBeenCalledTimes(1); + expect(embedFn).toHaveBeenCalledWith('the user prefers dark mode'); + + const hset = client.call.mock.calls.find((args) => args[0] === 'HSET'); + expect(hset?.[1]).toBe(`mem:mem:${id}`); + + const fields = hsetFields(hset as unknown[]); + expect(fields.content).toBe('the user prefers dark mode'); + expect(fields.threadId).toBe('t1'); + expect(fields.agentId).toBe('a1'); + expect(fields.namespace).toBe('user:1'); + expect(fields.tags).toBe('pref,ui'); + expect(fields.source).toBe('user'); + expect(fields.importance).toBe('0.5'); + expect(fields.access_count).toBe('0'); + expect(fields.vector).toEqual(encodeFloat32(await fakeEmbed(8)('the user prefers dark mode'))); + expect(typeof fields.created_at).toBe('string'); + expect(fields.last_accessed_at).toBe(fields.created_at); + }); + + it('honors a provided importance and omits absent optional fields', async () => { + const client = mockClient(); + const store = new MemoryStore({ client, name: 'mem', embedFn: fakeEmbed(8) }); + + await store.remember('a bare fact', { importance: 0.9 }); + + const hset = client.call.mock.calls.find((args) => args[0] === 'HSET'); + const fields = hsetFields(hset as unknown[]); + expect(fields.importance).toBe('0.9'); + expect('tags' in fields).toBe(false); + expect('threadId' in fields).toBe(false); + expect('source' in fields).toBe(false); + }); + + it('throws when a later embedding has a mismatched dimension', async () => { + let dims = 8; + const embedFn = vi.fn(async () => new Array(dims).fill(0.1)); + const store = new MemoryStore({ client: mockClient(), name: 'mem', embedFn }); + + await store.remember('first'); + dims = 4; + + await expect(store.remember('second')).rejects.toThrow(/dimension/i); + }); +}); diff --git a/packages/agent-memory/src/__tests__/buildMemoryRecord.test.ts b/packages/agent-memory/src/__tests__/buildMemoryRecord.test.ts new file mode 100644 index 00000000..640a122e --- /dev/null +++ b/packages/agent-memory/src/__tests__/buildMemoryRecord.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { encodeFloat32 } from '@betterdb/valkey-search-kit'; +import { buildMemoryRecord } from '../buildMemoryRecord'; + +function toObject(fields: (string | Buffer)[]): Record { + const out: Record = {}; + for (let i = 0; i < fields.length; i += 2) { + out[String(fields[i])] = fields[i + 1]; + } + return out; +} + +describe('buildMemoryRecord', () => { + it('builds the {name}:mem:{id} key and a deterministic field list', () => { + const vector = [0.1, 0.2, 0.3, 0.4]; + const record = buildMemoryRecord( + 'mem', + 'id1', + 'hello world', + vector, + { + threadId: 't', + agentId: 'a', + namespace: 'n', + tags: ['x', 'y'], + importance: 0.7, + source: 'user', + }, + 1000, + ); + + expect(record.key).toBe('mem:mem:id1'); + const f = toObject(record.fields); + expect(f.content).toBe('hello world'); + expect(f.importance).toBe('0.7'); + expect(f.tags).toBe('x,y'); + expect(f.threadId).toBe('t'); + expect(f.agentId).toBe('a'); + expect(f.namespace).toBe('n'); + expect(f.source).toBe('user'); + expect(f.created_at).toBe('1000'); + expect(f.last_accessed_at).toBe('1000'); + expect(f.access_count).toBe('0'); + expect(f.vector).toEqual(encodeFloat32(vector)); + }); + + it('defaults importance to 0.5 and omits absent optional fields including empty tags', () => { + const record = buildMemoryRecord('mem', 'id2', 'x', [0, 0], {}, 5); + + const f = toObject(record.fields); + expect(f.importance).toBe('0.5'); + expect('tags' in f).toBe(false); + expect('threadId' in f).toBe(false); + expect('source' in f).toBe(false); + }); + + it('throws when a tag contains a comma (would break TAG tokenization)', () => { + expect(() => + buildMemoryRecord('mem', 'id3', 'x', [0, 0], { tags: ['tool:web,search'] }, 5), + ).toThrow(/comma/i); + }); +}); diff --git a/packages/agent-memory/src/buildMemoryRecord.ts b/packages/agent-memory/src/buildMemoryRecord.ts new file mode 100644 index 00000000..62422ac3 --- /dev/null +++ b/packages/agent-memory/src/buildMemoryRecord.ts @@ -0,0 +1,58 @@ +import { encodeFloat32 } from '@betterdb/valkey-search-kit'; +import type { RememberOptions } from './types'; + +export interface MemoryWrite { + key: string; + fields: (string | Buffer)[]; +} + +const DEFAULT_IMPORTANCE = 0.5; + +export function buildMemoryRecord( + name: string, + id: string, + content: string, + vector: number[], + options: RememberOptions, + now: number, +): MemoryWrite { + const fields: (string | Buffer)[] = [ + 'content', + content, + 'vector', + encodeFloat32(vector), + 'importance', + String(options.importance ?? DEFAULT_IMPORTANCE), + 'created_at', + String(now), + 'last_accessed_at', + String(now), + 'access_count', + '0', + ]; + + const tags = options.tags ?? []; + for (const tag of tags) { + if (tag.includes(',')) { + throw new Error(`Tag '${tag}' must not contain a comma; tags are stored comma-separated`); + } + } + if (tags.length > 0) { + fields.push('tags', tags.join(',')); + } + + if (options.threadId !== undefined) { + fields.push('threadId', options.threadId); + } + if (options.agentId !== undefined) { + fields.push('agentId', options.agentId); + } + if (options.namespace !== undefined) { + fields.push('namespace', options.namespace); + } + if (options.source !== undefined) { + fields.push('source', options.source); + } + + return { key: `${name}:mem:${id}`, fields }; +} diff --git a/packages/agent-memory/src/index.ts b/packages/agent-memory/src/index.ts index 2d471337..1599ab13 100644 --- a/packages/agent-memory/src/index.ts +++ b/packages/agent-memory/src/index.ts @@ -1,3 +1,5 @@ export * from '@betterdb/agent-cache'; export { MemoryStore } from './MemoryStore'; +export type { MemoryStoreOptions } from './MemoryStore'; export { AgentMemory } from './AgentMemory'; +export type { EmbedFn, MemoryStoreClient, MemoryScope, RememberOptions } from './types'; diff --git a/packages/agent-memory/src/types.ts b/packages/agent-memory/src/types.ts new file mode 100644 index 00000000..a54f6251 --- /dev/null +++ b/packages/agent-memory/src/types.ts @@ -0,0 +1,17 @@ +export type EmbedFn = (text: string) => Promise; + +export interface MemoryStoreClient { + call(command: string, ...args: (string | Buffer | number)[]): Promise; +} + +export interface MemoryScope { + threadId?: string; + agentId?: string; + namespace?: string; +} + +export interface RememberOptions extends MemoryScope { + importance?: number; + tags?: string[]; + source?: string; +} From a60f8712e97b372250188bdab9862f6c940a9268 Mon Sep 17 00:00:00 2001 From: jamby77 Date: Fri, 19 Jun 2026 12:14:05 +0300 Subject: [PATCH 2/2] fix(agent-memory): validate importance is finite and within [0, 1] at write A caller-supplied importance was stored verbatim, so NaN/Infinity or an out-of-range value silently poisoned recall: the composite score became NaN and the memory was dropped, or a large value dominated ranking. Reject it at the write boundary, matching the package's other input guards. --- .../src/__tests__/buildMemoryRecord.test.ts | 15 +++++++++++++++ packages/agent-memory/src/buildMemoryRecord.ts | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/agent-memory/src/__tests__/buildMemoryRecord.test.ts b/packages/agent-memory/src/__tests__/buildMemoryRecord.test.ts index 640a122e..25b35f9b 100644 --- a/packages/agent-memory/src/__tests__/buildMemoryRecord.test.ts +++ b/packages/agent-memory/src/__tests__/buildMemoryRecord.test.ts @@ -59,4 +59,19 @@ describe('buildMemoryRecord', () => { buildMemoryRecord('mem', 'id3', 'x', [0, 0], { tags: ['tool:web,search'] }, 5), ).toThrow(/comma/i); }); + + it('rejects an importance outside [0, 1] or non-finite, so a bad value cannot poison ranking', () => { + for (const bad of [NaN, Infinity, -Infinity, -0.1, 1.5, 42]) { + expect(() => + buildMemoryRecord('mem', 'idx', 'x', [0, 0], { importance: bad }, 5), + ).toThrow(/importance/i); + } + }); + + it('accepts the inclusive [0, 1] bounds', () => { + for (const ok of [0, 0.5, 1]) { + const record = buildMemoryRecord('mem', 'idx', 'x', [0, 0], { importance: ok }, 5); + expect(toObject(record.fields).importance).toBe(String(ok)); + } + }); }); diff --git a/packages/agent-memory/src/buildMemoryRecord.ts b/packages/agent-memory/src/buildMemoryRecord.ts index 62422ac3..9214e0a9 100644 --- a/packages/agent-memory/src/buildMemoryRecord.ts +++ b/packages/agent-memory/src/buildMemoryRecord.ts @@ -16,13 +16,20 @@ export function buildMemoryRecord( options: RememberOptions, now: number, ): MemoryWrite { + const importance = options.importance ?? DEFAULT_IMPORTANCE; + if (!Number.isFinite(importance) || importance < 0 || importance > 1) { + throw new Error( + `importance must be a finite number in [0, 1], got: ${String(options.importance)}`, + ); + } + const fields: (string | Buffer)[] = [ 'content', content, 'vector', encodeFloat32(vector), 'importance', - String(options.importance ?? DEFAULT_IMPORTANCE), + String(importance), 'created_at', String(now), 'last_accessed_at',