@dex-ai/memory 0.3.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.
- package/README.md +164 -0
- package/package.json +43 -0
- package/src/_fakes.ts +148 -0
- package/src/db.test.ts +46 -0
- package/src/db.ts +149 -0
- package/src/embedder.ts +60 -0
- package/src/episodic.test.ts +63 -0
- package/src/episodic.ts +144 -0
- package/src/extension.test.ts +266 -0
- package/src/extension.ts +485 -0
- package/src/index.ts +32 -0
- package/src/procedural.test.ts +83 -0
- package/src/procedural.ts +183 -0
- package/src/semantic.test.ts +201 -0
- package/src/semantic.ts +210 -0
- package/src/summarize.test.ts +93 -0
- package/src/summarize.ts +154 -0
- package/src/tools.ts +283 -0
package/src/episodic.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Episodic memory — summarized past turns/tasks.
|
|
3
|
+
*
|
|
4
|
+
* Writes:
|
|
5
|
+
* - record(summary): inserts into episodic + embeds into episodic_vec.
|
|
6
|
+
*
|
|
7
|
+
* Reads:
|
|
8
|
+
* - recall(userId, queryEmbedding, opts): returns the union of
|
|
9
|
+
* most-recent N and top-K similar episodes (de-duped by id).
|
|
10
|
+
*
|
|
11
|
+
* The caller supplies the embedding vector — this module doesn't own the
|
|
12
|
+
* embedder (keeps the module testable without Transformers.js).
|
|
13
|
+
*/
|
|
14
|
+
import type { Database } from 'bun:sqlite';
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export interface EpisodeRow {
|
|
18
|
+
id: string;
|
|
19
|
+
userId: string;
|
|
20
|
+
summary: string;
|
|
21
|
+
metadata: Record<string, unknown>;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface EpisodeSqlRow {
|
|
26
|
+
id: string;
|
|
27
|
+
user_id: string;
|
|
28
|
+
summary: string;
|
|
29
|
+
metadata: string;
|
|
30
|
+
created_at: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rowToEpisode(r: EpisodeSqlRow): EpisodeRow {
|
|
34
|
+
return {
|
|
35
|
+
id: r.id,
|
|
36
|
+
userId: r.user_id,
|
|
37
|
+
summary: r.summary,
|
|
38
|
+
metadata: JSON.parse(r.metadata) as Record<string, unknown>,
|
|
39
|
+
createdAt: r.created_at,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Packs a number[] into Float32Array bytes for sqlite-vec. */
|
|
44
|
+
function toVecBytes(embedding: number[]): Uint8Array {
|
|
45
|
+
const f = new Float32Array(embedding);
|
|
46
|
+
return new Uint8Array(f.buffer, f.byteOffset, f.byteLength);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RecallOptions {
|
|
50
|
+
/** Most-recent episodes to include. Default 3. */
|
|
51
|
+
recentLimit?: number;
|
|
52
|
+
/** Most-similar episodes to include via vector search. Default 3. */
|
|
53
|
+
similarLimit?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class EpisodicStore {
|
|
57
|
+
readonly #db: Database;
|
|
58
|
+
readonly #insert;
|
|
59
|
+
readonly #insertVec;
|
|
60
|
+
readonly #listRecent;
|
|
61
|
+
readonly #similar;
|
|
62
|
+
|
|
63
|
+
constructor(db: Database) {
|
|
64
|
+
this.#db = db;
|
|
65
|
+
this.#insert = db.prepare(
|
|
66
|
+
'INSERT INTO episodic(id, user_id, summary, metadata, created_at) VALUES(?, ?, ?, ?, ?)',
|
|
67
|
+
);
|
|
68
|
+
this.#insertVec = db.prepare('INSERT INTO episodic_vec(id, embedding) VALUES(?, ?)');
|
|
69
|
+
this.#listRecent = db.prepare(
|
|
70
|
+
'SELECT * FROM episodic WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
|
|
71
|
+
);
|
|
72
|
+
// sqlite-vec vector search: returns ids by distance ascending (closer = more similar).
|
|
73
|
+
// We join back onto episodic to filter by user_id and return full rows.
|
|
74
|
+
this.#similar = db.prepare(`
|
|
75
|
+
SELECT e.*, v.distance as distance
|
|
76
|
+
FROM episodic e
|
|
77
|
+
JOIN (
|
|
78
|
+
SELECT id, distance
|
|
79
|
+
FROM episodic_vec
|
|
80
|
+
WHERE embedding MATCH ?
|
|
81
|
+
ORDER BY distance
|
|
82
|
+
LIMIT ?
|
|
83
|
+
) v ON v.id = e.id
|
|
84
|
+
WHERE e.user_id = ?
|
|
85
|
+
ORDER BY v.distance
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async record(input: {
|
|
90
|
+
userId: string;
|
|
91
|
+
summary: string;
|
|
92
|
+
embedding: number[];
|
|
93
|
+
metadata?: Record<string, unknown>;
|
|
94
|
+
}): Promise<EpisodeRow> {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const id = crypto.randomUUID();
|
|
97
|
+
const metaJson = JSON.stringify(input.metadata ?? {});
|
|
98
|
+
this.#db.transaction(() => {
|
|
99
|
+
this.#insert.run(id, input.userId, input.summary, metaJson, now);
|
|
100
|
+
this.#insertVec.run(id, toVecBytes(input.embedding));
|
|
101
|
+
})();
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
userId: input.userId,
|
|
105
|
+
summary: input.summary,
|
|
106
|
+
metadata: input.metadata ?? {},
|
|
107
|
+
createdAt: now,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Recall: union of recent N and top-K similar, de-duped by id and sorted by time desc.
|
|
113
|
+
* When the query embedding is omitted, only recent episodes are returned.
|
|
114
|
+
*/
|
|
115
|
+
async recall(
|
|
116
|
+
userId: string,
|
|
117
|
+
queryEmbedding: number[] | undefined,
|
|
118
|
+
opts: RecallOptions = {},
|
|
119
|
+
): Promise<EpisodeRow[]> {
|
|
120
|
+
const recentLimit = opts.recentLimit ?? 3;
|
|
121
|
+
const similarLimit = opts.similarLimit ?? 3;
|
|
122
|
+
|
|
123
|
+
const recent = this.#listRecent.all(userId, recentLimit) as EpisodeSqlRow[];
|
|
124
|
+
const byId = new Map<string, EpisodeSqlRow>();
|
|
125
|
+
for (const r of recent) byId.set(r.id, r);
|
|
126
|
+
|
|
127
|
+
if (queryEmbedding !== undefined && similarLimit > 0) {
|
|
128
|
+
// Fetch more than similarLimit to allow user_id filtering in the join
|
|
129
|
+
// dropping rows from other users.
|
|
130
|
+
const similar = this.#similar.all(
|
|
131
|
+
toVecBytes(queryEmbedding),
|
|
132
|
+
similarLimit * 4,
|
|
133
|
+
userId,
|
|
134
|
+
) as Array<EpisodeSqlRow & { distance: number }>;
|
|
135
|
+
for (const s of similar.slice(0, similarLimit)) {
|
|
136
|
+
if (!byId.has(s.id)) byId.set(s.id, s);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const merged = Array.from(byId.values()).map(rowToEpisode);
|
|
141
|
+
merged.sort((a, b) => b.createdAt - a.createdAt);
|
|
142
|
+
return merged;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { Message } from '@dex-ai/sdk';
|
|
3
|
+
import { Agent } from '@dex-ai/sdk';
|
|
4
|
+
import { memoryExtension } from './extension';
|
|
5
|
+
import { fakeEmbedder, scriptedProviderExtension, FAKE_PROVIDER, FAKE_MODEL } from './_fakes';
|
|
6
|
+
|
|
7
|
+
const userMsg = (text: string): Message => ({
|
|
8
|
+
role: 'user',
|
|
9
|
+
content: [{ type: 'text', text }],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function findToolResult(
|
|
13
|
+
messages: ReadonlyArray<Message>,
|
|
14
|
+
toolName: string,
|
|
15
|
+
): unknown {
|
|
16
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
17
|
+
const msg = messages[i]!;
|
|
18
|
+
if (msg.role !== 'tool') continue;
|
|
19
|
+
for (const c of msg.content) {
|
|
20
|
+
if (c.type === 'tool-result' && c.toolName === toolName) {
|
|
21
|
+
return c.output;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('memoryExtension', () => {
|
|
29
|
+
test('stores a user-supplied fact via remember_fact tool and recalls it next turn', async () => {
|
|
30
|
+
const providerExt = scriptedProviderExtension({
|
|
31
|
+
steps: [
|
|
32
|
+
{
|
|
33
|
+
kind: 'tool-call',
|
|
34
|
+
toolName: 'memory_remember_fact',
|
|
35
|
+
input: { subject: 'user', predicate: 'prefers', object: 'TypeScript' },
|
|
36
|
+
},
|
|
37
|
+
{ kind: 'text', text: 'noted' },
|
|
38
|
+
],
|
|
39
|
+
generateReplies: ['turn summary', '{"facts":[]}'],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const agent = await Agent.create({
|
|
43
|
+
provider: FAKE_PROVIDER, model: FAKE_MODEL,
|
|
44
|
+
extensions: [providerExt,
|
|
45
|
+
memoryExtension({
|
|
46
|
+
path: ':memory:',
|
|
47
|
+
userId: 'alice',
|
|
48
|
+
embed: fakeEmbedder(),
|
|
49
|
+
autoWrite: false, // off for this test so we're not also hitting extractFacts
|
|
50
|
+
}),
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const s = agent.generate({ input: [userMsg('remember I like TS')] });
|
|
55
|
+
await s.result;
|
|
56
|
+
for await (const _ of s) { /* drain */ }
|
|
57
|
+
await agent.dispose();
|
|
58
|
+
|
|
59
|
+
// Shutting down already happened; test DB is closed. Re-verify state by
|
|
60
|
+
// opening a fresh agent pointed at the same file — but :memory: doesn't
|
|
61
|
+
// persist across connections. So instead assert from the tool-result
|
|
62
|
+
// that was committed into history during the turn.
|
|
63
|
+
const out = findToolResult(agent.context.messages, 'memory_remember_fact');
|
|
64
|
+
expect(out).toBeDefined();
|
|
65
|
+
expect((out as { type: string }).type).toBe('json');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('onRequest injects facts as a synthetic system message that never persists', async () => {
|
|
69
|
+
const providerExt = scriptedProviderExtension({
|
|
70
|
+
steps: [
|
|
71
|
+
// turn 1: remember a fact
|
|
72
|
+
{
|
|
73
|
+
kind: 'tool-call',
|
|
74
|
+
toolName: 'memory_remember_fact',
|
|
75
|
+
input: { subject: 'user', predicate: 'uses', object: 'Bun' },
|
|
76
|
+
},
|
|
77
|
+
{ kind: 'text', text: 'ok' },
|
|
78
|
+
// turn 2: plain text reply
|
|
79
|
+
{ kind: 'text', text: 'hi again' },
|
|
80
|
+
],
|
|
81
|
+
generateReplies: [],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const agent = await Agent.create({
|
|
85
|
+
provider: FAKE_PROVIDER, model: FAKE_MODEL,
|
|
86
|
+
extensions: [providerExt,
|
|
87
|
+
memoryExtension({
|
|
88
|
+
path: ':memory:',
|
|
89
|
+
userId: 'alice',
|
|
90
|
+
embed: fakeEmbedder(),
|
|
91
|
+
autoWrite: false,
|
|
92
|
+
}),
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Turn 1 — write the fact.
|
|
97
|
+
const s1 = agent.generate({ input: [userMsg('use bun')] });
|
|
98
|
+
await s1.result;
|
|
99
|
+
for await (const _ of s1) { /* drain */ }
|
|
100
|
+
|
|
101
|
+
// Turn 2 — the synthetic system message should be prepended but NOT appear
|
|
102
|
+
// in actx.messages (history stays clean).
|
|
103
|
+
const beforeLen = agent.context.messages.length;
|
|
104
|
+
const s2 = agent.generate({ input: [userMsg('what stack?')] });
|
|
105
|
+
await s2.result;
|
|
106
|
+
for await (const _ of s2) { /* drain */ }
|
|
107
|
+
const afterLen = agent.context.messages.length;
|
|
108
|
+
|
|
109
|
+
// +1 user, +1 assistant (text-only turn) = +2 messages total.
|
|
110
|
+
expect(afterLen - beforeLen).toBe(2);
|
|
111
|
+
// No injected 'system' message got into history either.
|
|
112
|
+
for (const m of agent.context.messages) {
|
|
113
|
+
if (m.role === 'system') {
|
|
114
|
+
for (const c of m.content) {
|
|
115
|
+
if (c.type === 'text') {
|
|
116
|
+
expect(c.text).not.toMatch(/Known facts:/);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await agent.dispose();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('memoryExtension — auto-write', () => {
|
|
127
|
+
test('fires summarize + extractFacts at iteration stop (fire-and-forget) and awaits at dispose', async () => {
|
|
128
|
+
const providerExt = scriptedProviderExtension({
|
|
129
|
+
steps: [{ kind: 'text', text: 'hey there' }],
|
|
130
|
+
generateReplies: [
|
|
131
|
+
'user greeted the assistant', // summarize call
|
|
132
|
+
'{"facts":[{"subject":"user","predicate":"greeted","object":"at start"}]}', // extract call
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const agent = await Agent.create({
|
|
137
|
+
provider: FAKE_PROVIDER, model: FAKE_MODEL,
|
|
138
|
+
extensions: [providerExt,
|
|
139
|
+
memoryExtension({
|
|
140
|
+
path: ':memory:',
|
|
141
|
+
userId: 'alice',
|
|
142
|
+
embed: fakeEmbedder(),
|
|
143
|
+
autoWrite: true,
|
|
144
|
+
}),
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const s = agent.generate({ input: [userMsg('hello')] });
|
|
149
|
+
await s.result;
|
|
150
|
+
for await (const _ of s) { /* drain */ }
|
|
151
|
+
// onAgentStop awaits pending background writes.
|
|
152
|
+
await agent.dispose();
|
|
153
|
+
|
|
154
|
+
// We can't inspect :memory: after close. Instead, keep the DB alive by using
|
|
155
|
+
// a persistent file path — do the same flow against a temp file.
|
|
156
|
+
expect(true).toBe(true); // sanity; real check in the next test below
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('auto-write persists summary + extracted fact (file-backed)', async () => {
|
|
160
|
+
const { mkdtemp, rm } = await import('node:fs/promises');
|
|
161
|
+
const { tmpdir } = await import('node:os');
|
|
162
|
+
const { join } = await import('node:path');
|
|
163
|
+
const { MemoryDb } = await import('./db');
|
|
164
|
+
const { EpisodicStore } = await import('./episodic');
|
|
165
|
+
const { SemanticStore } = await import('./semantic');
|
|
166
|
+
|
|
167
|
+
const dir = await mkdtemp(join(tmpdir(), 'dex-mem-ext-'));
|
|
168
|
+
const path = join(dir, 'mem.db');
|
|
169
|
+
try {
|
|
170
|
+
const providerExt = scriptedProviderExtension({
|
|
171
|
+
steps: [{ kind: 'text', text: 'reply' }],
|
|
172
|
+
generateReplies: [
|
|
173
|
+
'user greeted the assistant',
|
|
174
|
+
'{"facts":[{"subject":"user","predicate":"greeted","object":"at start"}]}',
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const agent = await Agent.create({
|
|
179
|
+
provider: FAKE_PROVIDER, model: FAKE_MODEL,
|
|
180
|
+
extensions: [providerExt,
|
|
181
|
+
memoryExtension({
|
|
182
|
+
path,
|
|
183
|
+
userId: 'alice',
|
|
184
|
+
embed: fakeEmbedder(),
|
|
185
|
+
autoWrite: true,
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const s = agent.generate({ input: [userMsg('hello')] });
|
|
191
|
+
await s.result;
|
|
192
|
+
for await (const _ of s) { /* drain */ }
|
|
193
|
+
await agent.dispose();
|
|
194
|
+
|
|
195
|
+
// Re-open to inspect.
|
|
196
|
+
const db = new MemoryDb({ path });
|
|
197
|
+
const ep = new EpisodicStore(db.db);
|
|
198
|
+
const sem = new SemanticStore(db.db);
|
|
199
|
+
|
|
200
|
+
const episodes = await ep.recall('alice', undefined, { recentLimit: 10, similarLimit: 0 });
|
|
201
|
+
expect(episodes.length).toBe(1);
|
|
202
|
+
expect(episodes[0]!.summary).toBe('user greeted the assistant');
|
|
203
|
+
|
|
204
|
+
const facts = await sem.list('alice');
|
|
205
|
+
expect(facts.length).toBe(1);
|
|
206
|
+
expect(facts[0]!.subject).toBe('user');
|
|
207
|
+
expect(facts[0]!.object).toBe('at start');
|
|
208
|
+
expect(facts[0]!.source).toBe('extracted');
|
|
209
|
+
|
|
210
|
+
db.close();
|
|
211
|
+
} finally {
|
|
212
|
+
await rm(dir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('memoryExtension — procedural tools', () => {
|
|
218
|
+
test('store_procedure then get_procedure round-trips', async () => {
|
|
219
|
+
const providerExt = scriptedProviderExtension({
|
|
220
|
+
steps: [
|
|
221
|
+
{
|
|
222
|
+
kind: 'tool-call',
|
|
223
|
+
toolName: 'memory_store_procedure',
|
|
224
|
+
input: {
|
|
225
|
+
title: 'deploy-dex',
|
|
226
|
+
body: '1. bun run typecheck\n2. bun run test\n3. git tag',
|
|
227
|
+
tags: ['deploy', 'release'],
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
kind: 'tool-call',
|
|
232
|
+
toolName: 'memory_get_procedure',
|
|
233
|
+
input: { title: 'deploy-dex' },
|
|
234
|
+
},
|
|
235
|
+
{ kind: 'text', text: 'done' },
|
|
236
|
+
],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const agent = await Agent.create({
|
|
240
|
+
provider: FAKE_PROVIDER, model: FAKE_MODEL,
|
|
241
|
+
extensions: [providerExt,
|
|
242
|
+
memoryExtension({
|
|
243
|
+
path: ':memory:',
|
|
244
|
+
userId: 'alice',
|
|
245
|
+
embed: fakeEmbedder(),
|
|
246
|
+
autoWrite: false,
|
|
247
|
+
}),
|
|
248
|
+
],
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const s = agent.generate({ input: [userMsg('save and fetch')] });
|
|
252
|
+
await s.result;
|
|
253
|
+
for await (const _ of s) { /* drain */ }
|
|
254
|
+
await agent.dispose();
|
|
255
|
+
|
|
256
|
+
const stored = findToolResult(agent.context.messages, 'memory_store_procedure');
|
|
257
|
+
expect((stored as { type: string }).type).toBe('json');
|
|
258
|
+
|
|
259
|
+
const fetched = findToolResult(agent.context.messages, 'memory_get_procedure');
|
|
260
|
+
expect((fetched as { type: string }).type).toBe('json');
|
|
261
|
+
const body = (fetched as { value: { title: string; body: string } | null }).value;
|
|
262
|
+
expect(body).not.toBeNull();
|
|
263
|
+
expect(body!.title).toBe('deploy-dex');
|
|
264
|
+
expect(body!.body).toMatch(/bun run typecheck/);
|
|
265
|
+
});
|
|
266
|
+
});
|