@smithers-orchestrator/memory 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/memory",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "Persistent and semantic memory services for Smithers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -38,11 +38,11 @@
38
38
  "drizzle-orm": "^0.45.2",
39
39
  "effect": "^3.21.1",
40
40
  "zod": "^4.3.6",
41
- "@smithers-orchestrator/db": "0.24.2",
42
- "@smithers-orchestrator/errors": "0.24.2",
43
- "@smithers-orchestrator/graph": "0.24.2",
44
- "@smithers-orchestrator/scheduler": "0.24.2",
45
- "@smithers-orchestrator/observability": "0.24.2"
41
+ "@smithers-orchestrator/db": "0.25.0",
42
+ "@smithers-orchestrator/errors": "0.25.0",
43
+ "@smithers-orchestrator/observability": "0.25.0",
44
+ "@smithers-orchestrator/scheduler": "0.25.0",
45
+ "@smithers-orchestrator/graph": "0.25.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/bun": "latest",
package/src/Summarizer.js CHANGED
@@ -1,21 +1,92 @@
1
1
  import { Effect } from "effect";
2
2
  /** @typedef {import("./MemoryProcessor.ts").MemoryProcessor} MemoryProcessor */
3
3
 
4
+ const RECENT_MESSAGE_COUNT = 2;
5
+
6
+ /**
7
+ * @param {import("./MemoryMessage.ts").MemoryMessage} message
8
+ * @returns {string}
9
+ */
10
+ function renderMessage(message) {
11
+ let content = message.contentJson;
12
+ try {
13
+ const parsed = JSON.parse(message.contentJson);
14
+ content = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
15
+ }
16
+ catch {
17
+ // Preserve raw content when it is not JSON.
18
+ }
19
+ return `${message.role}: ${content}`;
20
+ }
21
+
22
+ /**
23
+ * @param {unknown} result
24
+ * @returns {string}
25
+ */
26
+ function extractSummary(result) {
27
+ if (typeof result === "string") {
28
+ return result;
29
+ }
30
+ if (result && typeof result === "object") {
31
+ if ("text" in result && typeof result.text === "string") {
32
+ return result.text;
33
+ }
34
+ if ("output" in result && typeof result.output === "string") {
35
+ return result.output;
36
+ }
37
+ }
38
+ return JSON.stringify(result);
39
+ }
40
+
4
41
  /**
5
42
  * @param {{ run: (prompt: string) => Promise<any> }} agent
6
43
  * @returns {MemoryProcessor}
7
44
  */
8
- export function Summarizer(_agent) {
45
+ export function Summarizer(agent) {
9
46
  /**
10
47
  * @param {MemoryStore} store
11
48
  * @returns {Effect.Effect<void, SmithersError>}
12
49
  */
13
- function processEffect(_store) {
50
+ function processEffect(store) {
14
51
  return Effect.gen(function* () {
15
- // Summarizer operates on a specific thread's messages, compressing
16
- // older messages into a summary. Without a thread context, it logs
17
- // and returns. This is a structural placeholder.
18
- yield* Effect.logInfo("Summarizer: configured — operates at thread level");
52
+ const threads = yield* store.listThreadsEffect();
53
+ let summarized = 0;
54
+ for (const thread of threads) {
55
+ const messages = yield* store.listMessagesEffect(thread.threadId);
56
+ if (messages.length <= RECENT_MESSAGE_COUNT) {
57
+ continue;
58
+ }
59
+ const oldMessages = messages.slice(0, -RECENT_MESSAGE_COUNT);
60
+ const recentMessages = messages.slice(-RECENT_MESSAGE_COUNT);
61
+ if (oldMessages.length === 1 && oldMessages[0].role === "system") {
62
+ continue;
63
+ }
64
+ const prompt = [
65
+ "Summarize these older conversation messages for future context.",
66
+ "Keep durable user preferences, decisions, facts, and unresolved tasks.",
67
+ "",
68
+ oldMessages.map(renderMessage).join("\n"),
69
+ ].join("\n");
70
+ const result = yield* Effect.tryPromise(() => agent.run(prompt));
71
+ const summary = extractSummary(result);
72
+ // Save the summary BEFORE deleting the old messages so there is no
73
+ // data-loss window: if the summary write fails the originals are
74
+ // still present (recoverable), and if the delete fails afterwards
75
+ // the originals plus the summary coexist (harmless — the next run
76
+ // re-summarizes and deletes them). The state where the old
77
+ // messages are gone and no summary exists is never reachable.
78
+ yield* store.saveMessageEffect({
79
+ id: `summary-${crypto.randomUUID()}`,
80
+ threadId: thread.threadId,
81
+ role: "system",
82
+ contentJson: JSON.stringify({ type: "summary", text: summary }),
83
+ createdAtMs: oldMessages[0].createdAtMs,
84
+ });
85
+ yield* store.deleteMessagesEffect(thread.threadId, oldMessages.map((message) => message.id));
86
+ yield* Effect.logInfo(`Summarizer: compressed ${oldMessages.length} messages before ${recentMessages[0]?.id ?? "end"}`);
87
+ summarized += 1;
88
+ }
89
+ yield* Effect.logInfo(`Summarizer: summarized ${summarized} threads`);
19
90
  }).pipe(Effect.annotateLogs({ processor: "Summarizer" }), Effect.withLogSpan("memory:processor:summarizer"));
20
91
  }
21
92
  return {
@@ -12,13 +12,24 @@ export function TokenLimiter(maxTokens) {
12
12
  * @param {MemoryStore} store
13
13
  * @returns {Effect.Effect<void, SmithersError>}
14
14
  */
15
- function processEffect(_store) {
15
+ function processEffect(store) {
16
16
  return Effect.gen(function* () {
17
- // Token limiter operates at the thread level; without a specific thread
18
- // context it logs and returns. In practice, this processor is invoked
19
- // with a store that wraps a specific thread. For now, this is a no-op
20
- // placeholder that documents the intended behaviour.
21
- yield* Effect.logInfo(`TokenLimiter: configured for ${maxTokens} tokens (${charBudget} chars) operates at thread level`);
17
+ const threads = yield* store.listThreadsEffect();
18
+ let deleted = 0;
19
+ for (const thread of threads) {
20
+ const messages = yield* store.listMessagesEffect(thread.threadId);
21
+ let charCount = messages.reduce((total, message) => total + message.contentJson.length, 0);
22
+ const deleteIds = [];
23
+ for (const message of messages) {
24
+ if (charCount <= charBudget) {
25
+ break;
26
+ }
27
+ deleteIds.push(message.id);
28
+ charCount -= message.contentJson.length;
29
+ }
30
+ deleted += yield* store.deleteMessagesEffect(thread.threadId, deleteIds);
31
+ }
32
+ yield* Effect.logInfo(`TokenLimiter: deleted ${deleted} messages to enforce ${maxTokens} token budget`);
22
33
  }).pipe(Effect.annotateLogs({ processor: "TokenLimiter", maxTokens }), Effect.withLogSpan("memory:processor:token-limiter"));
23
34
  }
24
35
  return {
package/src/index.d.ts CHANGED
@@ -83,12 +83,14 @@ type MemoryStore$2 = {
83
83
  listFacts: (ns: MemoryNamespace$3) => Promise<MemoryFact$1[]>;
84
84
  createThread: (ns: MemoryNamespace$3, title?: string) => Promise<MemoryThread$1>;
85
85
  getThread: (threadId: string) => Promise<MemoryThread$1 | undefined>;
86
+ listThreads: () => Promise<MemoryThread$1[]>;
86
87
  deleteThread: (threadId: string) => Promise<void>;
87
88
  saveMessage: (msg: Omit<MemoryMessage$1, "createdAtMs"> & {
88
89
  createdAtMs?: number;
89
90
  }) => Promise<void>;
90
91
  listMessages: (threadId: string, limit?: number) => Promise<MemoryMessage$1[]>;
91
92
  countMessages: (threadId: string) => Promise<number>;
93
+ deleteMessages: (threadId: string, messageIds: string[]) => Promise<number>;
92
94
  deleteExpiredFacts: () => Promise<number>;
93
95
  getFactEffect: (ns: MemoryNamespace$3, key: string) => Effect.Effect<MemoryFact$1 | undefined, SmithersError>;
94
96
  setFactEffect: (ns: MemoryNamespace$3, key: string, value: unknown, ttlMs?: number) => Effect.Effect<void, SmithersError>;
@@ -96,12 +98,14 @@ type MemoryStore$2 = {
96
98
  listFactsEffect: (ns: MemoryNamespace$3) => Effect.Effect<MemoryFact$1[], SmithersError>;
97
99
  createThreadEffect: (ns: MemoryNamespace$3, title?: string) => Effect.Effect<MemoryThread$1, SmithersError>;
98
100
  getThreadEffect: (threadId: string) => Effect.Effect<MemoryThread$1 | undefined, SmithersError>;
101
+ listThreadsEffect: () => Effect.Effect<MemoryThread$1[], SmithersError>;
99
102
  deleteThreadEffect: (threadId: string) => Effect.Effect<void, SmithersError>;
100
103
  saveMessageEffect: (msg: Omit<MemoryMessage$1, "createdAtMs"> & {
101
104
  createdAtMs?: number;
102
105
  }) => Effect.Effect<void, SmithersError>;
103
106
  listMessagesEffect: (threadId: string, limit?: number) => Effect.Effect<MemoryMessage$1[], SmithersError>;
104
107
  countMessagesEffect: (threadId: string) => Effect.Effect<number, SmithersError>;
108
+ deleteMessagesEffect: (threadId: string, messageIds: string[]) => Effect.Effect<number, SmithersError>;
105
109
  deleteExpiredFactsEffect: () => Effect.Effect<number, SmithersError>;
106
110
  };
107
111
 
@@ -20,12 +20,14 @@ export type MemoryStore = {
20
20
  listFacts: (ns: MemoryNamespace) => Promise<MemoryFact[]>;
21
21
  createThread: (ns: MemoryNamespace, title?: string) => Promise<MemoryThread>;
22
22
  getThread: (threadId: string) => Promise<MemoryThread | undefined>;
23
+ listThreads: () => Promise<MemoryThread[]>;
23
24
  deleteThread: (threadId: string) => Promise<void>;
24
25
  saveMessage: (
25
26
  msg: Omit<MemoryMessage, "createdAtMs"> & { createdAtMs?: number },
26
27
  ) => Promise<void>;
27
28
  listMessages: (threadId: string, limit?: number) => Promise<MemoryMessage[]>;
28
29
  countMessages: (threadId: string) => Promise<number>;
30
+ deleteMessages: (threadId: string, messageIds: string[]) => Promise<number>;
29
31
  deleteExpiredFacts: () => Promise<number>;
30
32
  getFactEffect: (
31
33
  ns: MemoryNamespace,
@@ -51,6 +53,7 @@ export type MemoryStore = {
51
53
  getThreadEffect: (
52
54
  threadId: string,
53
55
  ) => Effect.Effect<MemoryThread | undefined, SmithersError>;
56
+ listThreadsEffect: () => Effect.Effect<MemoryThread[], SmithersError>;
54
57
  deleteThreadEffect: (
55
58
  threadId: string,
56
59
  ) => Effect.Effect<void, SmithersError>;
@@ -64,5 +67,9 @@ export type MemoryStore = {
64
67
  countMessagesEffect: (
65
68
  threadId: string,
66
69
  ) => Effect.Effect<number, SmithersError>;
70
+ deleteMessagesEffect: (
71
+ threadId: string,
72
+ messageIds: string[],
73
+ ) => Effect.Effect<number, SmithersError>;
67
74
  deleteExpiredFactsEffect: () => Effect.Effect<number, SmithersError>;
68
75
  };
@@ -1,4 +1,4 @@
1
- import { and, eq, sql } from "drizzle-orm";
1
+ import { and, eq, inArray, sql } from "drizzle-orm";
2
2
  import { Effect, Layer, Metric } from "effect";
3
3
  import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
4
4
  import { dbQueryDuration } from "@smithers-orchestrator/observability/metrics";
@@ -194,20 +194,38 @@ function makeMemoryStore(db) {
194
194
  .limit(1)).pipe(Effect.map((rows) => rows[0]));
195
195
  }
196
196
  /**
197
+ * @returns {Effect.Effect<MemoryThread[], SmithersError>}
198
+ */
199
+ function listThreadsEffect() {
200
+ return readEffect("memory listThreads", () => db
201
+ .select()
202
+ .from(smithersMemoryThreads)
203
+ .orderBy(smithersMemoryThreads.createdAtMs)).pipe(Effect.map((rows) => rows.map((row) => ({
204
+ threadId: row.threadId,
205
+ namespace: row.namespace,
206
+ title: row.title,
207
+ metadataJson: row.metadataJson,
208
+ createdAtMs: row.createdAtMs,
209
+ updatedAtMs: row.updatedAtMs,
210
+ }))));
211
+ }
212
+ /**
197
213
  * @param {string} threadId
198
214
  * @returns {Effect.Effect<void, SmithersError>}
199
215
  */
200
216
  function deleteThreadEffect(threadId) {
201
- return Effect.gen(function* () {
202
- // Delete messages first
203
- yield* writeEffect("memory deleteThreadMessages", () => db
204
- .delete(smithersMemoryMessages)
205
- .where(eq(smithersMemoryMessages.threadId, threadId)));
206
- // Delete the thread
207
- yield* writeEffect("memory deleteThread", () => db
208
- .delete(smithersMemoryThreads)
209
- .where(eq(smithersMemoryThreads.threadId, threadId)));
210
- });
217
+ // Delete the messages and the thread row atomically so a failure on the
218
+ // second write can't leave the thread without its messages (or vice versa).
219
+ return writeEffect("memory deleteThread", () => Promise.resolve(
220
+ db.transaction((tx) => {
221
+ tx.delete(smithersMemoryMessages)
222
+ .where(eq(smithersMemoryMessages.threadId, threadId))
223
+ .run();
224
+ tx.delete(smithersMemoryThreads)
225
+ .where(eq(smithersMemoryThreads.threadId, threadId))
226
+ .run();
227
+ }),
228
+ ));
211
229
  }
212
230
  // --- Message Effects ---
213
231
  /**
@@ -217,14 +235,32 @@ function makeMemoryStore(db) {
217
235
  function saveMessageEffect(msg) {
218
236
  return Effect.gen(function* () {
219
237
  yield* Metric.increment(memoryMessageSaves);
220
- yield* writeEffect("memory saveMessage", () => db.insert(smithersMemoryMessages).values({
238
+ const createdAtMs = msg.createdAtMs ?? nowMs();
239
+ // Idempotent: re-saving a message with the same id (e.g. on
240
+ // crash-resume, deterministic replay, or fork/restore where ids are
241
+ // derived deterministically) must be a safe no-op upsert rather than
242
+ // a UNIQUE-constraint crash. Mirrors setFact's onConflictDoUpdate.
243
+ yield* writeEffect("memory saveMessage", () => db
244
+ .insert(smithersMemoryMessages)
245
+ .values({
221
246
  id: msg.id,
222
247
  threadId: msg.threadId,
223
248
  role: msg.role,
224
249
  contentJson: msg.contentJson,
225
250
  runId: msg.runId ?? null,
226
251
  nodeId: msg.nodeId ?? null,
227
- createdAtMs: msg.createdAtMs ?? nowMs(),
252
+ createdAtMs,
253
+ })
254
+ .onConflictDoUpdate({
255
+ target: smithersMemoryMessages.id,
256
+ set: {
257
+ threadId: msg.threadId,
258
+ role: msg.role,
259
+ contentJson: msg.contentJson,
260
+ runId: msg.runId ?? null,
261
+ nodeId: msg.nodeId ?? null,
262
+ createdAtMs,
263
+ },
228
264
  }));
229
265
  });
230
266
  }
@@ -240,7 +276,7 @@ function makeMemoryStore(db) {
240
276
  .from(smithersMemoryMessages)
241
277
  .where(eq(smithersMemoryMessages.threadId, threadId))
242
278
  .orderBy(smithersMemoryMessages.createdAtMs);
243
- if (limit) {
279
+ if (limit !== undefined) {
244
280
  query = query.limit(limit);
245
281
  }
246
282
  return query;
@@ -264,6 +300,19 @@ function makeMemoryStore(db) {
264
300
  .from(smithersMemoryMessages)
265
301
  .where(eq(smithersMemoryMessages.threadId, threadId))).pipe(Effect.map((rows) => rows[0]?.count ?? 0));
266
302
  }
303
+ /**
304
+ * @param {string} threadId
305
+ * @param {string[]} messageIds
306
+ * @returns {Effect.Effect<number, SmithersError>}
307
+ */
308
+ function deleteMessagesEffect(threadId, messageIds) {
309
+ if (messageIds.length === 0) {
310
+ return Effect.succeed(0);
311
+ }
312
+ return writeEffect("memory deleteMessages", () => db
313
+ .delete(smithersMemoryMessages)
314
+ .where(and(eq(smithersMemoryMessages.threadId, threadId), inArray(smithersMemoryMessages.id, messageIds)))).pipe(Effect.map((result) => result?.changes ?? result?.rowsAffected ?? 0));
315
+ }
267
316
  // --- Maintenance ---
268
317
  /**
269
318
  * @returns {Effect.Effect<number, SmithersError>}
@@ -283,10 +332,12 @@ function makeMemoryStore(db) {
283
332
  listFacts: (ns) => Effect.runPromise(listFactsEffect(ns)),
284
333
  createThread: (ns, title) => Effect.runPromise(createThreadEffect(ns, title)),
285
334
  getThread: (threadId) => Effect.runPromise(getThreadEffect(threadId)),
335
+ listThreads: () => Effect.runPromise(listThreadsEffect()),
286
336
  deleteThread: (threadId) => Effect.runPromise(deleteThreadEffect(threadId)),
287
337
  saveMessage: (msg) => Effect.runPromise(saveMessageEffect(msg)),
288
338
  listMessages: (threadId, limit) => Effect.runPromise(listMessagesEffect(threadId, limit)),
289
339
  countMessages: (threadId) => Effect.runPromise(countMessagesEffect(threadId)),
340
+ deleteMessages: (threadId, messageIds) => Effect.runPromise(deleteMessagesEffect(threadId, messageIds)),
290
341
  deleteExpiredFacts: () => Effect.runPromise(deleteExpiredFactsEffect()),
291
342
  // Effect variants
292
343
  getFactEffect,
@@ -295,10 +346,12 @@ function makeMemoryStore(db) {
295
346
  listFactsEffect,
296
347
  createThreadEffect,
297
348
  getThreadEffect,
349
+ listThreadsEffect,
298
350
  deleteThreadEffect,
299
351
  saveMessageEffect,
300
352
  listMessagesEffect,
301
353
  countMessagesEffect,
354
+ deleteMessagesEffect,
302
355
  deleteExpiredFactsEffect,
303
356
  };
304
357
  }
@@ -1 +0,0 @@
1
- export type { TaskMemoryConfig } from "@smithers-orchestrator/graph/types";