effect-assistant 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/Assistant.d.ts +77 -37
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js +11 -4
  4. package/dist/Assistant.js.map +1 -1
  5. package/dist/AssistantActivity.d.ts +10 -16
  6. package/dist/AssistantActivity.d.ts.map +1 -1
  7. package/dist/AssistantActivity.js +11 -4
  8. package/dist/AssistantActivity.js.map +1 -1
  9. package/dist/AssistantEngine.d.ts +39 -1
  10. package/dist/AssistantEngine.d.ts.map +1 -1
  11. package/dist/AssistantEngine.js +81 -28
  12. package/dist/AssistantEngine.js.map +1 -1
  13. package/dist/AssistantEvent.d.ts +104 -11
  14. package/dist/AssistantEvent.d.ts.map +1 -1
  15. package/dist/AssistantEvent.js +88 -13
  16. package/dist/AssistantEvent.js.map +1 -1
  17. package/dist/AssistantLoop.d.ts +4 -2
  18. package/dist/AssistantLoop.d.ts.map +1 -1
  19. package/dist/AssistantLoop.js +4 -2
  20. package/dist/AssistantLoop.js.map +1 -1
  21. package/dist/BlockKit.d.ts +4 -4
  22. package/dist/BlockKit.js +1 -1
  23. package/dist/BlockKit.js.map +1 -1
  24. package/dist/ChatLog.js +1 -1
  25. package/dist/Compaction.d.ts +7 -3
  26. package/dist/Compaction.d.ts.map +1 -1
  27. package/dist/Compaction.js +19 -8
  28. package/dist/Compaction.js.map +1 -1
  29. package/dist/CurrentChat.d.ts +2 -1
  30. package/dist/CurrentChat.d.ts.map +1 -1
  31. package/dist/CurrentChat.js.map +1 -1
  32. package/dist/Instructions.js +1 -1
  33. package/dist/Skill.d.ts +86 -11
  34. package/dist/Skill.d.ts.map +1 -1
  35. package/dist/Skill.js +93 -18
  36. package/dist/Skill.js.map +1 -1
  37. package/dist/Specialist.d.ts +7 -7
  38. package/dist/Specialist.d.ts.map +1 -1
  39. package/dist/Specialist.js.map +1 -1
  40. package/dist/Timeline.d.ts +516 -0
  41. package/dist/Timeline.d.ts.map +1 -0
  42. package/dist/Timeline.js +631 -0
  43. package/dist/Timeline.js.map +1 -0
  44. package/dist/ToolApproval.d.ts +146 -0
  45. package/dist/ToolApproval.d.ts.map +1 -0
  46. package/dist/ToolApproval.js +201 -0
  47. package/dist/ToolApproval.js.map +1 -0
  48. package/dist/ToolDiscovery.d.ts +6 -4
  49. package/dist/ToolDiscovery.d.ts.map +1 -1
  50. package/dist/ToolDiscovery.js +5 -4
  51. package/dist/ToolDiscovery.js.map +1 -1
  52. package/dist/index.d.ts +4 -4
  53. package/dist/index.js +4 -4
  54. package/dist/internal/chat.js +9 -7
  55. package/dist/internal/chat.js.map +1 -1
  56. package/dist/internal/events.d.ts +15 -16
  57. package/dist/internal/events.d.ts.map +1 -1
  58. package/dist/internal/events.js +29 -28
  59. package/dist/internal/events.js.map +1 -1
  60. package/dist/internal/history.js +53 -22
  61. package/dist/internal/history.js.map +1 -1
  62. package/dist/internal/input.js +7 -0
  63. package/dist/internal/input.js.map +1 -0
  64. package/dist/internal/loop.d.ts +10 -5
  65. package/dist/internal/loop.d.ts.map +1 -1
  66. package/dist/internal/loop.js +55 -22
  67. package/dist/internal/loop.js.map +1 -1
  68. package/dist/internal/specialists.js +10 -16
  69. package/dist/internal/specialists.js.map +1 -1
  70. package/dist/internal/toolkit.d.ts +5 -7
  71. package/dist/internal/toolkit.d.ts.map +1 -1
  72. package/dist/internal/toolkit.js +28 -18
  73. package/dist/internal/toolkit.js.map +1 -1
  74. package/dist/internal/tools.js +1 -1
  75. package/dist/internal/turns.d.ts.map +1 -1
  76. package/dist/internal/turns.js +35 -42
  77. package/dist/internal/turns.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/Assistant.ts +80 -40
  80. package/src/AssistantActivity.ts +11 -18
  81. package/src/AssistantEngine.ts +110 -43
  82. package/src/AssistantEvent.ts +106 -14
  83. package/src/AssistantLoop.ts +4 -2
  84. package/src/BlockKit.ts +6 -6
  85. package/src/Compaction.ts +25 -4
  86. package/src/CurrentChat.ts +2 -1
  87. package/src/Skill.ts +133 -23
  88. package/src/Specialist.ts +9 -9
  89. package/src/Timeline.ts +947 -0
  90. package/src/ToolApproval.ts +249 -0
  91. package/src/ToolDiscovery.ts +8 -3
  92. package/src/index.ts +10 -10
  93. package/src/internal/chat.ts +15 -5
  94. package/src/internal/events.ts +43 -43
  95. package/src/internal/history.ts +94 -32
  96. package/src/internal/input.ts +3 -0
  97. package/src/internal/loop.ts +120 -34
  98. package/src/internal/specialists.ts +7 -12
  99. package/src/internal/toolkit.ts +47 -30
  100. package/src/internal/turns.ts +52 -58
  101. package/dist/AppEvent.d.ts +0 -111
  102. package/dist/AppEvent.d.ts.map +0 -1
  103. package/dist/AppEvent.js +0 -71
  104. package/dist/AppEvent.js.map +0 -1
  105. package/dist/ChatView.d.ts +0 -408
  106. package/dist/ChatView.d.ts.map +0 -1
  107. package/dist/ChatView.js +0 -443
  108. package/dist/ChatView.js.map +0 -1
  109. package/dist/internal/skills.js +0 -28
  110. package/dist/internal/skills.js.map +0 -1
  111. package/src/AppEvent.ts +0 -134
  112. package/src/ChatView.ts +0 -587
  113. package/src/internal/skills.ts +0 -37
@@ -0,0 +1,947 @@
1
+ /**
2
+ * This module provides a chat's timeline: the chat as its user sees it. A chat client receives the
3
+ * timeline and nothing else.
4
+ *
5
+ * A {@link Timeline} holds the chat's items: the messages with their blocks, the app's own events
6
+ * it shows (each typed with its event, see {@link EventItem}), and the tool calls waiting for the
7
+ * user's approval ({@link ApprovalItem}). It also holds the chat's
8
+ * {@link Activity}, and whether the last turn failed or the chat was deleted. It never contains the
9
+ * model's output, tool calls or results, what the model read, or why a turn failed. The server
10
+ * streams {@link Change}s by piping the assistant's `subscribe` through {@link changes}, and the
11
+ * client folds them with {@link fold} (or {@link apply}, from {@link empty}). On the server,
12
+ * {@link fromEntries} builds a whole timeline from a chat's log, and {@link snapshot} the whole
13
+ * timeline as one change.
14
+ *
15
+ * **Only the app events in `show` reach the timeline**, and none by default: an event that is only
16
+ * for the assistant, like a summary of the chat, stays out. The RPC's schema, `Change([...])`,
17
+ * names the same events and types the client's timeline with them.
18
+ *
19
+ * @since 0.3.0
20
+ */
21
+ import { dual } from "effect/Function"
22
+ import * as Match from "effect/Match"
23
+ import * as Option from "effect/Option"
24
+ import * as Predicate from "effect/Predicate"
25
+ import * as Schema from "effect/Schema"
26
+ import * as SchemaTransformation from "effect/SchemaTransformation"
27
+ import * as Stream from "effect/Stream"
28
+ import type * as Assistant from "./Assistant.ts"
29
+ import * as AssistantActivity from "./AssistantActivity.ts"
30
+ import * as AssistantEvent from "./AssistantEvent.ts"
31
+ import * as BlockKit from "./BlockKit.ts"
32
+ import { InputId } from "./InputId.ts"
33
+ import * as AppEvents from "./internal/events.ts"
34
+ import * as ToolApproval from "./ToolApproval.ts"
35
+ import { toJson } from "./internal/schema.ts"
36
+
37
+ /**
38
+ * Schema for the status of a block. A block is `"open"` while it accepts an answer, `"answered"`
39
+ * once the user has used it, and `"closed"` when it closed without an answer because the user
40
+ * answered in words or moved on.
41
+ *
42
+ * @category schemas
43
+ * @since 0.1.0
44
+ */
45
+ export const BlockStatus = Schema.Literals(["open", "answered", "closed"])
46
+
47
+ /**
48
+ * The status of a block.
49
+ *
50
+ * @category models
51
+ * @since 0.1.0
52
+ */
53
+ export type BlockStatus = typeof BlockStatus.Type
54
+
55
+ /**
56
+ * Schema for a block on an assistant's message. It holds the block's kind, its props (JSON, as
57
+ * the block's props schema encodes them), its status, and the user's answer (JSON, as the block's
58
+ * answer schema encodes it).
59
+ *
60
+ * @category schemas
61
+ * @since 0.1.0
62
+ */
63
+ export const Block = Schema.Struct({
64
+ kind: Schema.String,
65
+ props: Schema.Json,
66
+ status: BlockStatus,
67
+ answer: Schema.optional(Schema.Json)
68
+ })
69
+
70
+ /**
71
+ * Represents a block on an assistant's message.
72
+ *
73
+ * @category models
74
+ * @since 0.1.0
75
+ */
76
+ export type Block = typeof Block.Type
77
+
78
+ /**
79
+ * Schema for a message the user sent. `at` is when it was stored, in epoch
80
+ * milliseconds.
81
+ *
82
+ * @category schemas
83
+ * @since 0.1.0
84
+ */
85
+ export const UserMessage = Schema.TaggedStruct("UserMessage", {
86
+ id: InputId,
87
+ text: Schema.String,
88
+ attachments: Schema.optional(Schema.Array(AssistantEvent.Attachment)),
89
+ at: Schema.Finite
90
+ })
91
+
92
+ /**
93
+ * Represents a message the user sent.
94
+ *
95
+ * @category models
96
+ * @since 0.1.0
97
+ */
98
+ export type UserMessage = typeof UserMessage.Type
99
+
100
+ /**
101
+ * Schema for a message the assistant sent the user, including its block if it has one.
102
+ *
103
+ * @category schemas
104
+ * @since 0.1.0
105
+ */
106
+ export const AssistantMessage = Schema.TaggedStruct("AssistantMessage", {
107
+ id: Schema.String,
108
+ text: Schema.String,
109
+ at: Schema.Finite,
110
+ block: Schema.optional(Block)
111
+ })
112
+
113
+ /**
114
+ * Represents a message the assistant sent the user.
115
+ *
116
+ * @category models
117
+ * @since 0.1.0
118
+ */
119
+ export type AssistantMessage = typeof AssistantMessage.Type
120
+
121
+ /**
122
+ * Represents one of the app's own events in the timeline: an item between the messages, in the
123
+ * order the chat stored it.
124
+ *
125
+ * **Details**
126
+ *
127
+ * The item's `_tag` is the event's, so a match over a timeline's items takes a handler per event
128
+ * beside `UserMessage` and `AssistantMessage`. The event, decoded with its schema, is in `event`,
129
+ * and the item's own fields sit beside it rather than spread into it: an event may have an `id` or
130
+ * an `at` of its own, and a class instance keeps its prototype. `id` is fixed per entry of the
131
+ * chat's log, and `at` is when the event was stored, in epoch milliseconds.
132
+ *
133
+ * @category models
134
+ * @since 0.3.0
135
+ */
136
+ export type EventItem<E> = E extends { readonly _tag: infer Tag extends string }
137
+ ? { readonly _tag: Tag; readonly id: string; readonly at: number; readonly event: E }
138
+ : never
139
+
140
+ /**
141
+ * Schema for a tool call that waits, or waited, for the user's approval, as an item on the
142
+ * timeline.
143
+ *
144
+ * **Details**
145
+ *
146
+ * It is only the yes or no: the assistant's own message tells the user what it asks them to
147
+ * approve, so the item carries no params. `id` is the call's id, which the decision is for (see
148
+ * `ToolApproval.approve`), and `tool` the tool's name, for a label. `status` is where the call stands
149
+ * (`ToolApproval.Status`); `reason` is the user's, when they rejected it with one; `outcome` is how
150
+ * the approved call went, once it ran. `at` is when the call started waiting, in epoch
151
+ * milliseconds. A change updates the item in place as the call is decided and run.
152
+ *
153
+ * @category schemas
154
+ * @since 0.4.0
155
+ */
156
+ export const ApprovalItem = Schema.TaggedStruct("ToolApproval", {
157
+ id: Schema.String,
158
+ tool: Schema.String,
159
+ status: Schema.Literals(["pending", "approved", "rejected", "expired"]),
160
+ reason: Schema.optional(Schema.String),
161
+ outcome: Schema.optional(AssistantEvent.ToolOutcome),
162
+ at: Schema.Finite
163
+ })
164
+
165
+ /**
166
+ * Represents a tool call that waits, or waited, for the user's approval, as an item on the
167
+ * timeline.
168
+ *
169
+ * @category models
170
+ * @since 0.4.0
171
+ */
172
+ export type ApprovalItem = typeof ApprovalItem.Type
173
+
174
+ /**
175
+ * Represents an item in the chat's timeline: a message the user or the assistant sent, one of the
176
+ * app's own events it shows (see {@link EventItem}), or a tool call waiting for the user's approval
177
+ * (see {@link ApprovalItem}).
178
+ *
179
+ * **Example** (Showing each item of a chat)
180
+ *
181
+ * ```ts
182
+ * import { Timeline } from "effect-assistant"
183
+ * import * as Match from "effect/Match"
184
+ * import * as Schema from "effect/Schema"
185
+ *
186
+ * class DailyBudgetReached extends Schema.TaggedClass<DailyBudgetReached>()("DailyBudgetReached", {}) {}
187
+ *
188
+ * const line = (item: Timeline.Item<DailyBudgetReached>) =>
189
+ * Match.valueTags(item, {
190
+ * UserMessage: (sent) => `You: ${sent.text}`,
191
+ * AssistantMessage: (sent) => `Assistant: ${sent.text}`,
192
+ * DailyBudgetReached: () => "You've reached today's budget."
193
+ * })
194
+ * ```
195
+ *
196
+ * @category models
197
+ * @since 0.3.0
198
+ */
199
+ export type Item<Events = never> = UserMessage | AssistantMessage | EventItem<Events> | ApprovalItem
200
+
201
+ /**
202
+ * Schema for a specialist that is working for the assistant. It holds the specialist's name and
203
+ * when it started, in epoch milliseconds.
204
+ *
205
+ * @category schemas
206
+ * @since 0.1.0
207
+ */
208
+ export const SpecialistWorking = Schema.Struct({
209
+ specialist: Schema.String,
210
+ since: Schema.Finite
211
+ })
212
+
213
+ /**
214
+ * Represents a specialist that is working for the assistant.
215
+ *
216
+ * @category models
217
+ * @since 0.1.0
218
+ */
219
+ export type SpecialistWorking = typeof SpecialistWorking.Type
220
+
221
+ /**
222
+ * Schema for what is working in the chat.
223
+ *
224
+ * `working` is true while the assistant runs a turn, or when a turn is about to start for
225
+ * something that arrived. `specialists` lists the specialists that are working. When the chat is
226
+ * idle, nothing is working, even if a workflow is waiting on a timer or on the user.
227
+ *
228
+ * @category schemas
229
+ * @since 0.1.0
230
+ */
231
+ export const Activity = Schema.Struct({
232
+ working: Schema.Boolean,
233
+ specialists: Schema.Array(SpecialistWorking)
234
+ })
235
+
236
+ /**
237
+ * Represents what is working in the chat.
238
+ *
239
+ * @category models
240
+ * @since 0.1.0
241
+ */
242
+ export type Activity = typeof Activity.Type
243
+
244
+ /**
245
+ * Represents a chat's timeline: the chat as its user sees it, typed with the app's events it shows.
246
+ * It holds the chat's items in order, what is working in it, whether the last turn failed, and
247
+ * whether the chat was deleted. `seq` is the position in the chat's log that the timeline is up to.
248
+ *
249
+ * @category models
250
+ * @since 0.3.0
251
+ */
252
+ export interface Timeline<Events = never> {
253
+ readonly seq: number
254
+ readonly items: ReadonlyArray<Item<Events>>
255
+ readonly activity: Activity
256
+ /** Whether the last turn failed with no message after it. */
257
+ readonly failed: boolean
258
+ /** Whether the chat was deleted. A deleted chat accepts nothing more, and its stream ends. */
259
+ readonly deleted: boolean
260
+ }
261
+
262
+ /**
263
+ * Represents a change to a chat's timeline, typed with the app's events it shows.
264
+ *
265
+ * **Details**
266
+ *
267
+ * A change holds each item that is new or changed (for example, a message whose block was
268
+ * answered or closed, or a call the user approved), in full. It also holds the current activity, whether the last turn failed,
269
+ * and whether the chat was deleted. `seq` is the position in the chat's log that the change is up
270
+ * to.
271
+ *
272
+ * @category models
273
+ * @since 0.3.0
274
+ */
275
+ export interface Change<Events = never> {
276
+ readonly seq: number
277
+ readonly items: ReadonlyArray<Item<Events>>
278
+ readonly activity: Activity
279
+ readonly failed: boolean
280
+ readonly deleted: boolean
281
+ }
282
+
283
+ const eventItem = (schema: AssistantEvent.AppEventSchema) =>
284
+ Schema.Struct({ _tag: Schema.Literal(AppEvents.tagOf(schema)), id: Schema.String, at: Schema.Finite, event: schema })
285
+
286
+ function makeChange<const Events extends ReadonlyArray<AssistantEvent.AppEventSchema> = readonly []>(
287
+ events?: Events
288
+ ): Schema.Codec<Change<Events[number]["Type"]>, Schema.Json>
289
+ // The overload states the decoded type: an item per given schema, each typed with its own event.
290
+ function makeChange(events: ReadonlyArray<AssistantEvent.AppEventSchema> = []): Schema.Top {
291
+ const AnyItem = Schema.toCodecJson(
292
+ Schema.Union([UserMessage, AssistantMessage, ApprovalItem, ...events.map(eventItem)])
293
+ )
294
+
295
+ const decodeItem = Schema.decodeUnknownOption(AnyItem)
296
+ const encodeItem = Schema.encodeUnknownSync(AnyItem)
297
+
298
+ return Schema.Struct({
299
+ seq: Schema.Int,
300
+ // An item for an event this schema doesn't name, or that doesn't decode (from a newer server, say), is left out
301
+ // rather than failing the whole change.
302
+ items: Schema.Array(Schema.Json).pipe(
303
+ Schema.decodeTo(
304
+ Schema.Array(Schema.toType(AnyItem)),
305
+ SchemaTransformation.transform<ReadonlyArray<typeof AnyItem.Type>, ReadonlyArray<Schema.Json>>({
306
+ decode: (json) => json.flatMap((item) => Option.toArray(decodeItem(item))),
307
+ encode: (items) => items.map((item) => encodeItem(item))
308
+ })
309
+ )
310
+ ),
311
+ activity: Activity,
312
+ failed: Schema.Boolean,
313
+ deleted: Schema.Boolean
314
+ })
315
+ }
316
+
317
+ /**
318
+ * Creates the schema for the changes to a chat's timeline, with items for the given app events,
319
+ * in JSON.
320
+ *
321
+ * **Details**
322
+ *
323
+ * It is the success schema of the RPC that streams a chat's timeline: name the events the chat's
324
+ * users see, the same ones the handler passes to {@link changes} as `show`. Tool calls waiting for
325
+ * approval are always in it, as {@link ApprovalItem}s. Each item decodes its
326
+ * event with that event's schema, so the client gets `Change<Events>` typed with them and folds it
327
+ * with {@link fold}, no definition needed. `Change()` carries no app events.
328
+ *
329
+ * A handler whose changes show an event the schema doesn't name doesn't compile.
330
+ *
331
+ * **Example** (An RPC that streams a chat's timeline, with the budget notice)
332
+ *
333
+ * ```ts
334
+ * import { ChatId, Timeline } from "effect-assistant"
335
+ * import * as Rpc from "effect/rpc/Rpc"
336
+ * import * as Schema from "effect/Schema"
337
+ *
338
+ * class DailyBudgetReached extends Schema.TaggedClass<DailyBudgetReached>()("DailyBudgetReached", {}) {}
339
+ *
340
+ * const Subscribe = Rpc.make("Subscribe", {
341
+ * payload: { chatId: ChatId.ChatId },
342
+ * success: Timeline.Change([DailyBudgetReached]),
343
+ * stream: true
344
+ * })
345
+ * ```
346
+ *
347
+ * @category schemas
348
+ * @since 0.3.0
349
+ */
350
+ export const Change: <const Events extends ReadonlyArray<AssistantEvent.AppEventSchema> = readonly []>(
351
+ events?: Events
352
+ ) => Schema.Codec<Change<Events[number]["Type"]>, Schema.Json> = makeChange
353
+
354
+ /**
355
+ * The timeline of a chat before anything has happened in it, with no items and nothing working.
356
+ * Use it as the starting point when folding changes with {@link apply}.
357
+ *
358
+ * @category constructors
359
+ * @since 0.1.0
360
+ */
361
+ export const empty: Timeline = {
362
+ seq: 0,
363
+ items: [],
364
+ activity: { working: false, specialists: [] },
365
+ failed: false,
366
+ deleted: false
367
+ }
368
+
369
+ /**
370
+ * Returns the timeline after a change.
371
+ *
372
+ * **Details**
373
+ *
374
+ * Each item in the change replaces the item with the same id, in place, and new items are added
375
+ * after the rest. The change's activity, `failed`, and `deleted` replace the timeline's. Applying a
376
+ * change twice changes nothing, so a client that resubscribes from the start can fold again and get
377
+ * the same timeline.
378
+ *
379
+ * The timeline is typed with the change's events, as its RPC's schema decoded them (see
380
+ * {@link Change}). To fold a stream of changes, use {@link fold}: in
381
+ * `Stream.scan(() => Timeline.empty, Timeline.apply)`, TypeScript fixes the state's type from
382
+ * `empty` before it sees the changes' events, so that form needs the timeline's type written out.
383
+ *
384
+ * **Example** (Folding changes into your own state)
385
+ *
386
+ * ```ts
387
+ * import { Timeline } from "effect-assistant"
388
+ *
389
+ * interface State<Events> {
390
+ * readonly timeline: Timeline.Timeline<Events>
391
+ * readonly unread: number
392
+ * }
393
+ *
394
+ * const next = <Events>(state: State<Events>, change: Timeline.Change<Events>): State<Events> => ({
395
+ * timeline: Timeline.apply(state.timeline, change),
396
+ * unread: state.unread + change.items.length
397
+ * })
398
+ * ```
399
+ *
400
+ * @category combinators
401
+ * @since 0.1.0
402
+ */
403
+ export const apply = <Events>(timeline: Timeline<Events>, change: Change<Events>): Timeline<Events> => {
404
+ const changed = new Map(change.items.map((item) => [item.id, item]))
405
+ const known = new Set(timeline.items.map((item) => item.id))
406
+
407
+ return {
408
+ seq: Math.max(timeline.seq, change.seq),
409
+ items: [
410
+ ...timeline.items.map((item) => changed.get(item.id) ?? item),
411
+ ...change.items.filter((item) => !known.has(item.id))
412
+ ],
413
+ activity: change.activity,
414
+ failed: change.failed,
415
+ deleted: change.deleted
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Returns the timelines a stream of changes folds to, from {@link empty}, typed with the changes'
421
+ * events.
422
+ *
423
+ * **Example** (A chat's timeline on the client)
424
+ *
425
+ * ```ts
426
+ * import { Timeline } from "effect-assistant"
427
+ * import * as Schema from "effect/Schema"
428
+ * import * as Stream from "effect/Stream"
429
+ *
430
+ * class DailyBudgetReached extends Schema.TaggedClass<DailyBudgetReached>()("DailyBudgetReached", {}) {}
431
+ *
432
+ * // The stream of an RPC whose success schema is `Timeline.Change([DailyBudgetReached])`.
433
+ * declare const changes: Stream.Stream<Timeline.Change<DailyBudgetReached>>
434
+ *
435
+ * const timelines = changes.pipe(Timeline.fold) // Stream<Timeline.Timeline<DailyBudgetReached>>
436
+ * ```
437
+ *
438
+ * @category combinators
439
+ * @since 0.3.0
440
+ */
441
+ export const fold = <Events, E, R>(
442
+ changes: Stream.Stream<Change<Events>, E, R>
443
+ ): Stream.Stream<Timeline<Events>, E, R> => changes.pipe(Stream.scan((): Timeline<Events> => empty, apply))
444
+
445
+ /**
446
+ * Returns whether nothing is working in the chat: no turn is running or about to start, and no
447
+ * specialist is working.
448
+ *
449
+ * @category guards
450
+ * @since 0.1.0
451
+ */
452
+ export const isIdle = <Events>(timeline: Timeline<Events>): boolean =>
453
+ !timeline.activity.working && timeline.activity.specialists.length === 0
454
+
455
+ interface AnyEventItem {
456
+ readonly _tag: string
457
+ readonly id: string
458
+ readonly at: number
459
+ readonly event: { readonly _tag: string }
460
+ }
461
+
462
+ type AnyItem = UserMessage | AssistantMessage | AnyEventItem | ApprovalItem
463
+
464
+ // What the fold below builds: the overloads that use it type its items with the events shown.
465
+ interface AnyChange {
466
+ readonly seq: number
467
+ readonly items: ReadonlyArray<AnyItem>
468
+ readonly activity: Activity
469
+ readonly failed: boolean
470
+ readonly deleted: boolean
471
+ }
472
+
473
+ /**
474
+ * Represents the options for building a chat's timeline from its log.
475
+ *
476
+ * @category models
477
+ * @since 0.3.0
478
+ */
479
+ export interface ShowOptions<Show extends ReadonlyArray<AssistantEvent.AppEventSchema>> {
480
+ /**
481
+ * The schemas of the app's events the chat's users see. Each becomes an item in the timeline;
482
+ * the app's other events are left out. Defaults to none.
483
+ */
484
+ readonly show?: Show | undefined
485
+ }
486
+
487
+ /**
488
+ * Represents the schemas an assistant's timeline may show: some of its own events.
489
+ *
490
+ * @category utility types
491
+ * @since 0.3.0
492
+ */
493
+ export type Shown<Events> = ReadonlyArray<AssistantEvent.AppEventSchema & { readonly Type: Events }>
494
+
495
+ function snapshotWith(entries: ReadonlyArray<AssistantEvent.AnyEntry>): Change
496
+ function snapshotWith<Events, const Show extends Shown<Events> = readonly []>(
497
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
498
+ definition: Definition<Events>,
499
+ options?: ShowOptions<Show>
500
+ ): Change<Show[number]["Type"]>
501
+ function snapshotWith(
502
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
503
+ _definition?: Schemas,
504
+ options?: ShowOptions<ReadonlyArray<AssistantEvent.AppEventSchema>>
505
+ ): AnyChange {
506
+ const timeline = folding(options?.show ?? [])
507
+
508
+ for (const entry of entries) timeline.step(entry)
509
+
510
+ return timeline.snapshot
511
+ }
512
+
513
+ /**
514
+ * Returns a chat's whole timeline as one change, built from the entries of its log.
515
+ *
516
+ * **When to use**
517
+ *
518
+ * Use it on the server for an RPC that returns a whole chat at once: its success schema is
519
+ * {@link Change} with the same events as `show`, and the client applies it to {@link empty}.
520
+ *
521
+ * **Details**
522
+ *
523
+ * Only the app's events in `show` become items; without it, the timeline has none.
524
+ *
525
+ * @category constructors
526
+ * @since 0.3.0
527
+ */
528
+ export const snapshot: {
529
+ (entries: ReadonlyArray<AssistantEvent.AnyEntry>): Change
530
+ <Events, const Show extends Shown<Events> = readonly []>(
531
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
532
+ definition: Definition<Events>,
533
+ options?: ShowOptions<Show>
534
+ ): Change<Show[number]["Type"]>
535
+ } = snapshotWith
536
+
537
+ function fromEntriesWith(entries: ReadonlyArray<AssistantEvent.AnyEntry>): Timeline
538
+ function fromEntriesWith<Events, const Show extends Shown<Events> = readonly []>(
539
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
540
+ definition: Definition<Events>,
541
+ options?: ShowOptions<Show>
542
+ ): Timeline<Show[number]["Type"]>
543
+ function fromEntriesWith(
544
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
545
+ _definition?: Schemas,
546
+ options?: ShowOptions<ReadonlyArray<AssistantEvent.AppEventSchema>>
547
+ ): AnyChange {
548
+ const timeline = folding(options?.show ?? [])
549
+
550
+ for (const entry of entries) timeline.step(entry)
551
+
552
+ return timeline.snapshot
553
+ }
554
+
555
+ /**
556
+ * Returns a chat's timeline, built from the entries of its log.
557
+ *
558
+ * **When to use**
559
+ *
560
+ * Use it in server-side code that reads a whole chat. A client gets the timeline as a stream from
561
+ * {@link changes} instead, and an RPC that returns a whole chat sends a {@link snapshot}.
562
+ *
563
+ * **Details**
564
+ *
565
+ * Only the app's events in `show` become items, typed with their schemas; without it, the timeline
566
+ * has none.
567
+ *
568
+ * **Example** (Building a chat's timeline from its log)
569
+ *
570
+ * ```ts
571
+ * import { type ChatId, ChatLog, Timeline } from "effect-assistant"
572
+ * import * as Effect from "effect/Effect"
573
+ *
574
+ * const timelineOf = (chatId: ChatId.ChatId) =>
575
+ * ChatLog.ChatLog.use((log) => log.read(chatId)).pipe(
576
+ * Effect.map((entries) => Timeline.fromEntries(entries))
577
+ * )
578
+ * ```
579
+ *
580
+ * @see {@link changes} for a stream of changes instead of a whole timeline
581
+ *
582
+ * @category constructors
583
+ * @since 0.1.0
584
+ */
585
+ export const fromEntries: {
586
+ (entries: ReadonlyArray<AssistantEvent.AnyEntry>): Timeline
587
+ <Events, const Show extends Shown<Events> = readonly []>(
588
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
589
+ definition: Definition<Events>,
590
+ options?: ShowOptions<Show>
591
+ ): Timeline<Show[number]["Type"]>
592
+ } = fromEntriesWith
593
+
594
+ interface Schemas {
595
+ readonly events: ReadonlyArray<AssistantEvent.AppEventSchema>
596
+ }
597
+
598
+ interface Folding {
599
+ readonly step: (entry: AssistantEvent.AnyEntry) => AnyChange | undefined
600
+ readonly snapshot: AnyChange
601
+ }
602
+
603
+ const activityOf = (activity: AssistantActivity.State): Activity => ({
604
+ working: activity.turnSince !== undefined || activity.queued,
605
+ specialists: [...activity.specialists.values()].map((run) => ({
606
+ specialist: run.specialist,
607
+ since: run.since
608
+ }))
609
+ })
610
+
611
+ const sameActivity = (a: Activity, b: Activity) =>
612
+ a.working === b.working &&
613
+ a.specialists.length === b.specialists.length &&
614
+ a.specialists.every((run, i) => {
615
+ const other = b.specialists[i]
616
+
617
+ return other !== undefined && run.specialist === other.specialist && run.since === other.since
618
+ })
619
+
620
+ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding => {
621
+ const shown = new Map(show.map((schema) => [AppEvents.tagOf(schema), schema]))
622
+ let approvals: ReadonlyMap<string, ToolApproval.State> = new Map()
623
+ let seq = 0
624
+ let failed = false
625
+ let deleted = false
626
+ let activity = AssistantActivity.empty
627
+ let blocks: ReadonlyMap<string, BlockKit.State> = new Map()
628
+ // The items in order, and the assistant's messages as stored: their blocks change as the chat goes on.
629
+ const items = new Map<string, AnyItem>()
630
+ const sent = new Map<string, AssistantEvent.AssistantMessage>()
631
+
632
+ const blockOf = (message: AssistantEvent.AssistantMessage): Block | undefined => {
633
+ if (message.block === undefined) return undefined
634
+
635
+ const state = blocks.get(message.id)
636
+ const status = state === undefined || state.open ? "open" : state.answer === undefined ? "closed" : "answered"
637
+ const block: Block = { kind: message.block.kind, props: toJson(message.block.props), status }
638
+
639
+ return state?.answer === undefined ? block : { ...block, answer: toJson(state.answer.value) }
640
+ }
641
+
642
+ const itemOf = (message: AssistantEvent.AssistantMessage, at: number): AssistantMessage => {
643
+ const block = blockOf(message)
644
+
645
+ return block === undefined
646
+ ? AssistantMessage.make({ id: message.id, text: message.text, at })
647
+ : AssistantMessage.make({ id: message.id, text: message.text, at, block })
648
+ }
649
+
650
+ const touched = (event: AssistantEvent.AssistantEvent, at: number): ReadonlyArray<string> =>
651
+ Match.value(event).pipe(
652
+ Match.tagsExhaustive({
653
+ UserMessage: (message) => {
654
+ items.set(
655
+ message.id,
656
+ message.attachments === undefined
657
+ ? UserMessage.make({ id: message.id, text: message.text, at })
658
+ : UserMessage.make({
659
+ id: message.id,
660
+ text: message.text,
661
+ attachments: message.attachments,
662
+ at
663
+ })
664
+ )
665
+ failed = false
666
+
667
+ return [message.id]
668
+ },
669
+ AssistantMessage: (message) => {
670
+ sent.set(message.id, message)
671
+ items.set(message.id, itemOf(message, at))
672
+ failed = false
673
+
674
+ return [message.id, ...(message.closes ?? [])]
675
+ },
676
+ BlockAnswer: (answer) => {
677
+ failed = false
678
+
679
+ return [answer.messageId]
680
+ },
681
+ BlocksClosed: (closed) => closed.messageIds,
682
+ ApprovalRequested: () => [],
683
+ ApprovalDecision: () => {
684
+ failed = false
685
+
686
+ return []
687
+ },
688
+ ApprovalsExpired: () => [],
689
+ TurnFailed: () => {
690
+ failed = true
691
+
692
+ return []
693
+ },
694
+ ModelOutput: () => [],
695
+ ToolStarted: () => [],
696
+ ToolResult: () => [],
697
+ TurnStarted: () => [],
698
+ TurnCompleted: () => [],
699
+ SpecialistStarted: () => [],
700
+ SpecialistFinished: () => [],
701
+ ChatDeleted: () => {
702
+ deleted = true
703
+
704
+ return []
705
+ }
706
+ })
707
+ )
708
+
709
+ // Decoded as `subscribe` streams it, or as the log stores it; one that doesn't decode is left out.
710
+ const eventItemOf = (entry: AssistantEvent.AnyEntry): Option.Option<AnyEventItem> => {
711
+ const schema = shown.get(entry.event._tag)
712
+
713
+ if (schema === undefined) return Option.none()
714
+
715
+ const event = Schema.is(schema)(entry.event) ? Option.some(entry.event) : AppEvents.decodeWith(schema, entry.event)
716
+
717
+ return Option.map(event, (decoded) => ({
718
+ _tag: decoded._tag,
719
+ id: `app-event:${entry.seq}`,
720
+ at: entry.at,
721
+ event: decoded
722
+ }))
723
+ }
724
+
725
+ const callsOf = (event: AssistantEvent.AssistantEvent): ReadonlyArray<string> =>
726
+ Predicate.isTagged(event, "ApprovalsExpired")
727
+ ? event.callIds
728
+ : Predicate.isTagged(event, "ApprovalRequested") ||
729
+ Predicate.isTagged(event, "ApprovalDecision") ||
730
+ Predicate.isTagged(event, "ToolResult")
731
+ ? [event.callId]
732
+ : []
733
+
734
+ // The items of the calls this entry changed.
735
+ const approvalItems = (event: AssistantEvent.AssistantEvent, before: ReadonlyMap<string, ToolApproval.State>) =>
736
+ callsOf(event).flatMap((callId) => {
737
+ const state = approvals.get(callId)
738
+
739
+ if (state === undefined || state === before.get(callId)) return []
740
+
741
+ const item = ApprovalItem.make({
742
+ id: callId,
743
+ tool: state.request.tool,
744
+ status: state.status,
745
+ reason: state.reason,
746
+ outcome: state.outcome,
747
+ at: state.at
748
+ })
749
+
750
+ items.set(callId, item)
751
+
752
+ return [item]
753
+ })
754
+
755
+ const step = (entry: AssistantEvent.AnyEntry): AnyChange | undefined => {
756
+ const previous = { activity: activityOf(activity), failed, deleted }
757
+ const { event } = entry
758
+
759
+ seq = entry.seq
760
+ activity = AssistantActivity.apply(activity, entry)
761
+
762
+ if (!AssistantEvent.isLibraryEvent(event)) {
763
+ const now = activityOf(activity)
764
+
765
+ return Option.match(eventItemOf(entry), {
766
+ onNone: () =>
767
+ sameActivity(previous.activity, now) ? undefined : { seq, items: [], activity: now, failed, deleted },
768
+ onSome: (item) => {
769
+ items.set(item.id, item)
770
+
771
+ return { seq, items: [item], activity: now, failed, deleted }
772
+ }
773
+ })
774
+ }
775
+
776
+ blocks = BlockKit.apply(blocks, event)
777
+
778
+ const decidedBefore = approvals
779
+ approvals = ToolApproval.apply(approvals, entry)
780
+
781
+ const changed = [...new Set(touched(event, entry.at))].flatMap((id) => {
782
+ const message = sent.get(id)
783
+ const current = items.get(id)
784
+
785
+ if (message === undefined || current === undefined) return current === undefined ? [] : [current]
786
+
787
+ const next = itemOf(message, current.at)
788
+ items.set(id, next)
789
+
790
+ return [next]
791
+ })
792
+
793
+ const all = [...changed, ...approvalItems(event, decidedBefore)]
794
+ const now = activityOf(activity)
795
+
796
+ return all.length === 0 &&
797
+ failed === previous.failed &&
798
+ deleted === previous.deleted &&
799
+ sameActivity(previous.activity, now)
800
+ ? undefined
801
+ : { seq, items: all, activity: now, failed, deleted }
802
+ }
803
+
804
+ return {
805
+ step,
806
+ get snapshot() {
807
+ return { seq, items: [...items.values()], activity: activityOf(activity), failed, deleted }
808
+ }
809
+ }
810
+ }
811
+
812
+ const merge = (changes: ReadonlyArray<AnyChange>): AnyChange | undefined => {
813
+ const last = changes.at(-1)
814
+
815
+ if (last === undefined) return undefined
816
+
817
+ const items = new Map<string, AnyItem>()
818
+
819
+ for (const change of changes) for (const item of change.items) items.set(item.id, item)
820
+
821
+ return {
822
+ seq: last.seq,
823
+ items: [...items.values()],
824
+ activity: last.activity,
825
+ failed: last.failed,
826
+ deleted: last.deleted
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Represents an assistant's definition as the timeline reads it. An assistant made with
832
+ * `Assistant.make` fits it.
833
+ *
834
+ * @category models
835
+ * @since 0.1.0
836
+ */
837
+ export interface Definition<Events = never> {
838
+ /** The schemas of the assistant's events. */
839
+ readonly events: ReadonlyArray<AssistantEvent.AppEventSchema>
840
+ /** The schema of the chat's entries, typed with the assistant's events: it types `show`. */
841
+ readonly Entry: Schema.Codec<Assistant.Entry<Events>, Schema.Json>
842
+ }
843
+
844
+ /**
845
+ * Represents the options accepted by {@link changes}.
846
+ *
847
+ * @category models
848
+ * @since 0.3.0
849
+ */
850
+ export interface ChangesOptions<
851
+ Show extends ReadonlyArray<AssistantEvent.AppEventSchema> = readonly []
852
+ > extends ShowOptions<Show> {
853
+ /**
854
+ * The `seq` up to which the client already has the timeline. The result contains only the
855
+ * changes after it. Defaults to `0`, every change.
856
+ */
857
+ readonly after?: number | undefined
858
+ }
859
+
860
+ function changesWith<E, R, Events, const Show extends Shown<Events> = readonly []>(
861
+ entries: Stream.Stream<AssistantEvent.AnyEntry, E, R>,
862
+ definition: Definition<Events>,
863
+ options?: ChangesOptions<Show>
864
+ ): Stream.Stream<Change<Show[number]["Type"]>, E, R>
865
+ function changesWith<E, R>(
866
+ entries: Stream.Stream<AssistantEvent.AnyEntry, E, R>,
867
+ _definition: Schemas,
868
+ options?: ChangesOptions<ReadonlyArray<AssistantEvent.AppEventSchema>>
869
+ ): Stream.Stream<AnyChange, E, R> {
870
+ const after = options?.after ?? 0
871
+
872
+ return entries.pipe(
873
+ Stream.mapAccumArray(
874
+ () => folding(options?.show ?? []),
875
+ (timeline, chunk) => {
876
+ const changed = chunk.flatMap((entry) => {
877
+ const change = timeline.step(entry)
878
+
879
+ return change === undefined || entry.seq <= after ? [] : [change]
880
+ })
881
+
882
+ const merged = merge(changed)
883
+
884
+ return [timeline, merged === undefined ? [] : [merged]]
885
+ }
886
+ )
887
+ )
888
+ }
889
+
890
+ /**
891
+ * Returns the stream of changes to a chat's timeline that its entries produce, for a chat client.
892
+ *
893
+ * **Details**
894
+ *
895
+ * The entries must stream from the start of the chat's log, as `assistant.subscribe(chatId)` does
896
+ * without `after`. The fold reads every entry, because a message depends on earlier entries: its
897
+ * block is answered or closed later. `after` only skips emitting the changes up to that `seq`, so a
898
+ * client that has the timeline up to a `seq` receives only what changed since. Pass `after` here,
899
+ * not to `subscribe`: a subscription that starts after a `seq` gives the fold an incomplete log,
900
+ * and the changes it produces are wrong.
901
+ *
902
+ * Each chunk of entries becomes one change; the stored entries, for example, arrive as one chunk.
903
+ *
904
+ * **Only the app's events in `show` reach the client.** Each one becomes an item between the
905
+ * messages (see {@link EventItem}); the app's other events, like a summary of the chat for the
906
+ * model, are left out. Without `show`, none are shown. The changes are typed with the events shown,
907
+ * so the RPC's success schema names the same ones (`Timeline.Change([...])`), and a handler that
908
+ * shows one the schema doesn't name doesn't compile. A timeline shows a turn as working once it
909
+ * starts, or from the user's input.
910
+ *
911
+ * **Example** (Streaming a chat's timeline to a client, with the one app event its user sees)
912
+ *
913
+ * ```ts
914
+ * import { Assistant, type ChatId, Timeline } from "effect-assistant"
915
+ * import * as Schema from "effect/Schema"
916
+ * import * as Stream from "effect/Stream"
917
+ *
918
+ * class Summary extends Schema.TaggedClass<Summary>()("Summary", { text: Schema.String, throughSeq: Schema.Int }) {}
919
+ * class DailyBudgetReached extends Schema.TaggedClass<DailyBudgetReached>()("DailyBudgetReached", {}) {}
920
+ *
921
+ * const MyAssistant = Assistant.make("my-assistant", { events: [Summary, DailyBudgetReached] })
922
+ *
923
+ * // The summary is for the model: only the budget notice reaches the user.
924
+ * const watch = (chatId: ChatId.ChatId, after: number) =>
925
+ * Stream.unwrap(
926
+ * MyAssistant.useSync((assistant) =>
927
+ * assistant.subscribe(chatId).pipe(Timeline.changes(MyAssistant, { show: [DailyBudgetReached], after }))
928
+ * )
929
+ * )
930
+ * ```
931
+ *
932
+ * @see {@link fold} for folding the changes into a timeline on the client
933
+ *
934
+ * @category utils
935
+ * @since 0.3.0
936
+ */
937
+ export const changes: {
938
+ <Events, const Show extends Shown<Events> = readonly []>(
939
+ definition: Definition<Events>,
940
+ options?: ChangesOptions<Show>
941
+ ): <E, R>(entries: Stream.Stream<AssistantEvent.AnyEntry, E, R>) => Stream.Stream<Change<Show[number]["Type"]>, E, R>
942
+ <E, R, Events, const Show extends Shown<Events> = readonly []>(
943
+ entries: Stream.Stream<AssistantEvent.AnyEntry, E, R>,
944
+ definition: Definition<Events>,
945
+ options?: ChangesOptions<Show>
946
+ ): Stream.Stream<Change<Show[number]["Type"]>, E, R>
947
+ } = dual((args) => Stream.isStream(args[0]), changesWith)