@anteros/core 0.0.41 → 0.0.44

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