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,249 @@
1
+ /**
2
+ * Tool calls that wait for the user's approval.
3
+ *
4
+ * A tool made with Effect's `needsApproval` doesn't run when the model calls it in a turn: the
5
+ * call waits ({@link AssistantEvent.ApprovalRequested}), the turn ends, and the chat's timeline
6
+ * shows it to the user. Their decision, built with {@link approve} or {@link reject} and submitted
7
+ * like a message, wakes a turn: an approved call runs then, with its params and id, and a rejected
8
+ * one doesn't. A call the user moved on from expires ({@link closeStale}, like `BlockKit.closeStale`). On the timeline, every
9
+ * call is an item with its tool's name and where it stands: only the yes or no, since the
10
+ * assistant's message says what it asks.
11
+ *
12
+ * @since 0.4.0
13
+ */
14
+ import * as Effect from "effect/Effect"
15
+ import * as Predicate from "effect/Predicate"
16
+ import * as AssistantEvent from "./AssistantEvent.ts"
17
+ import { CurrentChat } from "./CurrentChat.ts"
18
+ import * as InputId from "./InputId.ts"
19
+
20
+ /**
21
+ * Represents where a call that waits for approval stands: `"pending"` until the user decides,
22
+ * then `"approved"` or `"rejected"`, or `"expired"` when the user moved on.
23
+ *
24
+ * @category models
25
+ * @since 0.4.0
26
+ */
27
+ export type Status = "pending" | "approved" | "rejected" | "expired"
28
+
29
+ /**
30
+ * Represents a tool call that waited, or waits, for the user's approval.
31
+ *
32
+ * @category models
33
+ * @since 0.4.0
34
+ */
35
+ export interface State {
36
+ readonly request: AssistantEvent.ApprovalRequested
37
+ /** When the call started waiting, in epoch milliseconds. */
38
+ readonly at: number
39
+ readonly status: Status
40
+ /** The user's reason, when they rejected it with one. */
41
+ readonly reason: string | undefined
42
+ /** How the approved call went, once it ran. */
43
+ readonly outcome: AssistantEvent.ToolOutcome | undefined
44
+ }
45
+
46
+ const decided = (state: State, decision: AssistantEvent.ApprovalDecision): State =>
47
+ decision.reason === undefined
48
+ ? { ...state, status: decision.decision }
49
+ : { ...state, status: decision.decision, reason: decision.reason }
50
+
51
+ /**
52
+ * Returns the approvals after one more entry. Fold a chat's entries in order, or use
53
+ * {@link fromEntries}.
54
+ *
55
+ * **Details**
56
+ *
57
+ * The first of a call's decision and its expiry counts: anything after it changes nothing.
58
+ *
59
+ * @category combinators
60
+ * @since 0.4.0
61
+ */
62
+ export const apply = (
63
+ states: ReadonlyMap<string, State>,
64
+ entry: AssistantEvent.AnyEntry
65
+ ): ReadonlyMap<string, State> => {
66
+ const { event } = entry
67
+
68
+ if (!AssistantEvent.isLibraryEvent(event)) return states
69
+
70
+ if (Predicate.isTagged(event, "ApprovalRequested")) {
71
+ return states.has(event.callId)
72
+ ? states
73
+ : new Map(states).set(event.callId, {
74
+ request: event,
75
+ at: entry.at,
76
+ status: "pending",
77
+ reason: undefined,
78
+ outcome: undefined
79
+ })
80
+ }
81
+
82
+ if (Predicate.isTagged(event, "ApprovalDecision")) {
83
+ const state = states.get(event.callId)
84
+
85
+ return state === undefined || state.status !== "pending"
86
+ ? states
87
+ : new Map(states).set(event.callId, decided(state, event))
88
+ }
89
+
90
+ if (Predicate.isTagged(event, "ApprovalsExpired")) {
91
+ const expired = event.callIds.flatMap((callId) => {
92
+ const state = states.get(callId)
93
+
94
+ return state === undefined || state.status !== "pending" ? [] : [{ ...state, status: "expired" as const }]
95
+ })
96
+
97
+ if (expired.length === 0) return states
98
+
99
+ const next = new Map(states)
100
+
101
+ for (const state of expired) next.set(state.request.callId, state)
102
+
103
+ return next
104
+ }
105
+
106
+ if (Predicate.isTagged(event, "ToolResult")) {
107
+ const state = states.get(event.callId)
108
+
109
+ return state === undefined || state.status !== "approved"
110
+ ? states
111
+ : new Map(states).set(event.callId, { ...state, outcome: event.outcome })
112
+ }
113
+
114
+ return states
115
+ }
116
+
117
+ /**
118
+ * Returns every call that waited for approval in a chat, by call id, with where it stands.
119
+ *
120
+ * @category constructors
121
+ * @since 0.4.0
122
+ */
123
+ export const fromEntries = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): ReadonlyMap<string, State> =>
124
+ entries.reduce(apply, new Map<string, State>())
125
+
126
+ /**
127
+ * The id of a decision on a call: the same for every decision on it, so the chat stores one.
128
+ *
129
+ * @category utils
130
+ * @since 0.4.0
131
+ */
132
+ export const decisionId = (callId: string): InputId.InputId => InputId.fromKey(`approval:${callId}`)
133
+
134
+ /**
135
+ * Returns the input that approves a call waiting for the user's approval, to submit like a
136
+ * message. `approval` is the call's item on the chat's timeline, or anything with the call's id.
137
+ *
138
+ * **Example** (Approve and reject buttons on the client)
139
+ *
140
+ * ```tsx
141
+ * import { ToolApproval } from "effect-assistant"
142
+ *
143
+ * const buttons = (approval: { readonly id: string; readonly status: ToolApproval.Status }) =>
144
+ * approval.status === "pending" ? (
145
+ * <>
146
+ * <button onClick={() => submit(ToolApproval.approve(approval))}>Approve</button>
147
+ * <button onClick={() => submit(ToolApproval.reject(approval, { reason: "Not this week" }))}>Reject</button>
148
+ * </>
149
+ * ) : null
150
+ * ```
151
+ *
152
+ * @category constructors
153
+ * @since 0.4.0
154
+ */
155
+ export const approve = (approval: { readonly id: string }): AssistantEvent.ApprovalDecision =>
156
+ AssistantEvent.ApprovalDecision.make({ id: decisionId(approval.id), callId: approval.id, decision: "approved" })
157
+
158
+ /**
159
+ * Returns the input that rejects a call waiting for the user's approval, with an optional short
160
+ * reason the model reads.
161
+ *
162
+ * @see {@link approve} for an example
163
+ *
164
+ * @category constructors
165
+ * @since 0.4.0
166
+ */
167
+ export const reject = (
168
+ approval: { readonly id: string },
169
+ options?: { readonly reason?: string | undefined }
170
+ ): AssistantEvent.ApprovalDecision =>
171
+ options?.reason === undefined
172
+ ? AssistantEvent.ApprovalDecision.make({ id: decisionId(approval.id), callId: approval.id, decision: "rejected" })
173
+ : AssistantEvent.ApprovalDecision.make({
174
+ id: decisionId(approval.id),
175
+ callId: approval.id,
176
+ decision: "rejected",
177
+ reason: options.reason
178
+ })
179
+
180
+ /**
181
+ * Returns the ids of the calls still waiting for approval after the user typed the given number
182
+ * of messages without deciding them.
183
+ *
184
+ * **Details**
185
+ *
186
+ * These are the calls {@link closeStale} closes.
187
+ *
188
+ * @category utils
189
+ * @since 0.4.0
190
+ */
191
+ export const staleApprovals = (
192
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
193
+ options: { readonly messages: number }
194
+ ): ReadonlyArray<string> => {
195
+ const states = fromEntries(entries)
196
+
197
+ return [...states.values()].flatMap((state) => {
198
+ if (state.status !== "pending") return []
199
+
200
+ const requested = entries.findIndex(
201
+ ({ event }) =>
202
+ AssistantEvent.isLibraryEvent(event) &&
203
+ Predicate.isTagged(event, "ApprovalRequested") &&
204
+ event.callId === state.request.callId
205
+ )
206
+
207
+ const typed = entries.slice(requested + 1).filter(({ event }) => Predicate.isTagged(event, "UserMessage")).length
208
+
209
+ return typed >= options.messages ? [state.request.callId] : []
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Closes the calls the user moved on from, which still wait for approval after the given number
215
+ * of typed messages: they expire, and won't run.
216
+ *
217
+ * **Details**
218
+ *
219
+ * Run it from a turn, before or after its loop, like `BlockKit.closeStale`. It records an
220
+ * `ApprovalsExpired` event: the timeline shows the calls as expired, the model reads that they
221
+ * weren't approved, and a decision that arrives later isn't stored.
222
+ *
223
+ * **Example** (Closing calls the user moved on from, and stale blocks, every turn)
224
+ *
225
+ * ```ts
226
+ * import { BlockKit, ToolApproval } from "effect-assistant"
227
+ * import * as Effect from "effect/Effect"
228
+ *
229
+ * const MyAssistantLive = MyAssistant.toLayer(
230
+ * Effect.fn(function* (turn) {
231
+ * yield* BlockKit.closeStale({ messages: 10 })
232
+ * yield* ToolApproval.closeStale({ messages: 10 })
233
+ *
234
+ * return turn.loop({ instructions, toolkit: MyTools })
235
+ * })
236
+ * )
237
+ * ```
238
+ *
239
+ * @see {@link staleApprovals} for the calls it closes
240
+ *
241
+ * @category utils
242
+ * @since 0.4.0
243
+ */
244
+ export const closeStale = Effect.fn(function* (options: { readonly messages: number }) {
245
+ const chat = yield* CurrentChat
246
+ const stale = staleApprovals(yield* chat.entries, options)
247
+
248
+ if (stale.length > 0) yield* chat.append(AssistantEvent.ApprovalsExpired.make({ callIds: stale }))
249
+ })
@@ -94,8 +94,9 @@ const requiring =
94
94
  * the tools' handlers. A catalog tool's services are `execute_tool`'s, so the turn provides them
95
95
  * as it does for any tool.
96
96
  *
97
- * A catalog tool with Effect's `needsApproval` set fails the layer when it is built, as it would
98
- * fail a loop: the library doesn't run calls that wait for approval.
97
+ * A catalog tool with Effect's `needsApproval` set fails the layer when it is built: a call
98
+ * through `execute_tool` can't wait for the user's approval. Give such a tool to the loop's toolkit
99
+ * directly (see `ToolApproval`).
99
100
  *
100
101
  * **Example** (Finding rarely used tools when needed)
101
102
  *
@@ -148,7 +149,11 @@ export const layer = <T extends Record<string, Tool.Any>>(catalog: Toolkit.Toolk
148
149
  const definitions = Object.values(resolved.tools)
149
150
  const needingApproval = Toolkits.needingApproval(definitions)
150
151
 
151
- if (needingApproval.length > 0) return yield* Effect.die(Toolkits.needsApprovalMessage(needingApproval))
152
+ if (needingApproval.length > 0) {
153
+ return yield* Effect.die(
154
+ `${needingApproval.join(", ")} ${needingApproval.length === 1 ? "sets" : "set"} needsApproval, and a call through execute_tool can't wait for the user's approval. Give it to the loop's toolkit instead of ToolDiscovery's catalog.`
155
+ )
156
+ }
152
157
 
153
158
  return {
154
159
  search_tools: ({ query }) =>
package/src/index.ts CHANGED
@@ -4,11 +4,6 @@
4
4
  * @since 0.1.0
5
5
  */
6
6
 
7
- /**
8
- * @since 0.1.0
9
- */
10
- export * as AppEvent from "./AppEvent.ts"
11
-
12
7
  /**
13
8
  * @since 0.1.0
14
9
  */
@@ -64,11 +59,6 @@ export * as ChatLog from "./ChatLog.ts"
64
59
  */
65
60
  export * as Chats from "./Chats.ts"
66
61
 
67
- /**
68
- * @since 0.1.0
69
- */
70
- export * as ChatView from "./ChatView.ts"
71
-
72
62
  /**
73
63
  * @since 0.1.0
74
64
  */
@@ -109,6 +99,16 @@ export * as Skill from "./Skill.ts"
109
99
  */
110
100
  export * as Specialist from "./Specialist.ts"
111
101
 
102
+ /**
103
+ * @since 0.3.0
104
+ */
105
+ export * as Timeline from "./Timeline.ts"
106
+
107
+ /**
108
+ * @since 0.4.0
109
+ */
110
+ export * as ToolApproval from "./ToolApproval.ts"
111
+
112
112
  /**
113
113
  * @since 0.1.0
114
114
  */
@@ -18,6 +18,7 @@ import { ChatLog } from "../ChatLog.ts"
18
18
  import { CurrentChat } from "../CurrentChat.ts"
19
19
  import * as Chats from "../Chats.ts"
20
20
  import * as SpecialistService from "../Specialist.ts"
21
+ import * as ToolApproval from "../ToolApproval.ts"
21
22
  import * as AppEvents from "./events.ts"
22
23
  import * as History from "./history.ts"
23
24
  import * as Specialists from "./specialists.ts"
@@ -101,7 +102,6 @@ export const make = Effect.fnUntraced(function* (options: {
101
102
  }) {
102
103
  const { registration, chatId } = options
103
104
  const { app } = registration.definition
104
- const { wakes } = app
105
105
  const storage = yield* ChatLog
106
106
  const chats = yield* Chats.Chats
107
107
  const initial = yield* storage.read(chatId)
@@ -198,10 +198,20 @@ export const make = Effect.fnUntraced(function* (options: {
198
198
  if (yield* isDeleted) return yield* refused(input, "is deleted")
199
199
 
200
200
  const id = idOf(input)
201
+ const all = yield* Ref.get(entries)
201
202
 
202
- if (id !== undefined && (yield* Ref.get(entries)).some(({ event }) => idOf(event) === id)) return
203
+ if (id !== undefined && all.some(({ event }) => idOf(event) === id)) return
203
204
 
204
- yield* write([input])
205
+ // A decision counts only for a call that still waits: not one decided, or expired, or unknown.
206
+ if (
207
+ Predicate.isTagged(input, "ApprovalDecision") &&
208
+ ToolApproval.fromEntries(all).get(input.callId)?.status !== "pending"
209
+ ) {
210
+ return yield* refused(input, "has no call waiting for that decision")
211
+ }
212
+
213
+ // Stored as an input: it came in from outside a turn, so it wakes one, also after a restart.
214
+ yield* write([AssistantEvent.isLibraryEvent(input) ? input : AppEvents.markInput(input)])
205
215
  })
206
216
  )
207
217
  })
@@ -232,7 +242,7 @@ export const make = Effect.fnUntraced(function* (options: {
232
242
 
233
243
  const drain: Effect.Effect<void> = Semaphore.withPermit(turnsLock)(
234
244
  Effect.gen(function* () {
235
- while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries, wakes)) {
245
+ while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries)) {
236
246
  yield* Turns.run({ registration, handler, chat, services, checkOpen: Effect.asVoid(checkAvailable) })
237
247
  }
238
248
  })
@@ -296,7 +306,7 @@ export const make = Effect.fnUntraced(function* (options: {
296
306
  yield* inBackground(drain)
297
307
  }),
298
308
  resume: Effect.gen(function* () {
299
- if (History.needsTurn(yield* chat.entries, wakes)) yield* inBackground(drain)
309
+ if (History.needsTurn(yield* chat.entries)) yield* inBackground(drain)
300
310
  }),
301
311
  stream: (after) =>
302
312
  Stream.unwrap(
@@ -1,27 +1,30 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import * as Option from "effect/Option"
3
+ import * as Predicate from "effect/Predicate"
4
+ import * as Record from "effect/Record"
3
5
  import * as Schema from "effect/Schema"
4
- import type * as AppEvent from "../AppEvent.ts"
6
+ import * as SchemaAST from "effect/SchemaAST"
5
7
  import type * as Assistant from "../Assistant.ts"
6
8
  import * as AssistantEvent from "../AssistantEvent.ts"
7
- import { toJsonText } from "./schema.ts"
9
+ import { inputKey } from "./input.ts"
8
10
 
9
11
  /** @internal */
10
12
  export interface AppEvents {
11
- readonly byTag: ReadonlyMap<string, Registered>
12
- readonly wakes: ReadonlySet<string>
13
+ readonly byTag: ReadonlyMap<string, AssistantEvent.AppEventSchema>
13
14
  }
14
15
 
15
- interface Registered {
16
- readonly schema: AppEvent.EventSchema
17
- readonly render: AppEvent.Render<{ readonly _tag: string }> | undefined
16
+ /** @internal */
17
+ export const tagOf = (schema: AssistantEvent.AppEventSchema): string => {
18
+ const ast = schema.fields._tag.ast
19
+
20
+ if (SchemaAST.isLiteral(ast) && Predicate.isString(ast.literal)) return ast.literal
21
+
22
+ throw new Error("An app event is a tagged struct or class.")
18
23
  }
19
24
 
20
25
  /** @internal */
21
- export const make = (events: ReadonlyArray<AppEvent.Any>): AppEvents => {
22
- const byTag = new Map(
23
- events.map((event): [string, Registered] => [event.tag, { schema: event.schema, render: event.render }])
24
- )
26
+ export const make = (events: ReadonlyArray<AssistantEvent.AppEventSchema>): AppEvents => {
27
+ const byTag = new Map(events.map((schema): [string, AssistantEvent.AppEventSchema] => [tagOf(schema), schema]))
25
28
 
26
29
  for (const tag of byTag.keys()) {
27
30
  if (AssistantEvent.isLibraryEvent({ _tag: tag })) throw new Error(`${tag} is one of the library's events.`)
@@ -29,17 +32,17 @@ export const make = (events: ReadonlyArray<AppEvent.Any>): AppEvents => {
29
32
 
30
33
  if (byTag.size !== events.length) throw new Error("An assistant's events have different tags.")
31
34
 
32
- return { byTag, wakes: new Set(events.flatMap((event) => (event.wakes ? [event.tag] : []))) }
35
+ return { byTag }
33
36
  }
34
37
 
35
38
  /** @internal */
36
39
  export const encode = Effect.fnUntraced(function* (app: AppEvents, event: { readonly _tag: string }) {
37
- const registered = app.byTag.get(event._tag)
40
+ const schema = app.byTag.get(event._tag)
38
41
 
39
42
  // The types only let registered events through, so one with no schema here is a defect.
40
- if (registered === undefined) return yield* Effect.die(`${event._tag} isn't one of this assistant's events.`)
43
+ if (schema === undefined) return yield* Effect.die(`${event._tag} isn't one of this assistant's events.`)
41
44
 
42
- const json = yield* Schema.encodeUnknownEffect(Schema.toCodecJson(registered.schema))(event)
45
+ const json = yield* Schema.encodeUnknownEffect(Schema.toCodecJson(schema))(event)
43
46
 
44
47
  return yield* Schema.decodeUnknownEffect(AssistantEvent.StoredAppEvent)(json)
45
48
  }, Effect.orDie)
@@ -47,6 +50,30 @@ export const encode = Effect.fnUntraced(function* (app: AppEvents, event: { read
47
50
  /** @internal */
48
51
  export const isRegistered = (app: AppEvents, event: { readonly _tag: string }): boolean => app.byTag.has(event._tag)
49
52
 
53
+ // The app's schema decodes the event as the app wrote it.
54
+ const unmarked = (event: { readonly _tag: string }) =>
55
+ Predicate.hasProperty(event, inputKey) ? Record.remove(event, inputKey) : event
56
+
57
+ // An event as the log stores it, decoded with its schema.
58
+ /** @internal */
59
+ export const decodeWith = (
60
+ schema: AssistantEvent.AppEventSchema,
61
+ event: { readonly _tag: string }
62
+ ): Option.Option<{ readonly _tag: string }> => Schema.decodeUnknownOption(Schema.toCodecJson(schema))(unmarked(event))
63
+
64
+ const decodeEvent = (app: AppEvents, event: { readonly _tag: string }): Option.Option<{ readonly _tag: string }> => {
65
+ const schema = app.byTag.get(event._tag)
66
+
67
+ return schema === undefined ? Option.none() : decodeWith(schema, event)
68
+ }
69
+
70
+ // What came in through `submit`, from outside a turn, is an input: it wakes a turn.
71
+ /** @internal */
72
+ export const markInput = (event: AssistantEvent.StoredAppEvent): AssistantEvent.StoredAppEvent => ({
73
+ ...event,
74
+ [inputKey]: true
75
+ })
76
+
50
77
  // An app event that doesn't decode (say, one from an older version of its schema) is left out.
51
78
  /** @internal */
52
79
  export const decodeEntry = (app: AppEvents, entry: AssistantEvent.AnyEntry): Option.Option<Assistant.Entry<any>> => {
@@ -54,14 +81,7 @@ export const decodeEntry = (app: AppEvents, entry: AssistantEvent.AnyEntry): Opt
54
81
 
55
82
  if (AssistantEvent.isLibraryEvent(event)) return Option.some({ ...toEntry(entry), event })
56
83
 
57
- const registered = app.byTag.get(event._tag)
58
-
59
- if (registered === undefined) return Option.none()
60
-
61
- return Option.map(Schema.decodeUnknownOption(Schema.toCodecJson(registered.schema))(event), (decoded) => ({
62
- ...toEntry(entry),
63
- event: decoded
64
- }))
84
+ return Option.map(decodeEvent(app, event), (decoded) => ({ ...toEntry(entry), event: decoded }))
65
85
  }
66
86
 
67
87
  /** @internal */
@@ -73,28 +93,8 @@ export const decodeEntries = (
73
93
  /** @internal */
74
94
  export type DecodedEntries = ReturnType<typeof decodeEntries>
75
95
 
76
- const decodeStored = Schema.decodeUnknownOption(AssistantEvent.StoredAppEvent)
77
-
78
96
  const toEntry = (entry: AssistantEvent.AnyEntry) => ({
79
97
  chatId: entry.chatId,
80
98
  seq: entry.seq,
81
99
  at: entry.at
82
100
  })
83
-
84
- /** @internal */
85
- export const read = (app: AppEvents, event: { readonly _tag: string }): string | undefined => {
86
- const registered = app.byTag.get(event._tag)
87
-
88
- if (registered?.render === undefined) return undefined
89
-
90
- if (registered.render !== "json") return registered.render(event)
91
-
92
- const stored = Schema.encodeUnknownOption(Schema.toCodecJson(registered.schema))(event).pipe(
93
- Option.flatMap(decodeStored)
94
- )
95
-
96
- return Option.match(stored, {
97
- onNone: () => undefined,
98
- onSome: ({ _tag, ...fields }) => `<${_tag}>\n${toJsonText(fields)}\n</${_tag}>`
99
- })
100
- }