@effect-agent/storage-memory 0.1.0-beta.38 → 0.1.0-beta.40

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.
@@ -1,45 +1,46 @@
1
- import { ConversationId } from "@effect-agent/core";
1
+ import { ThreadId } from "@effect-agent/core";
2
2
  import {
3
+ type ThreadCheckpoint,
4
+ type ProducerEpoch,
5
+ type RecordId,
3
6
  AppendConflict,
4
7
  AppendResult,
5
8
  CanonicalRecordEnvelope,
6
9
  CanonicalSequence,
7
10
  CheckpointRejected,
8
- ConversationExportRequest,
9
- ConversationCheckpoint,
10
- ConversationExport,
11
- ConversationMaterialization,
12
- ConversationNotMaterialized,
13
- ConversationObservation,
14
- ConversationRead,
15
- ConversationStore,
16
- ConversationStoreError,
17
- ConversationTail,
18
- ConversationTailRequest,
11
+ ThreadExportRequest,
12
+ ThreadExport,
13
+ ThreadMaterialization,
14
+ ThreadNotMaterialized,
15
+ ThreadObservation,
16
+ ThreadRead,
17
+ ThreadStore,
18
+ type ThreadCheckpoints,
19
+ ThreadStoreError,
20
+ ThreadTail,
21
+ ThreadTailRequest,
19
22
  digestCanonicalBatch,
20
23
  EMPTY_TAIL_DIGEST,
21
24
  FenceRejected,
22
25
  FencedAppendRequest,
23
26
  LoadCheckpointRequest,
24
27
  ObservationOffset,
25
- ProducerEpoch,
26
- RecordId,
27
28
  SaveCheckpointRequest,
28
29
  type BatchId,
29
30
  type Digest,
30
- } from "@effect-agent/session";
31
+ } from "@effect-agent/thread";
31
32
  import { Crypto, Effect, Encoding, Layer, Option, PubSub, Ref, Schema, Stream } from "effect";
32
33
 
33
- const MAX_CONVERSATIONS = 256;
34
- const MAX_RECORDS_PER_CONVERSATION = 65_536;
35
- const MAX_CHECKPOINTS_PER_CONVERSATION = 1_024;
34
+ const MAX_THREADS = 256;
35
+ const MAX_RECORDS_PER_THREAD = 65_536;
36
+ const MAX_CHECKPOINTS_PER_THREAD = 1_024;
36
37
 
37
38
  interface StoredBatch {
38
39
  readonly digest: Digest;
39
40
  readonly result: AppendResult;
40
41
  }
41
42
 
42
- interface StoredConversation {
43
+ interface StoredThread {
43
44
  readonly producerEpoch: ProducerEpoch;
44
45
  readonly tailSequence: CanonicalSequence;
45
46
  readonly tailDigest: Digest;
@@ -47,21 +48,17 @@ interface StoredConversation {
47
48
  readonly recordIds: ReadonlySet<RecordId>;
48
49
  readonly batches: ReadonlyMap<BatchId, StoredBatch>;
49
50
  readonly tailDigests: ReadonlyMap<CanonicalSequence, Digest>;
50
- readonly checkpoints: ReadonlyMap<CanonicalSequence, ConversationCheckpoint>;
51
+ readonly checkpoints: ReadonlyMap<CanonicalSequence, ThreadCheckpoint>;
51
52
  }
52
53
 
53
54
  interface MemoryState {
54
- readonly conversations: ReadonlyMap<ConversationId, StoredConversation>;
55
+ readonly threads: ReadonlyMap<ThreadId, StoredThread>;
55
56
  }
56
57
 
57
58
  type AppendDecision =
58
59
  | {
59
60
  readonly _tag: "failure";
60
- readonly error:
61
- | ConversationStoreError
62
- | ConversationNotMaterialized
63
- | AppendConflict
64
- | FenceRejected;
61
+ readonly error: ThreadStoreError | ThreadNotMaterialized | AppendConflict | FenceRejected;
65
62
  }
66
63
  | {
67
64
  readonly _tag: "success";
@@ -70,27 +67,27 @@ type AppendDecision =
70
67
  };
71
68
 
72
69
  type MaterializeDecision =
73
- | { readonly _tag: "failure"; readonly error: ConversationStoreError | FenceRejected }
70
+ | { readonly _tag: "failure"; readonly error: ThreadStoreError | FenceRejected }
74
71
  | { readonly _tag: "success" };
75
72
 
76
73
  type CheckpointDecision =
77
74
  | {
78
75
  readonly _tag: "failure";
79
- readonly error: ConversationNotMaterialized | ConversationStoreError | CheckpointRejected;
76
+ readonly error: ThreadNotMaterialized | ThreadStoreError | CheckpointRejected;
80
77
  }
81
78
  | { readonly _tag: "success" };
82
79
 
83
- const storeError = (operation: string, message: string, cause?: unknown): ConversationStoreError =>
80
+ const storeError = (operation: string, message: string, cause?: unknown): ThreadStoreError =>
84
81
  cause === undefined
85
- ? ConversationStoreError.make({ operation, message })
86
- : ConversationStoreError.make({ operation, message, cause });
82
+ ? ThreadStoreError.make({ operation, message })
83
+ : ThreadStoreError.make({ operation, message, cause });
87
84
 
88
- const validate = Effect.fn("MemoryConversationStore.validate")(
85
+ const validate = Effect.fn("MemoryThreadStore.validate")(
89
86
  <A, I>(
90
87
  schema: Schema.Codec<A, I>,
91
88
  operation: string,
92
89
  value: unknown,
93
- ): Effect.Effect<A, ConversationStoreError> =>
90
+ ): Effect.Effect<A, ThreadStoreError> =>
94
91
  Schema.encodeUnknownEffect(schema)(value).pipe(
95
92
  Effect.flatMap(Schema.decodeUnknownEffect(schema)),
96
93
  Effect.mapError((error) => storeError(operation, `Invalid ${operation} request`, error)),
@@ -100,12 +97,12 @@ const validate = Effect.fn("MemoryConversationStore.validate")(
100
97
  const decodeCanonicalSequence = Schema.decodeSync(CanonicalSequence);
101
98
  const ZERO_CANONICAL_SEQUENCE = decodeCanonicalSequence(0);
102
99
 
103
- const offsetSequence = Effect.fn("MemoryConversationStore.offsetSequence")((
104
- conversationId: ConversationId,
100
+ const offsetSequence = Effect.fn("MemoryThreadStore.offsetSequence")((
101
+ threadId: ThreadId,
105
102
  offset: ObservationOffset | undefined,
106
- ): Effect.Effect<CanonicalSequence, ConversationStoreError> => {
103
+ ): Effect.Effect<CanonicalSequence, ThreadStoreError> => {
107
104
  if (offset === undefined) return Effect.succeed(ZERO_CANONICAL_SEQUENCE);
108
- const prefix = `memory:v1:${Encoding.encodeBase64(conversationId)}:`;
105
+ const prefix = `memory:v1:${Encoding.encodeBase64(threadId)}:`;
109
106
  const encodedSequence = offset.startsWith(prefix) ? offset.slice(prefix.length) : "";
110
107
  if (!/^\d+$/.test(encodedSequence)) {
111
108
  return Effect.fail(storeError("observe", "Malformed observation offset"));
@@ -118,67 +115,62 @@ const offsetSequence = Effect.fn("MemoryConversationStore.offsetSequence")((
118
115
  : Effect.fail(storeError("observe", "Malformed observation offset"));
119
116
  });
120
117
 
121
- const observationOffset = (
122
- conversationId: ConversationId,
123
- sequence: CanonicalSequence,
124
- ): ObservationOffset =>
125
- Schema.decodeSync(ObservationOffset)(
126
- `memory:v1:${Encoding.encodeBase64(conversationId)}:${sequence}`,
127
- );
118
+ const observationOffset = (threadId: ThreadId, sequence: CanonicalSequence): ObservationOffset =>
119
+ Schema.decodeSync(ObservationOffset)(`memory:v1:${Encoding.encodeBase64(threadId)}:${sequence}`);
128
120
 
129
- const findConversation = Effect.fn("MemoryConversationStore.findConversation")((
121
+ const findThread = Effect.fn("MemoryThreadStore.findThread")((
130
122
  state: MemoryState,
131
- conversationId: ConversationId,
132
- ): Effect.Effect<StoredConversation, ConversationNotMaterialized> => {
133
- const conversation = state.conversations.get(conversationId);
134
- return conversation === undefined
135
- ? Effect.fail(ConversationNotMaterialized.make({ conversationId }))
136
- : Effect.succeed(conversation);
123
+ threadId: ThreadId,
124
+ ): Effect.Effect<StoredThread, ThreadNotMaterialized> => {
125
+ const thread = state.threads.get(threadId);
126
+ return thread === undefined
127
+ ? Effect.fail(ThreadNotMaterialized.make({ threadId }))
128
+ : Effect.succeed(thread);
137
129
  });
138
130
 
139
131
  const CheckpointVersionEnvelope = Schema.Struct({
140
132
  checkpoint: Schema.Struct({
141
- conversationId: ConversationId,
133
+ threadId: ThreadId,
142
134
  schemaVersion: Schema.Natural,
143
135
  }),
144
136
  });
145
137
 
146
- const validateCheckpointVersion = Effect.fn("MemoryConversationStore.validateCheckpointVersion")(
147
- function* (value: unknown): Effect.fn.Return<void, ConversationStoreError | CheckpointRejected> {
138
+ const validateCheckpointVersion = Effect.fn("MemoryThreadStore.validateCheckpointVersion")(
139
+ function* (value: unknown): Effect.fn.Return<void, ThreadStoreError | CheckpointRejected> {
148
140
  const envelope = yield* Schema.decodeUnknownEffect(CheckpointVersionEnvelope)(value).pipe(
149
141
  Effect.mapError(() => storeError("saveCheckpoint", "Invalid saveCheckpoint request")),
150
142
  );
151
143
  if (envelope.checkpoint.schemaVersion !== 1) {
152
144
  return yield* CheckpointRejected.make({
153
- conversationId: envelope.checkpoint.conversationId,
145
+ threadId: envelope.checkpoint.threadId,
154
146
  reason: "unsupported-version",
155
147
  });
156
148
  }
157
149
  },
158
150
  );
159
151
 
160
- const makeConversationStore = Effect.gen(function* () {
152
+ const makeThreadStore = Effect.gen(function* () {
161
153
  const crypto = yield* Crypto.Crypto;
162
- const state = yield* Ref.make<MemoryState>({ conversations: new Map() });
154
+ const state = yield* Ref.make<MemoryState>({ threads: new Map() });
163
155
  const updates = yield* PubSub.sliding<void>(1);
164
156
  yield* Effect.addFinalizer(() => PubSub.shutdown(updates));
165
157
 
166
- const materialize: ConversationStore["Service"]["materialize"] = Effect.fn(
167
- "MemoryConversationStore.materialize",
158
+ const materialize: ThreadStore["Service"]["materialize"] = Effect.fn(
159
+ "MemoryThreadStore.materialize",
168
160
  )((unvalidated) =>
169
161
  Effect.gen(function* () {
170
- const request = yield* validate(ConversationMaterialization, "materialize", unvalidated);
162
+ const request = yield* validate(ThreadMaterialization, "materialize", unvalidated);
171
163
  const decision = yield* Ref.modify(
172
164
  state,
173
165
  (current): readonly [MaterializeDecision, MemoryState] => {
174
- const existing = current.conversations.get(request.conversationId);
166
+ const existing = current.threads.get(request.threadId);
175
167
  if (existing !== undefined) {
176
168
  if (request.producerEpoch < existing.producerEpoch) {
177
169
  return [
178
170
  {
179
171
  _tag: "failure",
180
172
  error: FenceRejected.make({
181
- conversationId: request.conversationId,
173
+ threadId: request.threadId,
182
174
  actualEpoch: existing.producerEpoch,
183
175
  attemptedEpoch: request.producerEpoch,
184
176
  }),
@@ -189,27 +181,24 @@ const makeConversationStore = Effect.gen(function* () {
189
181
  if (request.producerEpoch === existing.producerEpoch) {
190
182
  return [{ _tag: "success" }, current];
191
183
  }
192
- const conversations = new Map(current.conversations);
193
- conversations.set(request.conversationId, {
184
+ const threads = new Map(current.threads);
185
+ threads.set(request.threadId, {
194
186
  ...existing,
195
187
  producerEpoch: request.producerEpoch,
196
188
  });
197
- return [{ _tag: "success" }, { conversations }];
189
+ return [{ _tag: "success" }, { threads }];
198
190
  }
199
- if (current.conversations.size >= MAX_CONVERSATIONS) {
191
+ if (current.threads.size >= MAX_THREADS) {
200
192
  return [
201
193
  {
202
194
  _tag: "failure",
203
- error: storeError(
204
- "materialize",
205
- `In-memory conversation limit ${MAX_CONVERSATIONS} exceeded`,
206
- ),
195
+ error: storeError("materialize", `In-memory thread limit ${MAX_THREADS} exceeded`),
207
196
  },
208
197
  current,
209
198
  ];
210
199
  }
211
- const conversations = new Map(current.conversations);
212
- conversations.set(request.conversationId, {
200
+ const threads = new Map(current.threads);
201
+ threads.set(request.threadId, {
213
202
  producerEpoch: request.producerEpoch,
214
203
  tailSequence: ZERO_CANONICAL_SEQUENCE,
215
204
  tailDigest: EMPTY_TAIL_DIGEST,
@@ -219,218 +208,207 @@ const makeConversationStore = Effect.gen(function* () {
219
208
  tailDigests: new Map([[ZERO_CANONICAL_SEQUENCE, EMPTY_TAIL_DIGEST]]),
220
209
  checkpoints: new Map(),
221
210
  });
222
- return [{ _tag: "success" }, { conversations }];
211
+ return [{ _tag: "success" }, { threads }];
223
212
  },
224
213
  );
225
214
  if (decision._tag === "failure") return yield* decision.error;
226
215
  }),
227
216
  );
228
217
 
229
- const append: ConversationStore["Service"]["append"] = Effect.fn(
230
- "MemoryConversationStore.append",
231
- )((unvalidated) =>
232
- Effect.gen(function* () {
233
- const request = yield* validate(FencedAppendRequest, "append", unvalidated);
234
- const digest = yield* digestCanonicalBatch(request.expectedTailDigest, request.batch).pipe(
235
- Effect.provideService(Crypto.Crypto, crypto),
236
- Effect.mapError((error) => storeError("append", error.message, error)),
237
- );
218
+ const append: ThreadStore["Service"]["append"] = Effect.fn("MemoryThreadStore.append")(
219
+ (unvalidated) =>
220
+ Effect.gen(function* () {
221
+ const request = yield* validate(FencedAppendRequest, "append", unvalidated);
222
+ const digest = yield* digestCanonicalBatch(request.expectedTailDigest, request.batch).pipe(
223
+ Effect.provideService(Crypto.Crypto, crypto),
224
+ Effect.mapError((error) => storeError("append", error.message, error)),
225
+ );
238
226
 
239
- const decision = yield* Effect.uninterruptible(
240
- Ref.modify(state, (current): readonly [AppendDecision, MemoryState] => {
241
- const conversation = current.conversations.get(request.conversationId);
242
- if (conversation === undefined) {
243
- return [
244
- {
245
- _tag: "failure",
246
- error: ConversationNotMaterialized.make({
247
- conversationId: request.conversationId,
248
- }),
249
- },
250
- current,
251
- ];
252
- }
253
- if (request.producerEpoch !== conversation.producerEpoch) {
254
- return [
255
- {
256
- _tag: "failure",
257
- error: FenceRejected.make({
258
- conversationId: request.conversationId,
259
- actualEpoch: conversation.producerEpoch,
260
- attemptedEpoch: request.producerEpoch,
261
- }),
262
- },
263
- current,
264
- ];
265
- }
266
- const previous = conversation.batches.get(request.batch.batchId);
267
- if (previous !== undefined) {
268
- if (previous.digest !== digest) {
227
+ const decision = yield* Effect.uninterruptible(
228
+ Ref.modify(state, (current): readonly [AppendDecision, MemoryState] => {
229
+ const thread = current.threads.get(request.threadId);
230
+ if (thread === undefined) {
269
231
  return [
270
232
  {
271
233
  _tag: "failure",
272
- error: AppendConflict.make({
273
- conversationId: request.conversationId,
274
- batchId: request.batch.batchId,
275
- reason: "batch-digest",
234
+ error: ThreadNotMaterialized.make({
235
+ threadId: request.threadId,
276
236
  }),
277
237
  },
278
238
  current,
279
239
  ];
280
240
  }
281
- return [
282
- {
283
- _tag: "success",
284
- result: AppendResult.make({
285
- firstSequence: previous.result.firstSequence,
286
- lastSequence: previous.result.lastSequence,
287
- tailDigest: previous.result.tailDigest,
288
- replayed: true,
289
- }),
290
- records: [],
291
- },
292
- current,
293
- ];
294
- }
295
- if (
296
- request.expectedTailSequence !== conversation.tailSequence ||
297
- request.expectedTailDigest !== conversation.tailDigest
298
- ) {
299
- return [
300
- {
301
- _tag: "failure",
302
- error: AppendConflict.make({
303
- conversationId: request.conversationId,
304
- batchId: request.batch.batchId,
305
- reason: "tail",
306
- actualTailSequence: conversation.tailSequence,
307
- actualTailDigest: conversation.tailDigest,
308
- }),
309
- },
310
- current,
311
- ];
312
- }
313
- if (
314
- conversation.records.length + request.batch.records.length >
315
- MAX_RECORDS_PER_CONVERSATION
316
- ) {
317
- return [
318
- {
319
- _tag: "failure",
320
- error: storeError(
321
- "append",
322
- `In-memory record limit ${MAX_RECORDS_PER_CONVERSATION} exceeded`,
323
- ),
324
- },
325
- current,
326
- ];
327
- }
328
-
329
- const batchRecordIds = new Set<RecordId>();
330
- for (const record of request.batch.records) {
241
+ if (request.producerEpoch !== thread.producerEpoch) {
242
+ return [
243
+ {
244
+ _tag: "failure",
245
+ error: FenceRejected.make({
246
+ threadId: request.threadId,
247
+ actualEpoch: thread.producerEpoch,
248
+ attemptedEpoch: request.producerEpoch,
249
+ }),
250
+ },
251
+ current,
252
+ ];
253
+ }
254
+ const previous = thread.batches.get(request.batch.batchId);
255
+ if (previous !== undefined) {
256
+ if (previous.digest !== digest) {
257
+ return [
258
+ {
259
+ _tag: "failure",
260
+ error: AppendConflict.make({
261
+ threadId: request.threadId,
262
+ batchId: request.batch.batchId,
263
+ reason: "batch-digest",
264
+ }),
265
+ },
266
+ current,
267
+ ];
268
+ }
269
+ return [
270
+ {
271
+ _tag: "success",
272
+ result: AppendResult.make({
273
+ firstSequence: previous.result.firstSequence,
274
+ lastSequence: previous.result.lastSequence,
275
+ tailDigest: previous.result.tailDigest,
276
+ replayed: true,
277
+ }),
278
+ records: [],
279
+ },
280
+ current,
281
+ ];
282
+ }
331
283
  if (
332
- conversation.recordIds.has(record.recordId) ||
333
- batchRecordIds.has(record.recordId)
284
+ request.expectedTailSequence !== thread.tailSequence ||
285
+ request.expectedTailDigest !== thread.tailDigest
334
286
  ) {
335
287
  return [
336
288
  {
337
289
  _tag: "failure",
338
290
  error: AppendConflict.make({
339
- conversationId: request.conversationId,
291
+ threadId: request.threadId,
340
292
  batchId: request.batch.batchId,
341
- reason: "record-identity",
293
+ reason: "tail",
294
+ actualTailSequence: thread.tailSequence,
295
+ actualTailDigest: thread.tailDigest,
342
296
  }),
343
297
  },
344
298
  current,
345
299
  ];
346
300
  }
347
- batchRecordIds.add(record.recordId);
348
- }
301
+ if (thread.records.length + request.batch.records.length > MAX_RECORDS_PER_THREAD) {
302
+ return [
303
+ {
304
+ _tag: "failure",
305
+ error: storeError(
306
+ "append",
307
+ `In-memory record limit ${MAX_RECORDS_PER_THREAD} exceeded`,
308
+ ),
309
+ },
310
+ current,
311
+ ];
312
+ }
313
+
314
+ const batchRecordIds = new Set<RecordId>();
315
+ for (const record of request.batch.records) {
316
+ if (thread.recordIds.has(record.recordId) || batchRecordIds.has(record.recordId)) {
317
+ return [
318
+ {
319
+ _tag: "failure",
320
+ error: AppendConflict.make({
321
+ threadId: request.threadId,
322
+ batchId: request.batch.batchId,
323
+ reason: "record-identity",
324
+ }),
325
+ },
326
+ current,
327
+ ];
328
+ }
329
+ batchRecordIds.add(record.recordId);
330
+ }
349
331
 
350
- const records = request.batch.records.map((record, index) => {
351
- const sequence = decodeCanonicalSequence(conversation.tailSequence + index + 1);
352
- return CanonicalRecordEnvelope.make({
353
- conversationId: request.conversationId,
354
- batchId: request.batch.batchId,
355
- sequence,
356
- offset: observationOffset(request.conversationId, sequence),
357
- record,
332
+ const records = request.batch.records.map((record, index) => {
333
+ const sequence = decodeCanonicalSequence(thread.tailSequence + index + 1);
334
+ return CanonicalRecordEnvelope.make({
335
+ threadId: request.threadId,
336
+ batchId: request.batch.batchId,
337
+ sequence,
338
+ offset: observationOffset(request.threadId, sequence),
339
+ record,
340
+ });
358
341
  });
359
- });
360
- const lastSequence = decodeCanonicalSequence(conversation.tailSequence + records.length);
361
- const result = AppendResult.make({
362
- firstSequence: decodeCanonicalSequence(conversation.tailSequence + 1),
363
- lastSequence,
364
- tailDigest: digest,
365
- replayed: false,
366
- });
367
- const batches = new Map(conversation.batches);
368
- batches.set(request.batch.batchId, { digest, result });
369
- const recordIds = new Set(conversation.recordIds);
370
- for (const recordId of batchRecordIds) recordIds.add(recordId);
371
- const tailDigests = new Map(conversation.tailDigests);
372
- tailDigests.set(lastSequence, digest);
373
- const conversations = new Map(current.conversations);
374
- conversations.set(request.conversationId, {
375
- ...conversation,
376
- tailSequence: lastSequence,
377
- tailDigest: digest,
378
- records: [...conversation.records, ...records],
379
- recordIds,
380
- batches,
381
- tailDigests,
382
- });
383
- return [{ _tag: "success", result, records }, { conversations }];
384
- }).pipe(
385
- Effect.tap((decision) =>
386
- decision._tag === "success" && decision.records.length > 0
387
- ? PubSub.publish(updates, undefined)
388
- : Effect.void,
342
+ const lastSequence = decodeCanonicalSequence(thread.tailSequence + records.length);
343
+ const result = AppendResult.make({
344
+ firstSequence: decodeCanonicalSequence(thread.tailSequence + 1),
345
+ lastSequence,
346
+ tailDigest: digest,
347
+ replayed: false,
348
+ });
349
+ const batches = new Map(thread.batches);
350
+ batches.set(request.batch.batchId, { digest, result });
351
+ const recordIds = new Set(thread.recordIds);
352
+ for (const recordId of batchRecordIds) recordIds.add(recordId);
353
+ const tailDigests = new Map(thread.tailDigests);
354
+ tailDigests.set(lastSequence, digest);
355
+ const threads = new Map(current.threads);
356
+ threads.set(request.threadId, {
357
+ ...thread,
358
+ tailSequence: lastSequence,
359
+ tailDigest: digest,
360
+ records: [...thread.records, ...records],
361
+ recordIds,
362
+ batches,
363
+ tailDigests,
364
+ });
365
+ return [{ _tag: "success", result, records }, { threads }];
366
+ }).pipe(
367
+ Effect.tap((decision) =>
368
+ decision._tag === "success" && decision.records.length > 0
369
+ ? PubSub.publish(updates, undefined)
370
+ : Effect.void,
371
+ ),
389
372
  ),
390
- ),
391
- );
392
- if (decision._tag === "failure") return yield* decision.error;
393
- return decision.result;
394
- }),
373
+ );
374
+ if (decision._tag === "failure") return yield* decision.error;
375
+ return decision.result;
376
+ }),
395
377
  );
396
378
 
397
- const readSnapshot = Effect.fn("MemoryConversationStore.readSnapshot")(
398
- (conversationId: ConversationId, afterSequence: CanonicalSequence | undefined, limit: number) =>
379
+ const readSnapshot = Effect.fn("MemoryThreadStore.readSnapshot")(
380
+ (threadId: ThreadId, afterSequence: CanonicalSequence | undefined, limit: number) =>
399
381
  Ref.get(state).pipe(
400
- Effect.flatMap((current) => findConversation(current, conversationId)),
401
- Effect.map((conversation) =>
402
- conversation.records
382
+ Effect.flatMap((current) => findThread(current, threadId)),
383
+ Effect.map((thread) =>
384
+ thread.records
403
385
  .filter((record) => record.sequence > (afterSequence ?? ZERO_CANONICAL_SEQUENCE))
404
386
  .slice(0, limit),
405
387
  ),
406
388
  ),
407
389
  );
408
390
 
409
- const read: ConversationStore["Service"]["read"] = (unvalidated) =>
391
+ const read: ThreadStore["Service"]["read"] = (unvalidated) =>
410
392
  Stream.unwrap(
411
393
  Effect.gen(function* () {
412
- const request = yield* validate(ConversationRead, "read", unvalidated);
413
- const records = yield* readSnapshot(
414
- request.conversationId,
415
- request.afterSequence,
416
- request.limit,
417
- );
394
+ const request = yield* validate(ThreadRead, "read", unvalidated);
395
+ const records = yield* readSnapshot(request.threadId, request.afterSequence, request.limit);
418
396
  return Stream.fromIterable(records);
419
397
  }),
420
398
  );
421
399
 
422
- const observe: ConversationStore["Service"]["observe"] = (unvalidated) =>
400
+ const observe: ThreadStore["Service"]["observe"] = (unvalidated) =>
423
401
  Stream.unwrap(
424
402
  Effect.gen(function* () {
425
- const request = yield* validate(ConversationObservation, "observe", unvalidated);
426
- const afterSequence = yield* offsetSequence(request.conversationId, request.afterOffset);
403
+ const request = yield* validate(ThreadObservation, "observe", unvalidated);
404
+ const afterSequence = yield* offsetSequence(request.threadId, request.afterOffset);
427
405
  return Stream.unwrap(
428
406
  Effect.gen(function* () {
429
407
  const subscription = yield* PubSub.subscribe(updates);
430
408
  const initial = yield* readSnapshot(
431
- request.conversationId,
409
+ request.threadId,
432
410
  afterSequence,
433
- MAX_RECORDS_PER_CONVERSATION,
411
+ MAX_RECORDS_PER_THREAD,
434
412
  );
435
413
  const highWater =
436
414
  initial.length === 0 ? afterSequence : (initial.at(-1)?.sequence ?? afterSequence);
@@ -438,11 +416,7 @@ const makeConversationStore = Effect.gen(function* () {
438
416
  Stream.mapAccumEffect(
439
417
  () => highWater,
440
418
  (lastSequence) =>
441
- readSnapshot(
442
- request.conversationId,
443
- lastSequence,
444
- MAX_RECORDS_PER_CONVERSATION,
445
- ).pipe(
419
+ readSnapshot(request.threadId, lastSequence, MAX_RECORDS_PER_THREAD).pipe(
446
420
  Effect.map(
447
421
  (records) => [records.at(-1)?.sequence ?? lastSequence, records] as const,
448
422
  ),
@@ -455,160 +429,156 @@ const makeConversationStore = Effect.gen(function* () {
455
429
  }),
456
430
  );
457
431
 
458
- const exportConversation: ConversationStore["Service"]["export"] = Effect.fn(
459
- "MemoryConversationStore.export",
460
- )((unvalidated) =>
461
- Effect.gen(function* () {
462
- const request = yield* validate(ConversationExportRequest, "export", unvalidated);
463
- const conversation = yield* Ref.get(state).pipe(
464
- Effect.flatMap((current) => findConversation(current, request.conversationId)),
465
- );
466
- return ConversationExport.make({
467
- format: "effect-agent/conversation@1",
468
- conversationId: request.conversationId,
469
- tailSequence: conversation.tailSequence,
470
- tailDigest: conversation.tailDigest,
471
- records: conversation.records,
472
- });
473
- }),
432
+ const exportThread: ThreadStore["Service"]["export"] = Effect.fn("MemoryThreadStore.export")(
433
+ (unvalidated) =>
434
+ Effect.gen(function* () {
435
+ const request = yield* validate(ThreadExportRequest, "export", unvalidated);
436
+ const thread = yield* Ref.get(state).pipe(
437
+ Effect.flatMap((current) => findThread(current, request.threadId)),
438
+ );
439
+ return ThreadExport.make({
440
+ format: "effect-agent/thread@1",
441
+ threadId: request.threadId,
442
+ tailSequence: thread.tailSequence,
443
+ tailDigest: thread.tailDigest,
444
+ records: thread.records,
445
+ });
446
+ }),
474
447
  );
475
448
 
476
- const inspectTail: ConversationStore["Service"]["inspectTail"] = Effect.fn(
477
- "MemoryConversationStore.inspectTail",
449
+ const inspectTail: ThreadStore["Service"]["inspectTail"] = Effect.fn(
450
+ "MemoryThreadStore.inspectTail",
478
451
  )((unvalidated) =>
479
452
  Effect.gen(function* () {
480
- const request = yield* validate(ConversationTailRequest, "inspectTail", unvalidated);
481
- const conversation = yield* Ref.get(state).pipe(
482
- Effect.flatMap((current) => findConversation(current, request.conversationId)),
453
+ const request = yield* validate(ThreadTailRequest, "inspectTail", unvalidated);
454
+ const thread = yield* Ref.get(state).pipe(
455
+ Effect.flatMap((current) => findThread(current, request.threadId)),
483
456
  );
484
- return ConversationTail.make({
485
- conversationId: request.conversationId,
486
- tailSequence: conversation.tailSequence,
487
- tailDigest: conversation.tailDigest,
488
- producerEpoch: conversation.producerEpoch,
457
+ return ThreadTail.make({
458
+ threadId: request.threadId,
459
+ tailSequence: thread.tailSequence,
460
+ tailDigest: thread.tailDigest,
461
+ producerEpoch: thread.producerEpoch,
489
462
  });
490
463
  }),
491
464
  );
492
465
 
493
- const saveCheckpoint: ConversationStore["Service"]["saveCheckpoint"] = Effect.fn(
494
- "MemoryConversationStore.saveCheckpoint",
495
- )((unvalidated) =>
496
- Effect.gen(function* () {
497
- yield* validateCheckpointVersion(unvalidated);
498
- const request = yield* validate(SaveCheckpointRequest, "saveCheckpoint", unvalidated);
499
- const decision = yield* Ref.modify(
500
- state,
501
- (current): readonly [CheckpointDecision, MemoryState] => {
502
- const checkpoint = request.checkpoint;
503
- const conversation = current.conversations.get(checkpoint.conversationId);
504
- if (conversation === undefined) {
505
- return [
506
- {
507
- _tag: "failure",
508
- error: ConversationNotMaterialized.make({
509
- conversationId: checkpoint.conversationId,
510
- }),
511
- },
512
- current,
513
- ];
514
- }
515
- if (checkpoint.throughSequence > conversation.tailSequence) {
516
- return [
517
- {
518
- _tag: "failure",
519
- error: CheckpointRejected.make({
520
- conversationId: checkpoint.conversationId,
521
- reason: "ahead-of-tail",
522
- }),
523
- },
524
- current,
525
- ];
526
- }
527
- if (conversation.tailDigests.get(checkpoint.throughSequence) !== checkpoint.tailDigest) {
528
- return [
529
- {
530
- _tag: "failure",
531
- error: CheckpointRejected.make({
532
- conversationId: checkpoint.conversationId,
533
- reason: "digest-mismatch",
534
- }),
535
- },
536
- current,
537
- ];
538
- }
466
+ const saveCheckpoint: ThreadCheckpoints["save"] = Effect.fn("MemoryThreadStore.saveCheckpoint")(
467
+ (unvalidated) =>
468
+ Effect.gen(function* () {
469
+ yield* validateCheckpointVersion(unvalidated);
470
+ const request = yield* validate(SaveCheckpointRequest, "saveCheckpoint", unvalidated);
471
+ const decision = yield* Ref.modify(
472
+ state,
473
+ (current): readonly [CheckpointDecision, MemoryState] => {
474
+ const checkpoint = request.checkpoint;
475
+ const thread = current.threads.get(checkpoint.threadId);
476
+ if (thread === undefined) {
477
+ return [
478
+ {
479
+ _tag: "failure",
480
+ error: ThreadNotMaterialized.make({
481
+ threadId: checkpoint.threadId,
482
+ }),
483
+ },
484
+ current,
485
+ ];
486
+ }
487
+ if (checkpoint.throughSequence > thread.tailSequence) {
488
+ return [
489
+ {
490
+ _tag: "failure",
491
+ error: CheckpointRejected.make({
492
+ threadId: checkpoint.threadId,
493
+ reason: "ahead-of-tail",
494
+ }),
495
+ },
496
+ current,
497
+ ];
498
+ }
499
+ if (thread.tailDigests.get(checkpoint.throughSequence) !== checkpoint.tailDigest) {
500
+ return [
501
+ {
502
+ _tag: "failure",
503
+ error: CheckpointRejected.make({
504
+ threadId: checkpoint.threadId,
505
+ reason: "digest-mismatch",
506
+ }),
507
+ },
508
+ current,
509
+ ];
510
+ }
511
+ if (
512
+ !thread.checkpoints.has(checkpoint.throughSequence) &&
513
+ thread.checkpoints.size >= MAX_CHECKPOINTS_PER_THREAD
514
+ ) {
515
+ return [
516
+ {
517
+ _tag: "failure",
518
+ error: storeError(
519
+ "saveCheckpoint",
520
+ `In-memory checkpoint limit ${MAX_CHECKPOINTS_PER_THREAD} exceeded`,
521
+ ),
522
+ },
523
+ current,
524
+ ];
525
+ }
526
+ const checkpoints = new Map(thread.checkpoints);
527
+ checkpoints.set(checkpoint.throughSequence, checkpoint);
528
+ const threads = new Map(current.threads);
529
+ threads.set(checkpoint.threadId, { ...thread, checkpoints });
530
+ return [{ _tag: "success" }, { threads }];
531
+ },
532
+ );
533
+ if (decision._tag === "failure") return yield* decision.error;
534
+ }),
535
+ );
536
+
537
+ const loadCheckpoint: ThreadCheckpoints["load"] = Effect.fn("MemoryThreadStore.loadCheckpoint")(
538
+ (unvalidated) =>
539
+ Effect.gen(function* () {
540
+ const request = yield* validate(LoadCheckpointRequest, "loadCheckpoint", unvalidated);
541
+ const thread = yield* Ref.get(state).pipe(
542
+ Effect.flatMap((current) => findThread(current, request.threadId)),
543
+ );
544
+ const maximum = request.atOrBeforeSequence ?? thread.tailSequence;
545
+ let selected: ThreadCheckpoint | undefined;
546
+ for (const [sequence, checkpoint] of thread.checkpoints) {
539
547
  if (
540
- !conversation.checkpoints.has(checkpoint.throughSequence) &&
541
- conversation.checkpoints.size >= MAX_CHECKPOINTS_PER_CONVERSATION
548
+ sequence <= maximum &&
549
+ (selected === undefined || sequence > selected.throughSequence)
542
550
  ) {
543
- return [
544
- {
545
- _tag: "failure",
546
- error: storeError(
547
- "saveCheckpoint",
548
- `In-memory checkpoint limit ${MAX_CHECKPOINTS_PER_CONVERSATION} exceeded`,
549
- ),
550
- },
551
- current,
552
- ];
551
+ selected = checkpoint;
553
552
  }
554
- const checkpoints = new Map(conversation.checkpoints);
555
- checkpoints.set(checkpoint.throughSequence, checkpoint);
556
- const conversations = new Map(current.conversations);
557
- conversations.set(checkpoint.conversationId, { ...conversation, checkpoints });
558
- return [{ _tag: "success" }, { conversations }];
559
- },
560
- );
561
- if (decision._tag === "failure") return yield* decision.error;
562
- }),
563
- );
564
-
565
- const loadCheckpoint: ConversationStore["Service"]["loadCheckpoint"] = Effect.fn(
566
- "MemoryConversationStore.loadCheckpoint",
567
- )((unvalidated) =>
568
- Effect.gen(function* () {
569
- const request = yield* validate(LoadCheckpointRequest, "loadCheckpoint", unvalidated);
570
- const conversation = yield* Ref.get(state).pipe(
571
- Effect.flatMap((current) => findConversation(current, request.conversationId)),
572
- );
573
- const maximum = request.atOrBeforeSequence ?? conversation.tailSequence;
574
- let selected: ConversationCheckpoint | undefined;
575
- for (const [sequence, checkpoint] of conversation.checkpoints) {
553
+ }
576
554
  if (
577
- sequence <= maximum &&
578
- (selected === undefined || sequence > selected.throughSequence)
555
+ selected !== undefined &&
556
+ thread.tailDigests.get(selected.throughSequence) !== selected.tailDigest
579
557
  ) {
580
- selected = checkpoint;
558
+ return yield* CheckpointRejected.make({
559
+ threadId: request.threadId,
560
+ reason: "digest-mismatch",
561
+ });
581
562
  }
582
- }
583
- if (
584
- selected !== undefined &&
585
- conversation.tailDigests.get(selected.throughSequence) !== selected.tailDigest
586
- ) {
587
- return yield* CheckpointRejected.make({
588
- conversationId: request.conversationId,
589
- reason: "digest-mismatch",
590
- });
591
- }
592
- return Option.fromNullishOr(selected);
593
- }),
563
+ return Option.fromNullishOr(selected);
564
+ }),
594
565
  );
595
566
 
596
- return ConversationStore.of({
567
+ return ThreadStore.of({
597
568
  materialize,
598
569
  append,
599
570
  read,
600
571
  observe,
601
- export: exportConversation,
572
+ export: exportThread,
602
573
  inspectTail,
603
- saveCheckpoint,
604
- loadCheckpoint,
574
+ checkpoints: { save: saveCheckpoint, load: loadCheckpoint },
605
575
  });
606
576
  });
607
577
 
608
- export const MemoryConversationStoreLive = Layer.effect(ConversationStore, makeConversationStore);
578
+ export const MemoryThreadStoreLive = Layer.effect(ThreadStore, makeThreadStore);
609
579
 
610
580
  /**
611
- * In-memory canonical Conversation persistence. Durable accepted work is served by the separate
612
- * SubmissionLedger port; this Layer deliberately provides only the ConversationStore.
581
+ * In-memory canonical Thread persistence. Durable accepted work is served by the separate
582
+ * SubmissionLedger port; this Layer deliberately provides only the ThreadStore.
613
583
  */
614
- export const MemoryStorageLive = MemoryConversationStoreLive;
584
+ export const MemoryStorageLive = MemoryThreadStoreLive;