@anteros/core 0.0.40 → 0.0.42

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 (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. package/utils/func.ts +67 -52
@@ -0,0 +1,586 @@
1
+ import { AppError } from "./error";
2
+ import { parseDuration } from "../utils/func";
3
+ import { syncTtlRetention } from "../database/ttl";
4
+ import { writeDeleteMarkers, replicatesCollection } from "../database/deleteLog";
5
+ import { Redis } from "ioredis";
6
+ import type { Db } from "mongodb";
7
+ import type {
8
+ AgentMemory,
9
+ AgentMemoryContext,
10
+ AgentMemoryOptions,
11
+ AgentMemoryState,
12
+ AgentMessage,
13
+ AgentThread,
14
+ MongoAgentMemoryOptions,
15
+ RedisAgentMemoryOptions,
16
+ RedisClientLike,
17
+ } from "../types/agent";
18
+
19
+ /**
20
+ * Conversation stores — `agents.memory`.
21
+ *
22
+ * An agent with a `memory` reads its thread before a run and writes the updated
23
+ * conversation after it. Three implementations ship with the framework:
24
+ *
25
+ * - **`agents.memory.InMemory`** — a `Map` in the process. Fine for a demo, a
26
+ * script or a single-process server; useless across processes.
27
+ * - **`agents.memory.Mongo`** — one document per thread in a collection **you
28
+ * name** (`_memories_`, `support_threads`…). Durable, shared by every process,
29
+ * with a message cap, an optional TTL, a thread list, and replicated by the
30
+ * replication engine like a declared collection.
31
+ * - **`agents.memory.Redis`** — one key per thread, shared by every process
32
+ * without MongoDB. Same options, plus a per-thread expiry.
33
+ *
34
+ * All three share the same policy: **a thread is bounded** (`maxMessages`, default
35
+ * 100 — the oldest messages are dropped on save) and **namespaced per caller**
36
+ * (`resourceId:threadId`, unless `scoped: false`).
37
+ */
38
+
39
+ /** Default `maxMessages` — the cap is applied on every save. */
40
+ const DEFAULT_MAX_MESSAGES = 100;
41
+ const DEFAULT_LIST_LIMIT = 20;
42
+
43
+ /** Thread title derived from the first user message (the document's, set once). */
44
+ const TITLE_MAX = 80;
45
+
46
+ /**
47
+ * The document key of a thread.
48
+ *
49
+ * With `scoped` (default) the caller identity prefixes the id, so two callers
50
+ * using the same `threadId` never share a conversation. A missing `resourceId`
51
+ * falls back to the bare id.
52
+ */
53
+ function threadKey(threadId: string, ctx?: AgentMemoryContext, scoped = true): string {
54
+ const resourceId = ctx?.resourceId;
55
+ if (!scoped || !resourceId) return threadId;
56
+ return `${resourceId}:${threadId}`;
57
+ }
58
+
59
+ /**
60
+ * Keep the last `maxMessages` — the cap is a hard limit, and the opening
61
+ * `system` (or `developer`) message, when there is one, takes one of the slots
62
+ * instead of being the first casualty of a long conversation.
63
+ */
64
+ function trimMessages(messages: AgentMessage[], maxMessages: number): AgentMessage[] {
65
+ if (messages.length <= maxMessages) return messages;
66
+
67
+ const opening = messages[0]?.role === "system" || messages[0]?.role === "developer" ? messages[0] : undefined;
68
+ const budget = Math.max(0, maxMessages - (opening ? 1 : 0));
69
+ const kept = budget === 0 ? [] : messages.slice(messages.length - budget);
70
+ return opening ? [opening, ...kept] : kept;
71
+ }
72
+
73
+ function threadTitle(messages: AgentMessage[]): string | undefined {
74
+ const first = messages.find((message) => message.role === "user");
75
+ const text = typeof first?.content === "string"
76
+ ? first.content
77
+ : Array.isArray(first?.content)
78
+ ? first.content.filter((part) => part.type === "text").map((part) => (part as any).text).join(" ")
79
+ : "";
80
+ const title = text.trim().replace(/\s+/g, " ");
81
+ if (!title) return undefined;
82
+ return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}…` : title;
83
+ }
84
+
85
+ function cloneMessages(messages: AgentMessage[]): AgentMessage[] {
86
+ return messages.map((message) => ({ ...message }));
87
+ }
88
+
89
+ // ─── In-memory ──────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * In-process conversation store — the default when an agent declares no
93
+ * `memory`.
94
+ *
95
+ * ```ts
96
+ * import { agents } from '@anteros/core'
97
+ * const memory = new agents.memory.InMemory({ maxMessages: 40 })
98
+ * ```
99
+ *
100
+ * One `Map` per process: in a multi-process deployment (`reusePort`, several
101
+ * containers) each process has its own threads — use `agents.memory.Mongo()`
102
+ * when a conversation must be shared.
103
+ */
104
+ export class InMemoryAgentMemory implements AgentMemory {
105
+ #threads = new Map<string, { messages: AgentMessage[]; title?: string; createdAt: Date; updatedAt: Date }>();
106
+ #states = new Map<string, AgentMemoryState>();
107
+ #maxMessages: number;
108
+ #scoped: boolean;
109
+
110
+ constructor(options: AgentMemoryOptions = {}) {
111
+ this.#maxMessages = Math.max(1, options.maxMessages ?? DEFAULT_MAX_MESSAGES);
112
+ this.#scoped = options.scoped ?? true;
113
+ }
114
+
115
+ get(threadId: string, ctx?: AgentMemoryContext): AgentMessage[] {
116
+ const thread = this.#threads.get(threadKey(threadId, ctx, this.#scoped));
117
+ return thread ? cloneMessages(thread.messages) : [];
118
+ }
119
+
120
+ save(threadId: string, messages: AgentMessage[], ctx?: AgentMemoryContext): void {
121
+ const key = threadKey(threadId, ctx, this.#scoped);
122
+ const existing = this.#threads.get(key);
123
+ const trimmed = trimMessages(messages, this.#maxMessages);
124
+
125
+ this.#threads.set(key, {
126
+ messages: cloneMessages(trimmed),
127
+ title: existing?.title ?? threadTitle(trimmed),
128
+ createdAt: existing?.createdAt ?? new Date(),
129
+ updatedAt: new Date(),
130
+ });
131
+ }
132
+
133
+ clear(threadId: string, ctx?: AgentMemoryContext): void {
134
+ this.#threads.delete(threadKey(threadId, ctx, this.#scoped));
135
+ }
136
+
137
+ list(ctx?: AgentMemoryContext & { limit?: number }): AgentThread[] {
138
+ const prefix = this.#scoped && ctx?.resourceId ? `${ctx.resourceId}:` : "";
139
+ return [...this.#threads.entries()]
140
+ .filter(([key]) => (prefix ? key.startsWith(prefix) : true))
141
+ .map(([key, thread]) => ({
142
+ threadId: prefix ? key.slice(prefix.length) : key,
143
+ resourceId: ctx?.resourceId,
144
+ title: thread.title,
145
+ messages: thread.messages.length,
146
+ createdAt: thread.createdAt,
147
+ updatedAt: thread.updatedAt,
148
+ }))
149
+ .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
150
+ .slice(0, ctx?.limit ?? DEFAULT_LIST_LIMIT);
151
+ }
152
+
153
+ /** Thread keys currently held — debugging helper. */
154
+ threads(): string[] {
155
+ return [...this.#threads.keys()];
156
+ }
157
+
158
+ getState(key: string): AgentMemoryState | undefined {
159
+ const state = this.#states.get(key);
160
+ return state ? { ...state } : undefined;
161
+ }
162
+
163
+ setState(key: string, value: AgentMemoryState): void {
164
+ this.#states.set(key, { ...value });
165
+ }
166
+
167
+ clearState(key: string): void {
168
+ this.#states.delete(key);
169
+ }
170
+ }
171
+
172
+ // ─── MongoDB ────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Durable conversation store — one document per thread in the tenant database.
176
+ *
177
+ * ```ts
178
+ * export default define.Agent({
179
+ * // …
180
+ * memory: new agents.memory.Mongo({ collection: '_memories_', maxMessages: 60, ttl: '180d' }),
181
+ * })
182
+ * ```
183
+ *
184
+ * The **collection name is required**: the threads are your data, they live where
185
+ * you decide, and the replication engine copies that collection like a declared
186
+ * one (the loader publishes it — opt out of it with `replication.exclude: ['memory']`).
187
+ *
188
+ * Writes go through the **context's** `rest` (the tenant database), exactly like
189
+ * a tool: register it in the definition and every tenant keeps its own threads.
190
+ * The collection and its indexes are created on first use — never at boot.
191
+ */
192
+ export class MongoAgentMemory implements AgentMemory {
193
+ #options: MongoAgentMemoryOptions;
194
+ #ready = new WeakMap<Db, Promise<void>>();
195
+
196
+ constructor(options: MongoAgentMemoryOptions) {
197
+ if (!options?.collection) {
198
+ throw new AppError(
199
+ "A Mongo memory store needs its `collection` — `new agents.memory.Mongo({ collection: '_memories_' })`",
200
+ { status: 500, code: "AGENT_MEMORY_COLLECTION_REQUIRED" },
201
+ );
202
+ }
203
+ this.#options = options;
204
+ }
205
+
206
+ /** Collection holding the threads, in the tenant database. */
207
+ get collection(): string {
208
+ return this.#options.collection;
209
+ }
210
+
211
+ /** The replication engine copies this collection — see `syncAgents()`. */
212
+ collections(): string[] {
213
+ return [this.#options.collection, this.stateCollection];
214
+ }
215
+
216
+ /**
217
+ * Where the working memory lives: `${collection}__state`, derived so a tenant
218
+ * names one collection and gets both (the threads, and the scratchpads).
219
+ */
220
+ get stateCollection(): string {
221
+ return `${this.#options.collection}__state`;
222
+ }
223
+
224
+ async getState(key: string, ctx?: AgentMemoryContext): Promise<AgentMemoryState | undefined> {
225
+ const db = this.#db(ctx);
226
+ const doc: any = await db.collection(this.stateCollection).findOne({ _id: key as any });
227
+ return doc ? { value: doc.value, updatedAt: doc.updatedAt } : undefined;
228
+ }
229
+
230
+ async setState(key: string, value: AgentMemoryState, ctx?: AgentMemoryContext): Promise<void> {
231
+ const db = this.#db(ctx);
232
+ await db.collection(this.stateCollection).updateOne(
233
+ { _id: key as any },
234
+ {
235
+ $set: { value: value.value, updatedAt: value.updatedAt ?? new Date() },
236
+ $setOnInsert: { createdAt: new Date() },
237
+ },
238
+ { upsert: true },
239
+ );
240
+ }
241
+
242
+ async clearState(key: string, ctx?: AgentMemoryContext): Promise<void> {
243
+ const db = this.#db(ctx);
244
+ await db.collection(this.stateCollection).deleteOne({ _id: key as any });
245
+ }
246
+
247
+ #db(ctx?: AgentMemoryContext): Db {
248
+ const db: any = (ctx?.rest as any)?.db;
249
+ if (!db?.collection) {
250
+ throw new AppError(
251
+ 'A Mongo memory store needs a `rest` — resolve the agent from a context (`agents.get(id)`) or pass one (`agents.get(tenantId, id, rest)`)',
252
+ { status: 500, code: "AGENT_MEMORY_NO_REST" },
253
+ );
254
+ }
255
+ return db as Db;
256
+ }
257
+
258
+ /** Indexes and retention, once per database connection. */
259
+ #ensure(db: Db): Promise<void> {
260
+ let pending = this.#ready.get(db);
261
+ if (pending) return pending;
262
+
263
+ pending = (async () => {
264
+ try {
265
+ const collection = db.collection(this.collection);
266
+ // `list()` — the caller's most recent threads
267
+ await collection.createIndex({ resourceId: 1, updatedAt: -1 }, { background: true });
268
+ if (this.#options.ttl !== undefined) {
269
+ await syncTtlRetention(db, {
270
+ collection: this.collection,
271
+ index: `${this.collection}_updatedAt_ttl`,
272
+ field: "updatedAt",
273
+ retention: this.#options.ttl,
274
+ label: "Agent memory",
275
+ });
276
+ }
277
+ } catch (err: any) {
278
+ // Never fatal: a missing index costs performance, not correctness
279
+ console.error(`Agent memory index setup failed: ${err?.message}`);
280
+ }
281
+ })();
282
+
283
+ this.#ready.set(db, pending);
284
+ return pending;
285
+ }
286
+
287
+ async get(threadId: string, ctx?: AgentMemoryContext): Promise<AgentMessage[]> {
288
+ const db = this.#db(ctx);
289
+ await this.#ensure(db);
290
+
291
+ const doc: any = await db.collection(this.collection).findOne({
292
+ _id: threadKey(threadId, ctx, this.#options.scoped ?? true) as any,
293
+ });
294
+ return (doc?.messages ?? []) as AgentMessage[];
295
+ }
296
+
297
+ async save(threadId: string, messages: AgentMessage[], ctx?: AgentMemoryContext): Promise<void> {
298
+ const db = this.#db(ctx);
299
+ await this.#ensure(db);
300
+
301
+ const scoped = this.#options.scoped ?? true;
302
+ const trimmed = trimMessages(messages, Math.max(1, this.#options.maxMessages ?? DEFAULT_MAX_MESSAGES));
303
+ const now = new Date();
304
+
305
+ await db.collection(this.collection).updateOne(
306
+ { _id: threadKey(threadId, ctx, scoped) as any },
307
+ {
308
+ $set: {
309
+ threadId,
310
+ resourceId: ctx?.resourceId,
311
+ agentId: ctx?.agentId,
312
+ messages: trimmed,
313
+ updatedAt: now,
314
+ },
315
+ // The title and the creation date tell the story of the thread —
316
+ // a later save must not overwrite them.
317
+ $setOnInsert: {
318
+ createdAt: now,
319
+ ...(threadTitle(trimmed) ? { title: threadTitle(trimmed) } : {}),
320
+ },
321
+ },
322
+ { upsert: true },
323
+ );
324
+ }
325
+
326
+ async clear(threadId: string, ctx?: AgentMemoryContext): Promise<void> {
327
+ const db = this.#db(ctx);
328
+ await this.#ensure(db);
329
+
330
+ const key = threadKey(threadId, ctx, this.#options.scoped ?? true);
331
+ await db.collection(this.collection).deleteOne({ _id: key as any });
332
+
333
+ // A deleted thread no longer exists, so a date cursor can never see it —
334
+ // leave a tombstone so the deletion reaches the destinations too. Only when
335
+ // that collection is actually replicated for the tenant.
336
+ if (ctx?.tenant && replicatesCollection(ctx.tenant, this.collection)) {
337
+ await writeDeleteMarkers(db, ctx.tenant, this.collection, [key]);
338
+ }
339
+ }
340
+
341
+ async list(ctx?: AgentMemoryContext & { limit?: number }): Promise<AgentThread[]> {
342
+ const db = this.#db(ctx);
343
+ await this.#ensure(db);
344
+
345
+ const scoped = this.#options.scoped ?? true;
346
+ const filter = scoped && ctx?.resourceId ? { resourceId: ctx.resourceId } : {};
347
+
348
+ const docs: any[] = await db.collection(this.collection)
349
+ .find(filter)
350
+ .sort({ updatedAt: -1 })
351
+ .limit(ctx?.limit ?? DEFAULT_LIST_LIMIT)
352
+ .toArray();
353
+
354
+ return docs.map((doc) => ({
355
+ threadId: doc.threadId ?? doc._id,
356
+ resourceId: doc.resourceId,
357
+ title: doc.title,
358
+ messages: Array.isArray(doc.messages) ? doc.messages.length : 0,
359
+ createdAt: doc.createdAt,
360
+ updatedAt: doc.updatedAt,
361
+ }));
362
+ }
363
+ }
364
+
365
+ export {
366
+ DEFAULT_MAX_MESSAGES,
367
+ threadKey,
368
+ threadTitle,
369
+ trimMessages,
370
+ };
371
+
372
+ // ─── Redis ──────────────────────────────────────────────────────────────
373
+
374
+ /**
375
+ * Redis conversation store — shared by every process, no MongoDB involved.
376
+ *
377
+ * ```ts
378
+ * memory: new agents.memory.Redis({ url: Bun.env.REDIS_URL, ttl: '7d' })
379
+ * ```
380
+ *
381
+ * Layout (one key per thread, one sorted set per caller):
382
+ *
383
+ * ```
384
+ * anteros:agent-memory:<tenant>:thread:<resourceId>:<threadId> → JSON
385
+ * anteros:agent-memory:<tenant>:resource:<resourceId> → ZSET (score = updatedAt)
386
+ * ```
387
+ *
388
+ * The **tenant is part of every key** (unlike the Mongo store, whose database is
389
+ * already per tenant): a store instance created once at module scope can serve
390
+ * several tenants without mixing their conversations — and refuses to run without
391
+ * a tenant rather than putting everyone in one bucket.
392
+ *
393
+ * The caller index is what `list()` reads; it is pruned of threads that expired,
394
+ * and it expires on the same TTL as the threads it points to.
395
+ */
396
+ export class RedisAgentMemory implements AgentMemory {
397
+ #options: RedisAgentMemoryOptions;
398
+ #client?: RedisClientLike;
399
+
400
+ constructor(options: RedisAgentMemoryOptions = {}) {
401
+ this.#options = options;
402
+ this.#client = options.client;
403
+ }
404
+
405
+ /** Key namespace. */
406
+ get prefix(): string {
407
+ return this.#options.prefix ?? "anteros:agent-memory";
408
+ }
409
+
410
+ /** The `ioredis` client, created on first use (`lazyConnect`). */
411
+ #redis(): RedisClientLike {
412
+ if (this.#client) return this.#client;
413
+
414
+ const { url, host, port, password } = this.#options;
415
+ const connection = url
416
+ ?? Bun.env.REDIS_URL
417
+ ?? (host || Bun.env.REDIS_HOST
418
+ ? {
419
+ host: host ?? Bun.env.REDIS_HOST,
420
+ port: port ?? (Bun.env.REDIS_PORT ? Number(Bun.env.REDIS_PORT) : 6379),
421
+ password: password ?? Bun.env.REDIS_PASSWORD,
422
+ }
423
+ : "redis://localhost:6379");
424
+
425
+ // `lazyConnect`: defining the agent (module scope) must not open a socket
426
+ this.#client = new Redis(connection as any, { lazyConnect: true }) as unknown as RedisClientLike;
427
+ return this.#client;
428
+ }
429
+
430
+ /** A thread lives with its tenant: mixing them in one bucket is a data leak. */
431
+ #namespace(ctx?: AgentMemoryContext): string {
432
+ const tenant = ctx?.tenant;
433
+ if (!tenant) {
434
+ throw new AppError(
435
+ "A Redis memory store needs the tenant — resolve the agent from a context (`agents.get(id)`)",
436
+ { status: 500, code: "AGENT_MEMORY_NO_TENANT" },
437
+ );
438
+ }
439
+ return tenant;
440
+ }
441
+
442
+ #threadKey(threadId: string, ctx?: AgentMemoryContext): string {
443
+ const scoped = this.#options.scoped ?? true;
444
+ return `${this.prefix}:${this.#namespace(ctx)}:thread:${threadKey(threadId, ctx, scoped)}`;
445
+ }
446
+
447
+ #resourceKey(ctx?: AgentMemoryContext): string {
448
+ return `${this.prefix}:${this.#namespace(ctx)}:resource:${ctx?.resourceId ?? "-"}`;
449
+ }
450
+
451
+ #stateKey(key: string, ctx?: AgentMemoryContext): string {
452
+ return `${this.prefix}:${this.#namespace(ctx)}:state:${key}`;
453
+ }
454
+
455
+ /** `ttl` as Redis seconds. */
456
+ #seconds(): number | null {
457
+ const ttl = this.#options.ttl;
458
+ if (ttl === undefined || ttl === false) return null;
459
+ const ms = parseDuration(ttl);
460
+ if (ms === null) throw new AppError(`Invalid memory ttl '${ttl}' — expected a duration like '7d'`, {
461
+ status: 500, code: "AGENT_MEMORY_INVALID_TTL",
462
+ });
463
+ return Math.max(1, Math.ceil(ms / 1000));
464
+ }
465
+
466
+ async get(threadId: string, ctx?: AgentMemoryContext): Promise<AgentMessage[]> {
467
+ const raw = await this.#redis().get(this.#threadKey(threadId, ctx));
468
+ if (!raw) return [];
469
+ try {
470
+ return (JSON.parse(raw)?.messages ?? []) as AgentMessage[];
471
+ } catch {
472
+ // Corrupted entry — never let it break a run
473
+ console.error(`Agent memory: unreadable thread '${threadId}' in Redis`);
474
+ return [];
475
+ }
476
+ }
477
+
478
+ async save(threadId: string, messages: AgentMessage[], ctx?: AgentMemoryContext): Promise<void> {
479
+ const redis = this.#redis();
480
+ const scoped = this.#options.scoped ?? true;
481
+ const key = this.#threadKey(threadId, ctx);
482
+ const trimmed = trimMessages(messages, Math.max(1, this.#options.maxMessages ?? DEFAULT_MAX_MESSAGES));
483
+
484
+ // `title` and `createdAt` belong to the first exchange: Redis has no
485
+ // `$setOnInsert`, so the previous value is read back before writing.
486
+ const raw = await redis.get(key);
487
+ const previous = raw ? (() => { try { return JSON.parse(raw); } catch { return null; } })() : null;
488
+
489
+ const now = new Date();
490
+ const payload = {
491
+ threadId,
492
+ resourceId: ctx?.resourceId,
493
+ agentId: ctx?.agentId,
494
+ title: previous?.title ?? threadTitle(trimmed),
495
+ messages: trimmed,
496
+ createdAt: previous?.createdAt ?? now.toISOString(),
497
+ updatedAt: now.toISOString(),
498
+ };
499
+
500
+ const seconds = this.#seconds();
501
+ if (seconds) await redis.set(key, JSON.stringify(payload), "EX", seconds);
502
+ else await redis.set(key, JSON.stringify(payload));
503
+
504
+ // The caller index — what `list()` walks
505
+ if (scoped && ctx?.resourceId) {
506
+ const index = this.#resourceKey(ctx);
507
+ await redis.zadd(index, now.getTime(), threadKey(threadId, ctx, true));
508
+ // An index that outlives its threads would grow forever
509
+ if (seconds) await redis.expire(index, seconds);
510
+ }
511
+ }
512
+
513
+ async clear(threadId: string, ctx?: AgentMemoryContext): Promise<void> {
514
+ const redis = this.#redis();
515
+ await redis.del(this.#threadKey(threadId, ctx));
516
+ if ((this.#options.scoped ?? true) && ctx?.resourceId) {
517
+ await redis.zrem(this.#resourceKey(ctx), threadKey(threadId, ctx, true));
518
+ }
519
+ }
520
+
521
+ async getState(key: string, ctx?: AgentMemoryContext): Promise<AgentMemoryState | undefined> {
522
+ const raw = await this.#redis().get(this.#stateKey(key, ctx));
523
+ if (!raw) return undefined;
524
+ try {
525
+ const parsed = JSON.parse(raw);
526
+ return { value: parsed?.value, updatedAt: parsed?.updatedAt ? new Date(parsed.updatedAt) : undefined };
527
+ } catch {
528
+ return undefined;
529
+ }
530
+ }
531
+
532
+ async setState(key: string, value: AgentMemoryState, ctx?: AgentMemoryContext): Promise<void> {
533
+ // No expiry: a profile must outlive the conversation that created it
534
+ await this.#redis().set(this.#stateKey(key, ctx), JSON.stringify({
535
+ value: value.value,
536
+ updatedAt: (value.updatedAt ?? new Date()).toISOString(),
537
+ }));
538
+ }
539
+
540
+ async clearState(key: string, ctx?: AgentMemoryContext): Promise<void> {
541
+ await this.#redis().del(this.#stateKey(key, ctx));
542
+ }
543
+
544
+ async list(ctx?: AgentMemoryContext & { limit?: number }): Promise<AgentThread[]> {
545
+ const redis = this.#redis();
546
+ const index = this.#resourceKey(ctx);
547
+ const limit = ctx?.limit ?? DEFAULT_LIST_LIMIT;
548
+
549
+ const members = await redis.zrevrange(index, 0, limit - 1);
550
+ if (!members.length) return [];
551
+
552
+ const raws = await redis.mget(...members.map((member) => `${this.prefix}:${this.#namespace(ctx)}:thread:${member}`));
553
+
554
+ const threads: AgentThread[] = [];
555
+ const stale: string[] = [];
556
+
557
+ members.forEach((member, position) => {
558
+ const raw = raws[position];
559
+ if (!raw) { stale.push(member); return; } // expired or cleared behind our back
560
+ try {
561
+ const doc = JSON.parse(raw);
562
+ threads.push({
563
+ threadId: doc.threadId ?? member,
564
+ resourceId: doc.resourceId,
565
+ title: doc.title,
566
+ messages: Array.isArray(doc.messages) ? doc.messages.length : 0,
567
+ createdAt: doc.createdAt ? new Date(doc.createdAt) : undefined,
568
+ updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(),
569
+ });
570
+ } catch {
571
+ stale.push(member);
572
+ }
573
+ });
574
+
575
+ if (stale.length) await redis.zrem(index, ...stale);
576
+ return threads;
577
+ }
578
+
579
+ /** Close the client when the store owns it (a provided one is left alone). */
580
+ async close(): Promise<void> {
581
+ const client: any = this.#client;
582
+ if (this.#options.client || !client) return;
583
+ this.#client = undefined;
584
+ try { await client.quit?.(); } catch { /* already gone */ }
585
+ }
586
+ }