@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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -2
  3. package/dist/actions.d.ts +9 -0
  4. package/dist/actions.d.ts.map +1 -0
  5. package/dist/chunks/index-082dnty9.js +188 -0
  6. package/dist/chunks/index-082dnty9.js.map +14 -0
  7. package/dist/chunks/index-vj24rcgc.js +119 -0
  8. package/dist/chunks/index-vj24rcgc.js.map +13 -0
  9. package/dist/compact.d.ts +38 -0
  10. package/dist/compact.d.ts.map +1 -0
  11. package/dist/contract-actions.d.ts +3 -0
  12. package/dist/contract-actions.d.ts.map +1 -0
  13. package/dist/contract-messages.d.ts +3 -0
  14. package/dist/contract-messages.d.ts.map +1 -0
  15. package/dist/contract-owner.d.ts +3 -0
  16. package/dist/contract-owner.d.ts.map +1 -0
  17. package/dist/contract-samples.d.ts +6 -0
  18. package/dist/contract-samples.d.ts.map +1 -0
  19. package/dist/contract-threads.d.ts +3 -0
  20. package/dist/contract-threads.d.ts.map +1 -0
  21. package/dist/contract.d.ts +9 -0
  22. package/dist/contract.d.ts.map +1 -0
  23. package/dist/errors.d.ts +11 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/guards.d.ts +5 -0
  26. package/dist/guards.d.ts.map +1 -0
  27. package/dist/in-process.d.ts +8 -0
  28. package/dist/in-process.d.ts.map +1 -0
  29. package/dist/index.d.ts +8 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +167 -0
  32. package/dist/index.js.map +13 -0
  33. package/dist/messages.d.ts +7 -0
  34. package/dist/messages.d.ts.map +1 -0
  35. package/dist/mongo-actions.d.ts +8 -0
  36. package/dist/mongo-actions.d.ts.map +1 -0
  37. package/dist/mongo-collection.d.ts +32 -0
  38. package/dist/mongo-collection.d.ts.map +1 -0
  39. package/dist/mongo-ctx.d.ts +12 -0
  40. package/dist/mongo-ctx.d.ts.map +1 -0
  41. package/dist/mongo-fields.d.ts +41 -0
  42. package/dist/mongo-fields.d.ts.map +1 -0
  43. package/dist/mongo-map.d.ts +8 -0
  44. package/dist/mongo-map.d.ts.map +1 -0
  45. package/dist/mongo-messages.d.ts +7 -0
  46. package/dist/mongo-messages.d.ts.map +1 -0
  47. package/dist/mongo-store.d.ts +16 -0
  48. package/dist/mongo-store.d.ts.map +1 -0
  49. package/dist/mongo-threads.d.ts +13 -0
  50. package/dist/mongo-threads.d.ts.map +1 -0
  51. package/dist/mongo.d.ts +4 -0
  52. package/dist/mongo.d.ts.map +1 -0
  53. package/dist/mongo.js +271 -0
  54. package/dist/mongo.js.map +16 -0
  55. package/dist/paging.d.ts +8 -0
  56. package/dist/paging.d.ts.map +1 -0
  57. package/dist/pairs.d.ts +14 -0
  58. package/dist/pairs.d.ts.map +1 -0
  59. package/dist/redis-actions.d.ts +8 -0
  60. package/dist/redis-actions.d.ts.map +1 -0
  61. package/dist/redis-ctx.d.ts +37 -0
  62. package/dist/redis-ctx.d.ts.map +1 -0
  63. package/dist/redis-map.d.ts +13 -0
  64. package/dist/redis-map.d.ts.map +1 -0
  65. package/dist/redis-messages.d.ts +7 -0
  66. package/dist/redis-messages.d.ts.map +1 -0
  67. package/dist/redis-scripts.d.ts +40 -0
  68. package/dist/redis-scripts.d.ts.map +1 -0
  69. package/dist/redis-store.d.ts +21 -0
  70. package/dist/redis-store.d.ts.map +1 -0
  71. package/dist/redis-threads.d.ts +16 -0
  72. package/dist/redis-threads.d.ts.map +1 -0
  73. package/dist/redis.d.ts +3 -0
  74. package/dist/redis.d.ts.map +1 -0
  75. package/dist/redis.js +373 -0
  76. package/dist/redis.js.map +16 -0
  77. package/dist/schemas.d.ts +124 -0
  78. package/dist/schemas.d.ts.map +1 -0
  79. package/dist/state.d.ts +28 -0
  80. package/dist/state.d.ts.map +1 -0
  81. package/dist/summarize.d.ts +11 -0
  82. package/dist/summarize.d.ts.map +1 -0
  83. package/dist/testing.d.ts +3 -0
  84. package/dist/testing.d.ts.map +1 -0
  85. package/dist/testing.js +264 -0
  86. package/dist/testing.js.map +15 -0
  87. package/dist/threads.d.ts +7 -0
  88. package/dist/threads.d.ts.map +1 -0
  89. package/dist/truncate.d.ts +17 -0
  90. package/dist/truncate.d.ts.map +1 -0
  91. package/dist/types.d.ts +92 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/docs/README.md +9 -0
  94. package/docs/guide/memory.md +327 -0
  95. package/docs/roadmap.md +31 -0
  96. package/docs/troubleshooting.md +171 -0
  97. 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.
@@ -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
- "name": "@nxgt/ai-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }