@nxgt/ai-memory 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +173 -2
- package/dist/actions.d.ts +9 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/chunks/index-082dnty9.js +188 -0
- package/dist/chunks/index-082dnty9.js.map +14 -0
- package/dist/chunks/index-vj24rcgc.js +119 -0
- package/dist/chunks/index-vj24rcgc.js.map +13 -0
- package/dist/compact.d.ts +38 -0
- package/dist/compact.d.ts.map +1 -0
- package/dist/contract-actions.d.ts +3 -0
- package/dist/contract-actions.d.ts.map +1 -0
- package/dist/contract-messages.d.ts +3 -0
- package/dist/contract-messages.d.ts.map +1 -0
- package/dist/contract-owner.d.ts +3 -0
- package/dist/contract-owner.d.ts.map +1 -0
- package/dist/contract-samples.d.ts +6 -0
- package/dist/contract-samples.d.ts.map +1 -0
- package/dist/contract-threads.d.ts +3 -0
- package/dist/contract-threads.d.ts.map +1 -0
- package/dist/contract.d.ts +9 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/guards.d.ts +5 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/in-process.d.ts +8 -0
- package/dist/in-process.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +167 -0
- package/dist/index.js.map +13 -0
- package/dist/messages.d.ts +7 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/mongo-actions.d.ts +8 -0
- package/dist/mongo-actions.d.ts.map +1 -0
- package/dist/mongo-collection.d.ts +32 -0
- package/dist/mongo-collection.d.ts.map +1 -0
- package/dist/mongo-ctx.d.ts +12 -0
- package/dist/mongo-ctx.d.ts.map +1 -0
- package/dist/mongo-fields.d.ts +41 -0
- package/dist/mongo-fields.d.ts.map +1 -0
- package/dist/mongo-map.d.ts +8 -0
- package/dist/mongo-map.d.ts.map +1 -0
- package/dist/mongo-messages.d.ts +7 -0
- package/dist/mongo-messages.d.ts.map +1 -0
- package/dist/mongo-store.d.ts +16 -0
- package/dist/mongo-store.d.ts.map +1 -0
- package/dist/mongo-threads.d.ts +13 -0
- package/dist/mongo-threads.d.ts.map +1 -0
- package/dist/mongo.d.ts +4 -0
- package/dist/mongo.d.ts.map +1 -0
- package/dist/mongo.js +271 -0
- package/dist/mongo.js.map +16 -0
- package/dist/paging.d.ts +8 -0
- package/dist/paging.d.ts.map +1 -0
- package/dist/pairs.d.ts +14 -0
- package/dist/pairs.d.ts.map +1 -0
- package/dist/redis-actions.d.ts +8 -0
- package/dist/redis-actions.d.ts.map +1 -0
- package/dist/redis-ctx.d.ts +37 -0
- package/dist/redis-ctx.d.ts.map +1 -0
- package/dist/redis-map.d.ts +13 -0
- package/dist/redis-map.d.ts.map +1 -0
- package/dist/redis-messages.d.ts +7 -0
- package/dist/redis-messages.d.ts.map +1 -0
- package/dist/redis-scripts.d.ts +40 -0
- package/dist/redis-scripts.d.ts.map +1 -0
- package/dist/redis-store.d.ts +21 -0
- package/dist/redis-store.d.ts.map +1 -0
- package/dist/redis-threads.d.ts +16 -0
- package/dist/redis-threads.d.ts.map +1 -0
- package/dist/redis.d.ts +3 -0
- package/dist/redis.d.ts.map +1 -0
- package/dist/redis.js +373 -0
- package/dist/redis.js.map +16 -0
- package/dist/schemas.d.ts +124 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/state.d.ts +28 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/summarize.d.ts +11 -0
- package/dist/summarize.d.ts.map +1 -0
- package/dist/testing.d.ts +3 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +264 -0
- package/dist/testing.js.map +15 -0
- package/dist/threads.d.ts +7 -0
- package/dist/threads.d.ts.map +1 -0
- package/dist/truncate.d.ts +17 -0
- package/dist/truncate.d.ts.map +1 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.d.ts.map +1 -0
- package/docs/README.md +9 -0
- package/docs/guide/memory.md +327 -0
- package/docs/roadmap.md +31 -0
- package/docs/troubleshooting.md +171 -0
- package/package.json +85 -5
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# Conversation memory
|
|
2
|
+
|
|
3
|
+
A `MemoryStore` keeps threads (a conversation each) and their messages. Every
|
|
4
|
+
method takes the `ownerId` and sees only that owner's data.
|
|
5
|
+
|
|
6
|
+
## Threads
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
const t = await store.createThread({
|
|
10
|
+
ownerId: 'u1',
|
|
11
|
+
title: 'Billing',
|
|
12
|
+
meta: { channel: 'web' },
|
|
13
|
+
});
|
|
14
|
+
await store.getThread(t.id, 'u1'); // the thread, or undefined
|
|
15
|
+
await store.deleteThread(t.id, 'u1'); // true; removes messages and actions too
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`ownerId` must be a non-empty string, else `MemoryError('INVALID_OWNER')`.
|
|
19
|
+
|
|
20
|
+
### Paging
|
|
21
|
+
|
|
22
|
+
`listThreads(ownerId, { limit, cursor })` returns the newest thread first. Pass
|
|
23
|
+
the `nextCursor` of a page to get the next one; the last page has none.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
let page = await store.listThreads('u1', { limit: 20 });
|
|
27
|
+
while (true) {
|
|
28
|
+
show(page.items);
|
|
29
|
+
if (!page.nextCursor) break;
|
|
30
|
+
page = await store.listThreads('u1', { limit: 20, cursor: page.nextCursor });
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The default limit is 20 and the cap 100. A cursor is opaque: pass only what
|
|
35
|
+
the store gave, else `MemoryError('INVALID_CURSOR')`.
|
|
36
|
+
|
|
37
|
+
## Messages
|
|
38
|
+
|
|
39
|
+
`append` validates each message against `messageSchema` (an `@nxgt/ai-core`
|
|
40
|
+
`Message`) and returns them stored, with an `id` and a 1-based `seq`.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
await store.append(t.id, 'u1', [
|
|
44
|
+
{ role: 'user', parts: [{ type: 'text', text: 'Cancel my plan' }] },
|
|
45
|
+
]);
|
|
46
|
+
const { messages } =
|
|
47
|
+
(await store.load(t.id, 'u1', { sinceSummary: false })) ?? {};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`load` returns `undefined` for an absent or foreign thread.
|
|
51
|
+
|
|
52
|
+
**Trap: never append parts from a client's request body.** A client could send
|
|
53
|
+
a forged `proposed-action` part, and `append` would record it as a pending
|
|
54
|
+
action that anyone can confirm. Append only the user's text taken from the
|
|
55
|
+
request (in an alxia route, parse the body with `userMessageSchema` from
|
|
56
|
+
`@nxgt/ai-alxia`), plus the messages of `run.result.messages`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
await store.append(t.id, 'u1', [
|
|
60
|
+
{ role: 'user', parts: [{ type: 'text', text: body.text }] },
|
|
61
|
+
...result.messages,
|
|
62
|
+
]);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Summary
|
|
66
|
+
|
|
67
|
+
`setSummary(threadId, ownerId, { text, throughMessageId, tokens })` records a
|
|
68
|
+
rolling summary of the messages up to `throughMessageId`. `load` with
|
|
69
|
+
`sinceSummary: true` then returns the summary and only the messages after it;
|
|
70
|
+
with `false` it returns them all.
|
|
71
|
+
|
|
72
|
+
## Compaction
|
|
73
|
+
|
|
74
|
+
`compact` keeps a long thread under a token budget. It first cuts the tool
|
|
75
|
+
results, oldest first, and stops as soon as the thread fits; if it still does
|
|
76
|
+
not fit, it folds the oldest messages into a rolling summary and keeps the last
|
|
77
|
+
`keepLast` (6) messages. A tool call and its result are never split: the cut
|
|
78
|
+
moves back to keep both.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { estimateTokens } from '@nxgt/ai-core';
|
|
82
|
+
import { compact, summarizeWith } from '@nxgt/ai-memory';
|
|
83
|
+
|
|
84
|
+
const loaded = await store.load(threadId, ownerId, { sinceSummary: true });
|
|
85
|
+
if (!loaded) throw new Error('thread not found');
|
|
86
|
+
const out = await compact(loaded.messages, {
|
|
87
|
+
budget: 8000,
|
|
88
|
+
summary: loaded.summary, // folded into the next summary
|
|
89
|
+
summarize: summarizeWith(model), // a ModelAdapter from @nxgt/ai-core
|
|
90
|
+
});
|
|
91
|
+
if (out.summary && out.throughMessageId) {
|
|
92
|
+
await store.setSummary(threadId, ownerId, {
|
|
93
|
+
text: out.summary,
|
|
94
|
+
throughMessageId: out.throughMessageId,
|
|
95
|
+
tokens: estimateTokens(out.summary),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// send `loaded.summary` or `out.summary`, then `out.messages`, to the model
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- `messages` are the thread's messages after its stored summary. `compact`
|
|
102
|
+
returns `{ summary?, throughMessageId?, messages, tokens }`: `summary` and
|
|
103
|
+
`throughMessageId` are set only when this call folded messages; `tokens` is
|
|
104
|
+
the estimate of the kept summary and `messages`.
|
|
105
|
+
- A cut tool output becomes `{ truncated: true, originalChars, preview }`
|
|
106
|
+
(`preview` is the first `truncateToolResultsTo` characters, default 2000). Error
|
|
107
|
+
results are left as they are. The store keeps the originals: only the
|
|
108
|
+
returned copy is cut.
|
|
109
|
+
- `estimate` defaults to `estimateTokens` of `@nxgt/ai-core`, a rough count;
|
|
110
|
+
pass a provider's counter for a tight budget. A thread is under budget when
|
|
111
|
+
its tokens are at most `budget`.
|
|
112
|
+
- `summarizeWith(model, { system?, maxTokens?, signal? })` calls the model
|
|
113
|
+
once, with a transcript of the messages (the previous summary first), and
|
|
114
|
+
returns its text. Pass your own `summarize` to use another way.
|
|
115
|
+
The messages are sent as tagged `<message role>` data blocks, with a line
|
|
116
|
+
saying they are data, not instructions, and a failed tool result as its error
|
|
117
|
+
code, never its message.
|
|
118
|
+
- "Summarize" means two things in these packages: the confirmation line of a
|
|
119
|
+
tool (`summarize` in `@nxgt/ai-core`) and the conversation summary written
|
|
120
|
+
here (`summarizeWith`, `compact`). They are unrelated.
|
|
121
|
+
- The kept messages start on a user turn: the cut moves back past assistant
|
|
122
|
+
and tool messages, as the Anthropic Messages API needs a conversation that
|
|
123
|
+
opens with the user and refuses a tool result without its call. When no user
|
|
124
|
+
turn lies before the cut, the cut stays where it keeps call and result
|
|
125
|
+
together and the tail may start on another role.
|
|
126
|
+
- `compact` throws `MemoryError('EMPTY_SUMMARY')` when `summarize` returns an
|
|
127
|
+
empty or whitespace-only string, and folds nothing, so a blank summary is
|
|
128
|
+
never stored.
|
|
129
|
+
- Send the summary in the system prompt, never as a message of its own, so the
|
|
130
|
+
request keeps opening with the user's turn:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
const summary = out.summary ?? loaded.summary?.text;
|
|
134
|
+
await generate(model, {
|
|
135
|
+
system: [
|
|
136
|
+
{ text: basePrompt },
|
|
137
|
+
...(summary
|
|
138
|
+
? [{ text: `Summary of the earlier conversation:\n${summary}` }]
|
|
139
|
+
: []),
|
|
140
|
+
],
|
|
141
|
+
messages: out.messages.map((m) => m.message),
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- Compacting again an already compacted thread (its tail and stored summary)
|
|
146
|
+
changes nothing while it fits.
|
|
147
|
+
|
|
148
|
+
## Proposed actions
|
|
149
|
+
|
|
150
|
+
When an appended message holds a `proposed-action` part, the store records a
|
|
151
|
+
`pending` action you can find by its id.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const action = await store.findAction(actionId, 'u1');
|
|
155
|
+
const won = await store.updateAction(actionId, 'u1', { status: 'confirmed' });
|
|
156
|
+
if (!won) return; // absent, or someone else confirmed or rejected it first
|
|
157
|
+
await runConfirmedAction(/* ... */);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`updateAction` changes the action only while it is `pending` (a
|
|
161
|
+
compare-and-set), so two concurrent confirmations cannot both run the tool.
|
|
162
|
+
Patch `status` to `confirmed`, `rejected`, `expired` or `failed`, with the
|
|
163
|
+
tool's `result` when there is one.
|
|
164
|
+
|
|
165
|
+
## MongoDB store
|
|
166
|
+
|
|
167
|
+
`mongoMemory` from `@nxgt/ai-memory/mongo` stores threads and messages in two
|
|
168
|
+
collections you give it. The collections only need `insertOne`, `insertMany`,
|
|
169
|
+
`findOne`, `find` (with `sort`, `limit`, `toArray`), `findOneAndUpdate`,
|
|
170
|
+
`updateOne`, `deleteOne` and `deleteMany`: a `mongodb` driver `Collection`
|
|
171
|
+
(version 6 or later) has them, and so does a scoped collection of `@nxgt/mongo`.
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { mongoMemory } from '@nxgt/ai-memory/mongo';
|
|
175
|
+
|
|
176
|
+
const store = mongoMemory({
|
|
177
|
+
threads: db.collection('threads'),
|
|
178
|
+
messages: db.collection('messages'),
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Indexes
|
|
183
|
+
|
|
184
|
+
Create them once; the store does not.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { messageIndexes, threadIndexes } from '@nxgt/ai-memory/mongo';
|
|
188
|
+
|
|
189
|
+
for (const { key, ...options } of threadIndexes) {
|
|
190
|
+
await db.collection('threads').createIndex(key, options);
|
|
191
|
+
}
|
|
192
|
+
for (const { key, ...options } of messageIndexes) {
|
|
193
|
+
await db.collection('messages').createIndex(key, options);
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The messages' unique index on `ownerId, threadId, seq` is what keeps two
|
|
198
|
+
appends from sharing a position.
|
|
199
|
+
|
|
200
|
+
### Stored fields
|
|
201
|
+
|
|
202
|
+
`threadFields` and `messageFields` list the fields of each document, for
|
|
203
|
+
projections, migrations or a schema validator. `_id` is the thread or message
|
|
204
|
+
id (a time-ordered UUID, which is what pages `listThreads` newest first).
|
|
205
|
+
Every message document also carries its `ownerId`, and a message that proposes
|
|
206
|
+
actions carries an `actions` array with their current status.
|
|
207
|
+
|
|
208
|
+
### Expiry
|
|
209
|
+
|
|
210
|
+
`expireAfterSeconds` makes the store write an `expiresAt` date, and a thread
|
|
211
|
+
expires as one unit: each write (`append`, `setSummary`) moves the thread and
|
|
212
|
+
all its messages to that long after the write, so a load never starts with a
|
|
213
|
+
tool result whose call is gone. Create `expiresAtIndex` on both collections so MongoDB deletes them.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { expiresAtIndex } from '@nxgt/ai-memory/mongo';
|
|
217
|
+
|
|
218
|
+
const threads = db.collection('threads');
|
|
219
|
+
const messages = db.collection('messages');
|
|
220
|
+
|
|
221
|
+
await threads.createIndex(expiresAtIndex.key, { expireAfterSeconds: 0 });
|
|
222
|
+
await messages.createIndex(expiresAtIndex.key, { expireAfterSeconds: 0 });
|
|
223
|
+
const store = mongoMemory({
|
|
224
|
+
threads,
|
|
225
|
+
messages,
|
|
226
|
+
expireAfterSeconds: 30 * 86400,
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
MongoDB removes expired documents about once a minute, so they can be read for
|
|
231
|
+
a little after their time. A thread that is not written to for that long is
|
|
232
|
+
removed whole.
|
|
233
|
+
|
|
234
|
+
### Concurrency
|
|
235
|
+
|
|
236
|
+
`append` reserves its `seq` range with one atomic increment on the thread, so
|
|
237
|
+
concurrent appends never collide. `updateAction` is one `findOneAndUpdate`
|
|
238
|
+
that matches only an action still `pending`; of two concurrent confirmations
|
|
239
|
+
one gets the action and the other `undefined`. A failed insert in `append`
|
|
240
|
+
leaves a gap in `seq`; `load` still orders by it.
|
|
241
|
+
|
|
242
|
+
#### Deleting
|
|
243
|
+
|
|
244
|
+
`deleteThread` deletes the messages, then the thread, then the messages again.
|
|
245
|
+
It returns `false` for a foreign or absent owner. A crash after the first sweep
|
|
246
|
+
but before the thread delete leaves an empty thread, which a retry removes. A
|
|
247
|
+
crash after the thread delete can leave messages nobody can reach: a retry
|
|
248
|
+
returns `false`, and the TTL (`expiresAt`) removes them when expiry is on. An
|
|
249
|
+
`append` racing a delete checks the thread after its insert and removes its own
|
|
250
|
+
batch, throwing `THREAD_NOT_FOUND`; for a moment a pending action of that batch
|
|
251
|
+
can still be found by `findAction` or `updateAction`.
|
|
252
|
+
|
|
253
|
+
## Redis store
|
|
254
|
+
|
|
255
|
+
`redisMemory` from `@nxgt/ai-memory/redis` stores threads and messages in Redis
|
|
256
|
+
keys. Give it the client of `@nxgt/redis`: the `client` of a `connectRedis`
|
|
257
|
+
connection, or of an instance of `openRedis`. The store calls only
|
|
258
|
+
`client.send(command, args)`, so it opens no connection of its own and closes
|
|
259
|
+
nothing. It needs Redis 6.2 or later, and a single node or a replica set with
|
|
260
|
+
Sentinel: Redis Cluster is not supported, because one script touches several
|
|
261
|
+
keys.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { connectRedis } from '@nxgt/redis';
|
|
265
|
+
import { redisMemory } from '@nxgt/ai-memory/redis';
|
|
266
|
+
|
|
267
|
+
const redis = await connectRedis(process.env.REDIS_URL ?? 'redis://localhost');
|
|
268
|
+
const store = redisMemory({ client: redis.client });
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Keys
|
|
272
|
+
|
|
273
|
+
Every key starts with `prefix` (default `ai-memory`); give each application
|
|
274
|
+
that shares a Redis its own.
|
|
275
|
+
|
|
276
|
+
| key | type | holds |
|
|
277
|
+
| ------------------------------------ | ---------- | ---------------------------------------------------------------------- |
|
|
278
|
+
| `<prefix>:thread:<id>` | hash | the thread: `ownerId`, `title`, `meta`, `messageCount`, `summary`, ... |
|
|
279
|
+
| `<prefix>:messages:<id>` | sorted set | the messages of a thread, scored by `seq` |
|
|
280
|
+
| `<prefix>:ids:<id>` | hash | message id to `seq`, to find the summary's last message |
|
|
281
|
+
| `<prefix>:actions:<id>` | set | the keys of the actions the thread proposed |
|
|
282
|
+
| `<prefix>:action:<n>:<ownerId>:<id>` | hash | one proposed action: its `status`, its `body` and its `result` |
|
|
283
|
+
| `<prefix>:owner:<ownerId>` | sorted set | the ids of the owner's threads, which pages `listThreads` newest first |
|
|
284
|
+
|
|
285
|
+
`<n>` is the length of the owner id, so owner ids may hold `:` without their action keys meeting. Thread ids are time-ordered UUIDs. Reads need no index and no setup.
|
|
286
|
+
|
|
287
|
+
### Expiry
|
|
288
|
+
|
|
289
|
+
`expireAfterSeconds` sets an expiry on a thread's keys at every write that
|
|
290
|
+
touches the thread (`append`, `setSummary`): the thread, its messages and its
|
|
291
|
+
indexes go together, that long after the last write, and so do its
|
|
292
|
+
proposed actions (`updateAction` slides them too). The whole thread expires
|
|
293
|
+
from its last write, so a thread you keep writing to never expires and an idle one
|
|
294
|
+
disappears whole. The `now` option is the clock the expiry is computed from.
|
|
295
|
+
|
|
296
|
+
Redis deletes an expired key on access or in the background; a read after
|
|
297
|
+
the time never sees it. The owner index is not given an expiry: `listThreads`
|
|
298
|
+
skips the ids of expired threads and removes them as it goes.
|
|
299
|
+
|
|
300
|
+
### Concurrency
|
|
301
|
+
|
|
302
|
+
`append` is one Lua script: it checks the owner, reserves the `seq` range with
|
|
303
|
+
an atomic increment, and writes the messages and the new actions, so
|
|
304
|
+
concurrent appends never share a position and a reader never sees a gap.
|
|
305
|
+
`updateAction` is one script that matches only an action still `pending`; of
|
|
306
|
+
two concurrent confirmations one gets the action and the other `undefined`. An
|
|
307
|
+
action id is recorded once per owner, as in the other stores.
|
|
308
|
+
|
|
309
|
+
### Deleting
|
|
310
|
+
|
|
311
|
+
`deleteThread` is one script: it removes the messages, the actions, the thread
|
|
312
|
+
and its entry in the owner index together, and returns `false` for a foreign
|
|
313
|
+
or absent owner. It never leaves unreachable keys; an `append` racing it
|
|
314
|
+
finds the thread gone and throws `THREAD_NOT_FOUND`.
|
|
315
|
+
|
|
316
|
+
## Write your own store
|
|
317
|
+
|
|
318
|
+
Implement `MemoryStore` and run the contract suite against it:
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
import { memoryStoreContract } from '@nxgt/ai-memory/testing';
|
|
322
|
+
|
|
323
|
+
memoryStoreContract(() => myStore());
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Your `updateAction` must be atomic in the backing database (a conditional
|
|
327
|
+
update on `status: 'pending'`), not a read then a write.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
A direction, not a commitment. See the [repository roadmap](https://github.com/softistx/nxgt-ai/blob/develop/docs/roadmap.md) for the whole SDK.
|
|
4
|
+
|
|
5
|
+
## Now
|
|
6
|
+
|
|
7
|
+
- **The memory port and the in-process store** — you get:
|
|
8
|
+
- `MemoryStore`, scoped by owner on every call: threads, messages, a rolling summary and proposed actions.
|
|
9
|
+
- `inProcessMemory()`, an in-process store.
|
|
10
|
+
- `memoryStoreContract` in `@nxgt/ai-memory/testing`, to test your own stores.
|
|
11
|
+
- **Compaction** — you get:
|
|
12
|
+
- `compact`, which trims old tool results first, then keeps a rolling summary without splitting a tool call from its result.
|
|
13
|
+
- `summarizeWith(model)`, a summarizer over any model adapter.
|
|
14
|
+
- **MongoDB store** — `@nxgt/ai-memory/mongo`: `mongoMemory` over two collections, with optional expiry; it passes the same contract.
|
|
15
|
+
- **Redis store** — `@nxgt/ai-memory/redis`: `redisMemory` over the `@nxgt/redis` client, with optional expiry; it passes the same contract.
|
|
16
|
+
|
|
17
|
+
## Next
|
|
18
|
+
|
|
19
|
+
Nothing yet.
|
|
20
|
+
|
|
21
|
+
## Later
|
|
22
|
+
|
|
23
|
+
Nothing yet.
|
|
24
|
+
|
|
25
|
+
## Not planned
|
|
26
|
+
|
|
27
|
+
- **Searching across threads** — retrieval is out of scope for the SDK.
|
|
28
|
+
|
|
29
|
+
## Shipped
|
|
30
|
+
|
|
31
|
+
Nothing yet. Releases are listed in `CHANGELOG.md`.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
- [`MemoryError` THREAD_NOT_FOUND](#memoryerror-thread_not_found)
|
|
4
|
+
- [`MemoryError` MESSAGE_NOT_FOUND](#memoryerror-message_not_found)
|
|
5
|
+
- [`MemoryError` INVALID_CURSOR](#memoryerror-invalid_cursor)
|
|
6
|
+
- [`MemoryError` INVALID_OWNER](#memoryerror-invalid_owner)
|
|
7
|
+
- [`updateAction` returns undefined](#updateaction-returns-undefined)
|
|
8
|
+
- [`compact` returns more tokens than `budget`](#compact-returns-more-tokens-than-budget)
|
|
9
|
+
- [The summary forgets what the previous one said](#the-summary-forgets-what-the-previous-one-said)
|
|
10
|
+
- [The model sees `{ truncated: true, preview }` as a tool result](#the-model-sees--truncated-true-preview--as-a-tool-result)
|
|
11
|
+
- [`MemoryError` EMPTY_SUMMARY](#memoryerror-empty_summary)
|
|
12
|
+
- [Mongo store: `E11000 duplicate key` on `ownerId_1_threadId_1_seq_1`](#mongo-store-e11000-duplicate-key-on-ownerid_1_threadid_1_seq_1)
|
|
13
|
+
- [Mongo store: expired threads and messages are still read](#mongo-store-expired-threads-and-messages-are-still-read)
|
|
14
|
+
- [Mongo store: `findOneAndUpdate` returns a result object, not a document](#mongo-store-findoneandupdate-returns-a-result-object-not-a-document)
|
|
15
|
+
- [Mongo store: a failed `append` leaves a gap in `seq`](#mongo-store-a-failed-append-leaves-a-gap-in-seq)
|
|
16
|
+
- [Redis store: `ERR syntax error` when listing threads](#redis-store-err-syntax-error-when-listing-threads)
|
|
17
|
+
- [Redis store: `CROSSSLOT Keys in request don't hash to the same slot`](#redis-store-crossslot-keys-in-request-dont-hash-to-the-same-slot)
|
|
18
|
+
- [Redis store: `WRONGTYPE Operation against a key holding the wrong kind of value`](#redis-store-wrongtype-operation-against-a-key-holding-the-wrong-kind-of-value)
|
|
19
|
+
- [Redis store: `ERR_REDIS_CONNECTION_CLOSED`](#redis-store-err_redis_connection_closed)
|
|
20
|
+
- [Redis store: a thread is gone before its time](#redis-store-a-thread-is-gone-before-its-time)
|
|
21
|
+
|
|
22
|
+
## `MemoryError` THREAD_NOT_FOUND
|
|
23
|
+
|
|
24
|
+
**When:** `append` or `setSummary` throws `Thread "…" was not found.`
|
|
25
|
+
**Why:** the thread does not exist, was deleted, or belongs to another owner; the store does not tell these apart. A Mongo `append` racing a `deleteThread` also throws it and removes its own batch; this is expected, so do not retry.
|
|
26
|
+
**Fix:** check the `ownerId` is the one that created the thread.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const thread = await store.getThread(id, ownerId);
|
|
30
|
+
if (!thread) return notFound();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## `MemoryError` MESSAGE_NOT_FOUND
|
|
34
|
+
|
|
35
|
+
**When:** `setSummary` throws `Message "…" is not in thread "…".`
|
|
36
|
+
**Why:** `throughMessageId` is not the `id` of a message of that thread.
|
|
37
|
+
**Fix:** use an `id` returned by `append` or `load`, not a `seq`.
|
|
38
|
+
|
|
39
|
+
## `MemoryError` INVALID_CURSOR
|
|
40
|
+
|
|
41
|
+
**When:** `listThreads` throws `"…" is not a cursor this store gave.`
|
|
42
|
+
**Why:** the cursor was edited, or came from another store.
|
|
43
|
+
**Fix:** pass back the `nextCursor` of the previous page unchanged.
|
|
44
|
+
|
|
45
|
+
## `MemoryError` INVALID_OWNER
|
|
46
|
+
|
|
47
|
+
**When:** any call throws `ownerId must be a non-empty string.`
|
|
48
|
+
**Why:** the owner id is empty or missing, which would put every caller in one scope.
|
|
49
|
+
**Fix:** resolve the signed-in user before calling the store.
|
|
50
|
+
|
|
51
|
+
## `updateAction` returns undefined
|
|
52
|
+
|
|
53
|
+
**When:** you confirm an action and get `undefined`.
|
|
54
|
+
**Why:** the action is absent, belongs to another owner, or is no longer `pending` because a concurrent call or an earlier one already settled it.
|
|
55
|
+
**Fix:** treat it as "not yours to run": do not execute the tool. Call `findAction` to see the status.
|
|
56
|
+
|
|
57
|
+
## `compact` returns more tokens than `budget`
|
|
58
|
+
|
|
59
|
+
It happens when the last `keepLast` messages (default 6), or a tool call and the
|
|
60
|
+
result kept with it, are alone over the budget. `compact` never drops them. Lower
|
|
61
|
+
`keepLast`, lower `truncateToolResultsTo`, or raise `budget`.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
await compact(messages, {
|
|
65
|
+
budget,
|
|
66
|
+
keepLast: 2,
|
|
67
|
+
truncateToolResultsTo: 500,
|
|
68
|
+
summarize,
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## The summary forgets what the previous one said
|
|
73
|
+
|
|
74
|
+
`compact` only knows the earlier summary when you give it. Pass the thread's
|
|
75
|
+
stored one as `summary`.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const loaded = await store.load(id, ownerId, { sinceSummary: true });
|
|
79
|
+
await compact(loaded.messages, { budget, summary: loaded.summary, summarize });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## The model sees `{ truncated: true, preview }` as a tool result
|
|
83
|
+
|
|
84
|
+
That is a tool output cut by `compact` because it was longer than
|
|
85
|
+
`truncateToolResultsTo` characters. The stored message is unchanged. Raise
|
|
86
|
+
`truncateToolResultsTo`, or have the tool return less.
|
|
87
|
+
|
|
88
|
+
## `MemoryError` EMPTY_SUMMARY
|
|
89
|
+
|
|
90
|
+
`summarize` returned an empty or whitespace-only string, so `compact` folded
|
|
91
|
+
nothing rather than let a blank summary replace the messages. Check the model's
|
|
92
|
+
reply (a refusal or a `maxTokens` too low gives no text) and retry.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
try {
|
|
96
|
+
await compact(messages, { budget, summarize });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e instanceof MemoryError && e.code === 'EMPTY_SUMMARY') retryLater();
|
|
99
|
+
else throw e;
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Mongo store: `E11000 duplicate key` on `ownerId_1_threadId_1_seq_1`
|
|
104
|
+
|
|
105
|
+
**When:** `append` on the Mongo store fails with a duplicate key error.
|
|
106
|
+
**Why:** two documents got the same `seq` in a thread: the messages were written by something other than the store, or `messageCount` of the thread was changed by hand.
|
|
107
|
+
**Fix:** let only the store write these collections. Set the thread's `messageCount` to the highest `seq` of its messages again.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const last = await messages
|
|
111
|
+
.find({ threadId })
|
|
112
|
+
.sort({ seq: -1 })
|
|
113
|
+
.limit(1)
|
|
114
|
+
.toArray();
|
|
115
|
+
await threads.updateOne(
|
|
116
|
+
{ _id: threadId },
|
|
117
|
+
{ $set: { messageCount: last[0]?.seq ?? 0 } },
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Mongo store: expired threads and messages are still read
|
|
122
|
+
|
|
123
|
+
**When:** a thread past its `expireAfterSeconds` still loads.
|
|
124
|
+
**Why:** MongoDB's TTL monitor runs about once a minute, and it only runs for a collection that has `expiresAtIndex`.
|
|
125
|
+
**Fix:** create `expiresAtIndex` on both collections (see the guide), and do not rely on the exact second of expiry.
|
|
126
|
+
|
|
127
|
+
## Mongo store: `findOneAndUpdate` returns a result object, not a document
|
|
128
|
+
|
|
129
|
+
**When:** `append` stores `NaN` as a `seq`, or `updateAction` returns `undefined` for a pending action that exists.
|
|
130
|
+
**Why:** the collection resolves `findOneAndUpdate` to `{ value }`, as `mongodb` 5 and earlier do; the store needs the document itself.
|
|
131
|
+
**Fix:** use `mongodb` 6 or later, or wrap the collection so `findOneAndUpdate` resolves to the document.
|
|
132
|
+
|
|
133
|
+
## Mongo store: a failed `append` leaves a gap in `seq`
|
|
134
|
+
|
|
135
|
+
**When:** an `append` threw while inserting, and the next messages start at a higher `seq` than the last stored one.
|
|
136
|
+
**Why:** the store reserves the `seq` range on the thread before inserting, so a failed insert keeps `messageCount` advanced.
|
|
137
|
+
**Fix:** none needed. A retry of the `append` works, but the gap stays. It is harmless: `load` orders by `seq`.
|
|
138
|
+
|
|
139
|
+
## Redis store: `ERR syntax error` when listing threads
|
|
140
|
+
|
|
141
|
+
**When:** `listThreads` fails with `ERR syntax error`.
|
|
142
|
+
**Why:** it pages with `ZRANGE ... BYLEX REV`, which Redis 6.2 added; an older server does not know the form.
|
|
143
|
+
**Fix:** use Redis 6.2 or later.
|
|
144
|
+
|
|
145
|
+
## Redis store: `CROSSSLOT Keys in request don't hash to the same slot`
|
|
146
|
+
|
|
147
|
+
**When:** `append`, `setSummary` or `deleteThread` fails on a Redis Cluster.
|
|
148
|
+
**Why:** each script touches the keys of a thread, which a cluster spreads over several slots, and builds the action keys inside the script instead of declaring them in `KEYS`. Redis Cluster is not supported. The store supports a single node, or one primary with replicas behind Sentinel.
|
|
149
|
+
**Fix:** point the client at a non-cluster Redis.
|
|
150
|
+
|
|
151
|
+
## Redis store: `WRONGTYPE Operation against a key holding the wrong kind of value`
|
|
152
|
+
|
|
153
|
+
**When:** any call fails with `WRONGTYPE`.
|
|
154
|
+
**Why:** another application, or a cache, writes keys that start with the same `prefix`.
|
|
155
|
+
**Fix:** give the store a prefix of its own, and clear the keys that collide.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const store = redisMemory({ client, prefix: 'support-chat' });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Redis store: `ERR_REDIS_CONNECTION_CLOSED`
|
|
162
|
+
|
|
163
|
+
**When:** a call rejects with `ERR_REDIS_CONNECTION_CLOSED`.
|
|
164
|
+
**Why:** the client was closed, by `connection.close()` or `closeRedis()` of `@nxgt/redis`, while the store still used it. The store never opens or closes a connection.
|
|
165
|
+
**Fix:** close the connection after the last call, at shutdown, and build the store from a connection that is still open.
|
|
166
|
+
|
|
167
|
+
## Redis store: a thread is gone before its time
|
|
168
|
+
|
|
169
|
+
**When:** `getThread` returns `undefined` or `append` throws `THREAD_NOT_FOUND` for a thread you created.
|
|
170
|
+
**Why:** `expireAfterSeconds` counts from the thread's last write, and the clock the expiry uses is `now`: a `now` that returns a time in the past, or a short TTL, expires the thread at once.
|
|
171
|
+
**Fix:** leave `now` out (it is `Date.now`) or give it epoch milliseconds, and set `expireAfterSeconds` longer than the idle time you accept.
|
package/package.json
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@nxgt/ai-memory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Conversation memory for @nxgt/ai-core agents: an owner-scoped store port, an in-process store and a contract suite for your own stores",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"docs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"package.json",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./mongo": {
|
|
23
|
+
"types": "./dist/mongo.d.ts",
|
|
24
|
+
"import": "./dist/mongo.js",
|
|
25
|
+
"default": "./dist/mongo.js"
|
|
26
|
+
},
|
|
27
|
+
"./redis": {
|
|
28
|
+
"types": "./dist/redis.d.ts",
|
|
29
|
+
"import": "./dist/redis.js",
|
|
30
|
+
"default": "./dist/redis.js"
|
|
31
|
+
},
|
|
32
|
+
"./testing": {
|
|
33
|
+
"types": "./dist/testing.d.ts",
|
|
34
|
+
"import": "./dist/testing.js",
|
|
35
|
+
"default": "./dist/testing.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"ai",
|
|
41
|
+
"agent",
|
|
42
|
+
"llm",
|
|
43
|
+
"memory",
|
|
44
|
+
"conversation",
|
|
45
|
+
"bun",
|
|
46
|
+
"typescript"
|
|
47
|
+
],
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/softistx/nxgt-ai.git",
|
|
51
|
+
"directory": "packages/ai-memory"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"registry": "https://registry.npmjs.org",
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "bun run ../../build.ts",
|
|
59
|
+
"test": "bun run build:redis && bun test --timeout 30000 src",
|
|
60
|
+
"typecheck": "tsc --noEmit",
|
|
61
|
+
"build:redis": "bun run test/redis.ts"
|
|
62
|
+
},
|
|
63
|
+
"nxgt": {
|
|
64
|
+
"entrypoints": [
|
|
65
|
+
"src/index.ts",
|
|
66
|
+
"src/mongo.ts",
|
|
67
|
+
"src/redis.ts",
|
|
68
|
+
"src/testing.ts"
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@nxgt/ai-core": "^0.2.0",
|
|
73
|
+
"@nxgt/ai-testing": "^0.1.1",
|
|
74
|
+
"@nxgt/redis": "0.11.0",
|
|
75
|
+
"@types/bun": "^1.4.4",
|
|
76
|
+
"mongodb": "7.6.0",
|
|
77
|
+
"mongodb-memory-server-core": "11.2.0",
|
|
78
|
+
"redis-memory-server": "0.17.1",
|
|
79
|
+
"zod": "4.6.5"
|
|
80
|
+
},
|
|
81
|
+
"peerDependencies": {
|
|
82
|
+
"@nxgt/ai-core": "^0.2.0",
|
|
83
|
+
"typescript": "^6.0.3 || ^7.0.0",
|
|
84
|
+
"zod": ">=4.6.5 <5"
|
|
85
|
+
}
|
|
86
|
+
}
|