@effect-uai/core 0.13.0 → 0.15.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 (116) hide show
  1. package/dist/{Audio-60lKKHxS.d.mts → Audio-_nGR4V0X.d.mts} +2 -2
  2. package/dist/{Audio-60lKKHxS.d.mts.map → Audio-_nGR4V0X.d.mts.map} +1 -1
  3. package/dist/Image-Cv6iBLTy.d.mts +99 -0
  4. package/dist/Image-Cv6iBLTy.d.mts.map +1 -0
  5. package/dist/{Items-SfUabNuB.d.mts → Items-C7MyrYPX.d.mts} +35 -4
  6. package/dist/Items-C7MyrYPX.d.mts.map +1 -0
  7. package/dist/Media-BbX7BAaO.d.mts +60 -0
  8. package/dist/Media-BbX7BAaO.d.mts.map +1 -0
  9. package/dist/MessengerError-CeK6VAcu.d.mts +64 -0
  10. package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
  11. package/dist/{Tool-DE-rnvkw.d.mts → Tool-BF78QWtn.d.mts} +2 -2
  12. package/dist/{Tool-DE-rnvkw.d.mts.map → Tool-BF78QWtn.d.mts.map} +1 -1
  13. package/dist/{ToolEvent-D99MGQYH.d.mts → ToolEvent-Di4a723B.d.mts} +2 -2
  14. package/dist/{ToolEvent-D99MGQYH.d.mts.map → ToolEvent-Di4a723B.d.mts.map} +1 -1
  15. package/dist/{ToolResult-Cj8ChodL.d.mts → ToolResult-BsYQRjmW.d.mts} +2 -2
  16. package/dist/{ToolResult-Cj8ChodL.d.mts.map → ToolResult-BsYQRjmW.d.mts.map} +1 -1
  17. package/dist/{Toolkit-BEZrNPTI.d.mts → Toolkit-DAjJ9Tvl.d.mts} +5 -5
  18. package/dist/{Toolkit-BEZrNPTI.d.mts.map → Toolkit-DAjJ9Tvl.d.mts.map} +1 -1
  19. package/dist/browser/BrowserTool.d.mts +1 -1
  20. package/dist/capabilities/Capabilities.d.mts +26 -2
  21. package/dist/capabilities/Capabilities.d.mts.map +1 -1
  22. package/dist/capabilities/Capabilities.mjs +32 -1
  23. package/dist/capabilities/Capabilities.mjs.map +1 -1
  24. package/dist/domain/Audio.d.mts +1 -1
  25. package/dist/domain/Image.d.mts +3 -2
  26. package/dist/domain/Image.mjs +29 -3
  27. package/dist/domain/Image.mjs.map +1 -1
  28. package/dist/domain/Items.d.mts +2 -2
  29. package/dist/domain/Items.mjs +19 -1
  30. package/dist/domain/Items.mjs.map +1 -1
  31. package/dist/domain/Media.d.mts +2 -2
  32. package/dist/domain/Media.mjs +51 -2
  33. package/dist/domain/Media.mjs.map +1 -1
  34. package/dist/domain/Music.d.mts +3 -8
  35. package/dist/domain/Music.d.mts.map +1 -1
  36. package/dist/domain/Music.mjs.map +1 -1
  37. package/dist/domain/Research.d.mts +1 -1
  38. package/dist/domain/Turn.d.mts +73 -9
  39. package/dist/domain/Turn.d.mts.map +1 -1
  40. package/dist/domain/Turn.mjs +38 -2
  41. package/dist/domain/Turn.mjs.map +1 -1
  42. package/dist/embedding-model/Embedding.d.mts +1 -1
  43. package/dist/image-generator/ImageGenerator.d.mts +188 -0
  44. package/dist/image-generator/ImageGenerator.d.mts.map +1 -0
  45. package/dist/image-generator/ImageGenerator.mjs +50 -0
  46. package/dist/image-generator/ImageGenerator.mjs.map +1 -0
  47. package/dist/inbox/Inbox.d.mts +16 -0
  48. package/dist/inbox/Inbox.d.mts.map +1 -0
  49. package/dist/inbox/Inbox.mjs +28 -0
  50. package/dist/inbox/Inbox.mjs.map +1 -0
  51. package/dist/inbox/Inbox.test.d.mts +1 -0
  52. package/dist/inbox/Inbox.test.mjs +42 -0
  53. package/dist/inbox/Inbox.test.mjs.map +1 -0
  54. package/dist/index.d.mts +15 -9
  55. package/dist/index.mjs +8 -2
  56. package/dist/language-model/LanguageModel.d.mts +2 -2
  57. package/dist/messenger/Messenger.d.mts +472 -0
  58. package/dist/messenger/Messenger.d.mts.map +1 -0
  59. package/dist/messenger/Messenger.mjs +66 -0
  60. package/dist/messenger/Messenger.mjs.map +1 -0
  61. package/dist/messenger/MessengerAdapter.d.mts +35 -0
  62. package/dist/messenger/MessengerAdapter.d.mts.map +1 -0
  63. package/dist/messenger/MessengerAdapter.mjs +102 -0
  64. package/dist/messenger/MessengerAdapter.mjs.map +1 -0
  65. package/dist/messenger/MessengerAdapter.test.d.mts +1 -0
  66. package/dist/messenger/MessengerAdapter.test.mjs +138 -0
  67. package/dist/messenger/MessengerAdapter.test.mjs.map +1 -0
  68. package/dist/messenger/MessengerError.d.mts +2 -0
  69. package/dist/messenger/MessengerError.mjs +39 -0
  70. package/dist/messenger/MessengerError.mjs.map +1 -0
  71. package/dist/music-generator/MusicGenerator.d.mts +3 -2
  72. package/dist/music-generator/MusicGenerator.d.mts.map +1 -1
  73. package/dist/observability/Metrics.d.mts +1 -1
  74. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
  75. package/dist/streaming/Multipart.d.mts +21 -0
  76. package/dist/streaming/Multipart.d.mts.map +1 -0
  77. package/dist/streaming/Multipart.mjs +26 -0
  78. package/dist/streaming/Multipart.mjs.map +1 -0
  79. package/dist/testing/MockMessenger.d.mts +192 -0
  80. package/dist/testing/MockMessenger.d.mts.map +1 -0
  81. package/dist/testing/MockMessenger.mjs +76 -0
  82. package/dist/testing/MockMessenger.mjs.map +1 -0
  83. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  84. package/dist/testing/MockProvider.d.mts +1 -1
  85. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  86. package/dist/tool/Approval.d.mts +3 -3
  87. package/dist/tool/HistoryCheck.d.mts +2 -2
  88. package/dist/tool/Tool.d.mts +1 -1
  89. package/dist/tool/ToolEvent.d.mts +1 -1
  90. package/dist/tool/ToolResult.d.mts +1 -1
  91. package/dist/tool/Toolkit.d.mts +1 -1
  92. package/dist/transcriber/Transcriber.d.mts +1 -1
  93. package/dist/web-read/WebReadTool.d.mts +1 -1
  94. package/dist/web-search/WebSearchTool.d.mts +1 -1
  95. package/package.json +29 -1
  96. package/src/capabilities/Capabilities.ts +38 -0
  97. package/src/domain/Image.ts +68 -2
  98. package/src/domain/Items.ts +18 -1
  99. package/src/domain/Media.ts +66 -0
  100. package/src/domain/Music.ts +4 -3
  101. package/src/domain/Turn.ts +56 -0
  102. package/src/image-generator/ImageGenerator.ts +165 -0
  103. package/src/inbox/Inbox.test.ts +52 -0
  104. package/src/inbox/Inbox.ts +33 -0
  105. package/src/index.ts +6 -0
  106. package/src/messenger/Messenger.ts +293 -0
  107. package/src/messenger/MessengerAdapter.test.ts +179 -0
  108. package/src/messenger/MessengerAdapter.ts +189 -0
  109. package/src/messenger/MessengerError.ts +67 -0
  110. package/src/streaming/Multipart.ts +29 -0
  111. package/src/testing/MockMessenger.ts +147 -0
  112. package/dist/Image-BK8-4H6s.d.mts +0 -59
  113. package/dist/Image-BK8-4H6s.d.mts.map +0 -1
  114. package/dist/Items-SfUabNuB.d.mts.map +0 -1
  115. package/dist/Media-DwgpifPD.d.mts +0 -57
  116. package/dist/Media-DwgpifPD.d.mts.map +0 -1
@@ -0,0 +1,179 @@
1
+ import { describe, it } from "@effect/vitest"
2
+ import { Array as Arr, Duration, Effect, Option, Result, Stream } from "effect"
3
+ import { TestClock } from "effect/testing"
4
+ import { expect } from "vitest"
5
+ import * as MockMessenger from "../testing/MockMessenger.js"
6
+ import {
7
+ ChannelId,
8
+ type ConversationRef,
9
+ MessageId,
10
+ type Outbound,
11
+ type StreamOptions,
12
+ inConversation,
13
+ } from "./Messenger.js"
14
+ import { type StreamViaEditsOptions, splitForLimit } from "./MessengerAdapter.js"
15
+ import { MessengerRateLimited } from "./MessengerError.js"
16
+
17
+ const here: ConversationRef = { channel: ChannelId("c1") }
18
+
19
+ const bodyOf = (msg: Outbound): string => (msg.body._tag === "Text" ? msg.body.text : "")
20
+
21
+ const posts = (calls: ReadonlyArray<MockMessenger.Call>) =>
22
+ Arr.filterMap(calls, (c) =>
23
+ c._tag === "Post" ? Result.succeed(bodyOf(c.message)) : Result.failVoid,
24
+ )
25
+
26
+ const edits = (calls: ReadonlyArray<MockMessenger.Call>) =>
27
+ Arr.filterMap(calls, (c) =>
28
+ c._tag === "Edit" ? Result.succeed(bodyOf(c.next)) : Result.failVoid,
29
+ )
30
+
31
+ /** Run `deltas` through the mock's `stream` and hand back the call log. */
32
+ const streamed = (
33
+ deltas: ReadonlyArray<string>,
34
+ script: MockMessenger.MockMessengerScript = {},
35
+ streamOptions?: StreamOptions,
36
+ ) =>
37
+ Effect.gen(function* () {
38
+ const { service, recorder } = MockMessenger.make(script)
39
+ const id = yield* service.stream(Stream.fromIterable(deltas), streamOptions)
40
+ const { calls } = yield* recorder
41
+ return { id, calls }
42
+ }).pipe(inConversation(here))
43
+
44
+ const options = (o: StreamViaEditsOptions): MockMessenger.MockMessengerScript => ({
45
+ streamOptions: o,
46
+ })
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // splitForLimit
50
+ // ---------------------------------------------------------------------------
51
+
52
+ describe("splitForLimit", () => {
53
+ it("prefers a paragraph break, then a line, then a word", () => {
54
+ expect(splitForLimit("aaa\n\nbbb ccc", 8)).toEqual(["aaa", "bbb ccc"])
55
+ expect(splitForLimit("aaa\nbbbb ccc", 8)).toEqual(["aaa", "bbbb ccc"])
56
+ expect(splitForLimit("aaaa bbbb ccc", 9)).toEqual(["aaaa", "bbbb ccc"])
57
+ })
58
+
59
+ it("hard-cuts a run with no boundary inside the limit", () => {
60
+ expect(splitForLimit("aaaaaaaaaa", 4)).toEqual(["aaaa", "aaaa", "aa"])
61
+ })
62
+ })
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // streamViaEdits
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe("streamViaEdits", () => {
69
+ // TestClock keeps wall time frozen, so the `every` gate never opens and
70
+ // every delta after the first coalesces into the final flush.
71
+ it.effect("coalesces deltas arriving inside the `every` window", () =>
72
+ Effect.gen(function* () {
73
+ const { calls } = yield* streamed(["Hel", "lo ", "the", "re!"])
74
+
75
+ expect(posts(calls)).toEqual(["Hel"])
76
+ expect(edits(calls)).toEqual(["Hello there!"])
77
+ }),
78
+ )
79
+
80
+ it.effect("waits for `minChars` of growth before spending an edit", () =>
81
+ Effect.gen(function* () {
82
+ const { calls } = yield* streamed(
83
+ ["aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"],
84
+ options({ every: 0, minChars: 10 }),
85
+ )
86
+
87
+ // Edits land at 16 chars (12 of growth) and again on the final flush.
88
+ expect(posts(calls)).toEqual(["aaaa"])
89
+ expect(edits(calls).map((t) => t.length)).toEqual([16, 24])
90
+ }),
91
+ )
92
+
93
+ it.effect("skips the final flush when the message already shows the text", () =>
94
+ Effect.gen(function* () {
95
+ const { calls } = yield* streamed(
96
+ ["aaaaa", "bbbbb", "ccccc"],
97
+ options({ every: 0, minChars: 10 }),
98
+ )
99
+
100
+ // The third delta opens the growth gate, so the tail is already sent.
101
+ expect(edits(calls)).toEqual(["aaaaabbbbbccccc"])
102
+ }),
103
+ )
104
+
105
+ it.effect("rolls over to a new message past maxText", () =>
106
+ Effect.gen(function* () {
107
+ const { id, calls } = yield* streamed(["aaaa bbbb cccc dddd"], {
108
+ limits: { maxText: 10, maxCaption: 10 },
109
+ })
110
+
111
+ // Cut on the word boundary, not mid-word, and the id is the last message.
112
+ expect(posts(calls)).toEqual(["aaaa bbbb", "cccc dddd"])
113
+ expect(edits(calls)).toEqual([])
114
+ expect(id).toEqual(Option.some(MessageId("m2")))
115
+ }),
116
+ )
117
+
118
+ it.effect("quotes the question on the first message only, never on a rollover", () =>
119
+ Effect.gen(function* () {
120
+ const { calls } = yield* streamed(
121
+ ["aaaa bbbb cccc dddd"],
122
+ { limits: { maxText: 10, maxCaption: 10 } },
123
+ { replyTo: MessageId("q1") },
124
+ )
125
+
126
+ const replies = Arr.filterMap(calls, (c) =>
127
+ c._tag === "Post" ? Result.succeed(c.message.replyTo) : Result.failVoid,
128
+ )
129
+ expect(replies).toEqual([MessageId("q1"), undefined])
130
+ }),
131
+ )
132
+
133
+ it.effect("posts nothing for a stream with no text", () =>
134
+ Effect.gen(function* () {
135
+ const { id, calls } = yield* streamed(["", ""])
136
+
137
+ expect(calls).toEqual([])
138
+ expect(id).toEqual(Option.none())
139
+ }),
140
+ )
141
+
142
+ // A model opening a tool-calling turn with a newline, which every platform
143
+ // rejects as an empty message.
144
+ it.effect("treats whitespace-only deltas as no text at all", () =>
145
+ Effect.gen(function* () {
146
+ const { id, calls } = yield* streamed(["\n", " ", "\n\n"])
147
+
148
+ expect(calls).toEqual([])
149
+ expect(id).toEqual(Option.none())
150
+ }),
151
+ )
152
+
153
+ it.live("waits out a rate limit and re-sends the same edit", () =>
154
+ Effect.gen(function* () {
155
+ const limited = new MessengerRateLimited({
156
+ provider: "mock",
157
+ retryAfter: Duration.millis(10),
158
+ })
159
+ const { calls } = yield* streamed(["abc", "defghij"], {
160
+ ...options({ every: 0, minChars: 1 }),
161
+ failures: [undefined, limited],
162
+ })
163
+
164
+ expect(posts(calls)).toEqual(["abc"])
165
+ expect(edits(calls)).toEqual(["abcdefghij", "abcdefghij"])
166
+ }),
167
+ )
168
+
169
+ it.effect("gives up once the rate-limit budget is spent", () =>
170
+ Effect.gen(function* () {
171
+ const limited = new MessengerRateLimited({ provider: "mock", retryAfter: Duration.zero })
172
+ const failed = yield* Effect.flip(
173
+ streamed(["abc"], { ...options({ rateLimitRetries: 1 }), failures: [limited, limited] }),
174
+ )
175
+
176
+ expect(failed._tag).toBe("MessengerRateLimited")
177
+ }).pipe(TestClock.withLive),
178
+ )
179
+ })
@@ -0,0 +1,189 @@
1
+ /**
2
+ * What an adapter author needs and a recipe author never sees: text
3
+ * splitting at a platform ceiling, and post-then-edit progressive delivery
4
+ * for platforms without a native streaming API.
5
+ */
6
+ import { Array as Arr, Clock, Data, Duration, Effect, Option, Schedule, Stream } from "effect"
7
+ import {
8
+ CurrentConversation,
9
+ type MessageId,
10
+ type MessengerService,
11
+ type StreamOptions,
12
+ text,
13
+ } from "./Messenger.js"
14
+ import type * as MessengerError from "./MessengerError.js"
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Text splitting
18
+ // ---------------------------------------------------------------------------
19
+
20
+ // Paragraph break first, then line break, then space.
21
+ const boundaries = ["\n\n", "\n", " "] as const
22
+
23
+ /** Best split at or before `limit`; a hard cut only for an unbroken run. */
24
+ const cutAt = (body: string, limit: number): readonly [head: string, rest: string] =>
25
+ body.length <= limit
26
+ ? [body, ""]
27
+ : Arr.findFirst(boundaries, (sep) =>
28
+ Option.map(
29
+ Option.liftPredicate(body.lastIndexOf(sep, limit - sep.length), (at) => at > 0),
30
+ (at) => [body.slice(0, at), body.slice(at + sep.length)] as const,
31
+ ),
32
+ ).pipe(Option.getOrElse(() => [body.slice(0, limit), body.slice(limit)] as const))
33
+
34
+ /**
35
+ * Chunk `body` into pieces no longer than `limit`, breaking on paragraph,
36
+ * line, then word boundaries. Always yields at least one chunk, so callers
37
+ * have something to send even for empty input.
38
+ */
39
+ export const splitForLimit = (body: string, limit: number): Arr.NonEmptyReadonlyArray<string> => {
40
+ const [head, rest] = cutAt(body, limit)
41
+ return rest.length === 0 ? [head] : [head, ...splitForLimit(rest, limit)]
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // streamViaEdits
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** The slice of a messenger that {@link streamViaEdits} drives. */
49
+ export type EditableVerbs = Pick<MessengerService, "post" | "edit" | "limits">
50
+
51
+ export type StreamViaEditsOptions = {
52
+ /** Minimum wall time between edits. Default 1 second. */
53
+ readonly every?: Duration.Input
54
+ /** Minimum growth since the last edit before spending another. Default 40. */
55
+ readonly minChars?: number
56
+ /** How often to honour a `MessengerRateLimited` before giving up. Default 3. */
57
+ readonly rateLimitRetries?: number
58
+ }
59
+
60
+ const isRateLimited = (e: MessengerError.MessengerError): boolean =>
61
+ e._tag === "MessengerRateLimited"
62
+
63
+ // A retry schedule's input is the error, so the delay is the platform's own
64
+ // `retry_after` rather than a guess. Bounded by `recurs`, so an unlucky call
65
+ // eventually surfaces the rate limit instead of stalling behind it.
66
+ const honourRetryAfter = (
67
+ times: number,
68
+ ): Schedule.Schedule<number, MessengerError.MessengerError> =>
69
+ Schedule.recurs(times).pipe(
70
+ Schedule.setInputType<MessengerError.MessengerError>(),
71
+ Schedule.modifyDelay(({ input }) =>
72
+ Effect.succeed(input._tag === "MessengerRateLimited" ? input.retryAfter : Duration.zero),
73
+ ),
74
+ )
75
+
76
+ /**
77
+ * The message being filled: not on the platform yet, or posted and edited
78
+ * since. Only the first draft carries the reply; rollover drafts do not.
79
+ */
80
+ type Progress = Data.TaggedEnum<{
81
+ Draft: { readonly pending: string; readonly envelope: StreamOptions }
82
+ Sent: {
83
+ readonly id: MessageId
84
+ /** Text the message shows now. Never resent unchanged. */
85
+ readonly sent: string
86
+ /** Text it should show, deltas included. */
87
+ readonly pending: string
88
+ readonly lastFlush: number
89
+ }
90
+ }>
91
+
92
+ const Progress = Data.taggedEnum<Progress>()
93
+
94
+ type Sent = Data.TaggedEnum.Value<Progress, "Sent">
95
+
96
+ const appended = (s: Progress, delta: string): Progress => ({
97
+ ...s,
98
+ pending: s.pending + delta,
99
+ })
100
+
101
+ /**
102
+ * Progressive delivery as post-then-edit, for adapters with no native
103
+ * streaming API. Coalesces deltas by time and growth, never resends
104
+ * unchanged text, waits out `MessengerRateLimited`, rolls over to a fresh
105
+ * message past `limits.maxText`, and always flushes the tail.
106
+ *
107
+ * The first chunk goes out as soon as any text arrives, so the message shows
108
+ * up immediately and fills in from there. A stream that produced no text
109
+ * posts nothing and yields `None`.
110
+ */
111
+ export const streamViaEdits =
112
+ (verbs: EditableVerbs, options?: StreamViaEditsOptions): MessengerService["stream"] =>
113
+ (deltas, streamOptions = {}) =>
114
+ Effect.gen(function* () {
115
+ const every = Duration.toMillis(options?.every ?? "1 second")
116
+ const minChars = options?.minChars ?? 40
117
+ const schedule = honourRetryAfter(options?.rateLimitRetries ?? 3)
118
+ const conversation = yield* CurrentConversation
119
+
120
+ const patiently = <A, R2>(
121
+ effect: Effect.Effect<A, MessengerError.MessengerError, R2>,
122
+ ): Effect.Effect<A, MessengerError.MessengerError, R2> =>
123
+ Effect.retry(effect, { schedule, while: isRateLimited })
124
+
125
+ // Post a draft, edit a sent message, and skip an edit that would resend
126
+ // what the message already shows.
127
+ const deliver = (
128
+ s: Progress,
129
+ body: string,
130
+ now: number,
131
+ ): Effect.Effect<Sent, MessengerError.MessengerError, CurrentConversation> =>
132
+ Progress.$match(s, {
133
+ Draft: ({ pending, envelope }) =>
134
+ patiently(verbs.post(text(body, envelope))).pipe(
135
+ Effect.map((id) => Progress.Sent({ id, sent: body, pending, lastFlush: now })),
136
+ ),
137
+ Sent: (sent) =>
138
+ body === sent.sent
139
+ ? Effect.succeed(sent)
140
+ : patiently(verbs.edit({ conversation, id: sent.id }, text(body))).pipe(
141
+ Effect.as(Progress.Sent({ ...sent, sent: body, lastFlush: now })),
142
+ ),
143
+ })
144
+
145
+ // Anything past the ceiling becomes the next message: finish the current
146
+ // one at a clean boundary, then start a draft on the remainder.
147
+ const rollover = (
148
+ s: Progress,
149
+ now: number,
150
+ ): Effect.Effect<Progress, MessengerError.MessengerError, CurrentConversation> => {
151
+ const [head, rest] = cutAt(s.pending, verbs.limits.maxText)
152
+ return rest.length === 0
153
+ ? Effect.succeed(s)
154
+ : deliver(s, head, now).pipe(
155
+ Effect.flatMap(() => rollover(Progress.Draft({ pending: rest, envelope: {} }), now)),
156
+ )
157
+ }
158
+
159
+ // A draft lands on its first real text; a sent message waits for both
160
+ // gates. Blank is not text: a model that opens a tool-calling turn with
161
+ // a newline would otherwise post it, and platforms reject a message
162
+ // that is only whitespace.
163
+ const due = (s: Progress, now: number): boolean =>
164
+ Progress.$match(s, {
165
+ Draft: ({ pending }) => pending.trim().length > 0,
166
+ Sent: ({ sent, pending, lastFlush }) =>
167
+ now - lastFlush >= every && pending.length - sent.length >= minChars,
168
+ })
169
+
170
+ const step = (before: Progress, delta: string) =>
171
+ Effect.gen(function* () {
172
+ const now = yield* Clock.currentTimeMillis
173
+ const s = yield* rollover(appended(before, delta), now)
174
+ if (!due(s, now)) return s
175
+ return yield* deliver(s, s.pending, now)
176
+ })
177
+
178
+ const folded = yield* Stream.runFoldEffect(
179
+ deltas,
180
+ () => Progress.Draft({ pending: "", envelope: streamOptions }),
181
+ step,
182
+ )
183
+ const now = yield* Clock.currentTimeMillis
184
+ const s = yield* rollover(folded, now)
185
+ // The tail always lands; a draft that never held text is nothing to send.
186
+ if (s._tag === "Draft" && s.pending.trim().length === 0) return Option.none()
187
+ const final = yield* deliver(s, s.pending, now)
188
+ return Option.some(final.id)
189
+ })
@@ -0,0 +1,67 @@
1
+ import { Data, type Duration, Match } from "effect"
2
+
3
+ // `reason` is required wherever one exists: the platform always says
4
+ // something, and an adapter raising `Unsupported` knows why it did.
5
+
6
+ /** Transport never came up: bad token, rejected handshake. Raised at layer build. */
7
+ export class MessengerConnectFailed extends Data.TaggedError("MessengerConnectFailed")<{
8
+ provider: string
9
+ reason: string
10
+ raw: unknown
11
+ }> {}
12
+
13
+ /**
14
+ * Inbound transport ended for good (reconnect budget spent, fatal close).
15
+ * Routine reconnects stay inside the adapter and never surface here.
16
+ */
17
+ export class MessengerTransportClosed extends Data.TaggedError("MessengerTransportClosed")<{
18
+ provider: string
19
+ reason: string
20
+ raw?: unknown
21
+ }> {}
22
+
23
+ /** Which verb was rejected. */
24
+ export type MessengerOperation = "post" | "edit" | "react" | "typing"
25
+
26
+ /** An outbound verb was rejected: permission, unknown chat, text too long, no-op edit. */
27
+ export class MessengerRequestFailed extends Data.TaggedError("MessengerRequestFailed")<{
28
+ provider: string
29
+ operation: MessengerOperation
30
+ reason: string
31
+ raw: unknown
32
+ }> {}
33
+
34
+ /**
35
+ * Slow down, for this long. Typed rather than retried blindly so callers -
36
+ * `streamViaEdits` above all - decide whether waiting is still worth it.
37
+ */
38
+ export class MessengerRateLimited extends Data.TaggedError("MessengerRateLimited")<{
39
+ provider: string
40
+ retryAfter: Duration.Duration
41
+ raw?: unknown
42
+ }> {}
43
+
44
+ /** The platform cannot express this at all: off-set reaction emoji, edits on WhatsApp. */
45
+ export class MessengerUnsupported extends Data.TaggedError("MessengerUnsupported")<{
46
+ provider: string
47
+ capability: string
48
+ reason: string
49
+ }> {}
50
+
51
+ export type MessengerError =
52
+ | MessengerConnectFailed
53
+ | MessengerTransportClosed
54
+ | MessengerRequestFailed
55
+ | MessengerRateLimited
56
+ | MessengerUnsupported
57
+
58
+ /** Human-readable description for logs. Prose, not a contract; branch on `_tag`. */
59
+ export const describe: (e: MessengerError) => string = Match.type<MessengerError>().pipe(
60
+ Match.discriminatorsExhaustive("_tag")({
61
+ MessengerConnectFailed: (e) => `the messenger could not connect: ${e.reason}`,
62
+ MessengerTransportClosed: (e) => `the messenger connection closed: ${e.reason}`,
63
+ MessengerRequestFailed: (e) => `the ${e.operation} was rejected: ${e.reason}`,
64
+ MessengerRateLimited: () => "the messenger is rate limited",
65
+ MessengerUnsupported: (e) => `this messenger does not support ${e.capability}: ${e.reason}`,
66
+ }),
67
+ )
@@ -0,0 +1,29 @@
1
+ import { Effect } from "effect"
2
+ import { HttpClientRequest } from "effect/unstable/http"
3
+
4
+ /**
5
+ * Multipart body for an `HttpClientRequest`, encoded to bytes.
6
+ *
7
+ * Use this over `HttpClientRequest.bodyFormData`: the Undici client
8
+ * passes the `FormData` object straight to `dispatcher.request`, which
9
+ * cannot serialise it, so the request is never sent and the effect
10
+ * hangs with no error. Encoding here produces the same bytes every
11
+ * other client derives internally, so it works on all of them.
12
+ *
13
+ * The failure is whatever `Response.arrayBuffer` rejects with; map it
14
+ * to your provider's transport error at the call site.
15
+ */
16
+ export const bodyMultipart = (
17
+ form: FormData,
18
+ ): Effect.Effect<
19
+ (request: HttpClientRequest.HttpClientRequest) => HttpClientRequest.HttpClientRequest,
20
+ unknown
21
+ > =>
22
+ Effect.gen(function* () {
23
+ const encoded = new Response(form)
24
+ const buffer = yield* Effect.tryPromise(() => encoded.arrayBuffer())
25
+ return HttpClientRequest.bodyUint8Array(
26
+ new Uint8Array(buffer),
27
+ encoded.headers.get("content-type") ?? "multipart/form-data",
28
+ )
29
+ })
@@ -0,0 +1,147 @@
1
+ import { Array as Arr, Data, Effect, Layer, Option, Ref, Stream } from "effect"
2
+ import {
3
+ type ConversationRef,
4
+ CurrentConversation,
5
+ type InboundEvent,
6
+ MessageId,
7
+ type MessageRef,
8
+ Messenger,
9
+ type MessengerLimits,
10
+ type MessengerService,
11
+ type Outbound,
12
+ } from "../messenger/Messenger.js"
13
+ import {
14
+ type StreamViaEditsOptions,
15
+ splitForLimit,
16
+ streamViaEdits,
17
+ } from "../messenger/MessengerAdapter.js"
18
+ import * as MessengerError from "../messenger/MessengerError.js"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Call log
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * One entry per outbound call. `Post` is logged once even when the text was
26
+ * long enough to split, so a caller that rolled over on its own is
27
+ * distinguishable from one that leaned on `post` to do it.
28
+ */
29
+ export type Call = Data.TaggedEnum<{
30
+ Post: { readonly conversation: ConversationRef; readonly message: Outbound }
31
+ Edit: { readonly message: MessageRef; readonly next: Outbound }
32
+ React: { readonly message: MessageRef; readonly emoji: string }
33
+ TypingStart: { readonly conversation: ConversationRef }
34
+ TypingStop: { readonly conversation: ConversationRef }
35
+ }>
36
+
37
+ export const Call = Data.taggedEnum<Call>()
38
+
39
+ export type MockMessengerRecorder = {
40
+ readonly calls: ReadonlyArray<Call>
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Script
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export type MockMessengerScript = {
48
+ /** Inbound events to replay. Empty by default. */
49
+ readonly events?: Stream.Stream<InboundEvent, MessengerError.MessengerError>
50
+ readonly limits?: MessengerLimits
51
+ /**
52
+ * Failures handed back one per `post` / `edit` call, in order; `undefined`
53
+ * entries succeed, and everything past the end succeeds.
54
+ */
55
+ readonly failures?: ReadonlyArray<MessengerError.MessengerError | undefined>
56
+ /** Passed through to `streamViaEdits`, which backs `stream`. */
57
+ readonly streamOptions?: StreamViaEditsOptions
58
+ }
59
+
60
+ const defaultLimits: MessengerLimits = { maxText: 4096, maxCaption: 1024 }
61
+
62
+ const bodyText = (msg: Outbound): Option.Option<string> =>
63
+ msg.body._tag === "Text" ? Option.some(msg.body.text) : Option.none()
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Service builder
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const buildService = (
70
+ script: MockMessengerScript,
71
+ record: (call: Call) => Effect.Effect<void>,
72
+ cursor: Ref.Ref<number>,
73
+ minted: Ref.Ref<number>,
74
+ ): MessengerService => {
75
+ const limits = script.limits ?? defaultLimits
76
+
77
+ const nextFailure = Ref.getAndUpdate(cursor, (n) => n + 1).pipe(
78
+ Effect.flatMap((i) =>
79
+ Option.match(Option.fromNullishOr((script.failures ?? [])[i]), {
80
+ onNone: () => Effect.void,
81
+ onSome: Effect.fail<MessengerError.MessengerError>,
82
+ }),
83
+ ),
84
+ )
85
+
86
+ const mint = Ref.updateAndGet(minted, (n) => n + 1).pipe(Effect.map((n) => MessageId(`m${n}`)))
87
+
88
+ const post: MessengerService["post"] = (msg) =>
89
+ Effect.gen(function* () {
90
+ const conversation = yield* CurrentConversation
91
+ yield* record(Call.Post({ conversation, message: msg }))
92
+ yield* nextFailure
93
+ // One id per chunk, mirroring an adapter that splits at the ceiling.
94
+ const chunks = Option.match(bodyText(msg), {
95
+ onNone: () => 1,
96
+ onSome: (t) => splitForLimit(t, limits.maxText).length,
97
+ })
98
+ return yield* Effect.map(
99
+ Effect.forEach(Arr.range(1, chunks), () => mint),
100
+ Arr.lastNonEmpty,
101
+ )
102
+ })
103
+
104
+ const edit: MessengerService["edit"] = (msg, next) =>
105
+ record(Call.Edit({ message: msg, next })).pipe(Effect.andThen(nextFailure))
106
+
107
+ return {
108
+ events: script.events ?? Stream.empty,
109
+ post,
110
+ edit,
111
+ react: (msg, emoji) => record(Call.React({ message: msg, emoji })),
112
+ typing: Effect.gen(function* () {
113
+ const conversation = yield* CurrentConversation
114
+ yield* record(Call.TypingStart({ conversation }))
115
+ yield* Effect.addFinalizer(() => record(Call.TypingStop({ conversation })))
116
+ }),
117
+ stream: streamViaEdits({ post, edit, limits }, script.streamOptions),
118
+ limits,
119
+ }
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Public API
124
+ // ---------------------------------------------------------------------------
125
+
126
+ const makeUnsafe = (script: MockMessengerScript) => {
127
+ const calls = Ref.makeUnsafe<ReadonlyArray<Call>>([])
128
+ const service = buildService(
129
+ script,
130
+ (call) => Ref.update(calls, Arr.append(call)),
131
+ Ref.makeUnsafe(0),
132
+ Ref.makeUnsafe(0),
133
+ )
134
+ return {
135
+ service,
136
+ recorder: Ref.get(calls).pipe(Effect.map((c): MockMessengerRecorder => ({ calls: c }))),
137
+ }
138
+ }
139
+
140
+ /** Layer registering the mock against the `Messenger` tag, plus its recorder. */
141
+ export const layer = (script: MockMessengerScript = {}) => {
142
+ const { service, recorder } = makeUnsafe(script)
143
+ return { layer: Layer.succeed(Messenger, service), recorder }
144
+ }
145
+
146
+ /** Bare service value + recorder. Use with `Effect.provideService`. */
147
+ export const make = (script: MockMessengerScript = {}) => makeUnsafe(script)
@@ -1,59 +0,0 @@
1
- import { i as MediaUrl, n as MediaBytes, r as MediaSource, t as MediaBase64 } from "./Media-DwgpifPD.mjs";
2
- import { Schema } from "effect";
3
- declare namespace Image_d_exports {
4
- export { ImageBase64Source, ImageBytesSource, ImageMimeType, ImageSource, ImageUrlSource, imageBase64, imageBytes, imageUrl, isImageBase64, isImageBytes, isImageUrl };
5
- }
6
- /**
7
- * Image MIME types AI providers typically accept. The first four are the
8
- * universal subset (Cohere v4, Voyage multimodal, Jina v4, Google
9
- * `gemini-embedding-2`); HEIC / HEIF are Google-specific. The
10
- * `(string & {})` tail keeps autocomplete on the literals while still
11
- * accepting any string, so a newly-supported format works without an
12
- * SDK update.
13
- */
14
- type ImageMimeType = "image/png" | "image/jpeg" | "image/webp" | "image/gif" | "image/heic" | "image/heif" | (string & {});
15
- type ImageUrlSource = MediaUrl<ImageMimeType>;
16
- type ImageBase64Source = MediaBase64<ImageMimeType>;
17
- type ImageBytesSource = MediaBytes<ImageMimeType>;
18
- /**
19
- * Where an image lives. Provider layers normalize across these:
20
- * `bytes` becomes a base64 data URI for OpenAI / Anthropic, an
21
- * `inlineData` part for Gemini, and a separate field for Cohere /
22
- * Voyage. URL constraints (must be HTTPS, must be public, …) are
23
- * provider-specific and validated at the layer, not in the type.
24
- */
25
- type ImageSource = MediaSource<ImageMimeType>;
26
- declare const ImageUrlSource: Schema.TaggedStruct<"url", {
27
- readonly url: Schema.String;
28
- readonly mimeType: Schema.optional<Schema.Schema<ImageMimeType>>;
29
- }>;
30
- declare const ImageBase64Source: Schema.TaggedStruct<"base64", {
31
- readonly base64: Schema.String;
32
- readonly mimeType: Schema.Schema<ImageMimeType>;
33
- }>;
34
- declare const ImageBytesSource: Schema.TaggedStruct<"bytes", {
35
- readonly bytes: Schema.Uint8Array;
36
- readonly mimeType: Schema.Schema<ImageMimeType>;
37
- }>;
38
- declare const ImageSource: Schema.Schema<ImageSource>;
39
- declare const imageUrl: (url: string, mimeType?: ImageMimeType) => ImageUrlSource;
40
- declare const imageBase64: (base64: string, mimeType: ImageMimeType) => ImageBase64Source;
41
- declare const imageBytes: (bytes: Uint8Array, mimeType: ImageMimeType) => ImageBytesSource;
42
- declare const isImageUrl: <I>(input: I) => input is I & {
43
- readonly _tag: "url";
44
- readonly url: string;
45
- readonly mimeType?: ImageMimeType | undefined;
46
- };
47
- declare const isImageBase64: <I>(input: I) => input is I & {
48
- readonly _tag: "base64";
49
- readonly base64: string;
50
- readonly mimeType: ImageMimeType;
51
- };
52
- declare const isImageBytes: <I>(input: I) => input is I & {
53
- readonly _tag: "bytes";
54
- readonly bytes: Uint8Array<ArrayBufferLike>;
55
- readonly mimeType: ImageMimeType;
56
- };
57
- //#endregion
58
- export { ImageUrlSource as a, imageBytes as c, isImageBytes as d, isImageUrl as f, ImageSource as i, imageUrl as l, ImageBytesSource as n, Image_d_exports as o, ImageMimeType as r, imageBase64 as s, ImageBase64Source as t, isImageBase64 as u };
59
- //# sourceMappingURL=Image-BK8-4H6s.d.mts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Image-BK8-4H6s.d.mts","names":[],"sources":["../src/domain/Image.ts"],"mappings":";;;;;;;;;;;;;KAWY;KAYA,iBAAiB,SAAS;KAC1B,oBAAoB,YAAY;KAChC,mBAAmB,WAAW;;;;;;;;KAS9B,cAAc,YAAY;cAEzB,gBAAc,OAAA;;;;cAKd,mBAAiB,OAAA;;;;cAKjB,kBAAgB,OAAA;;;;cAKhB,aAAa,OAAO,OAAO;cAM3B,WAAQ,aAAe,WAAa,kBAAgB;cAGpD,cAAW,gBAAkB,UAAY,kBAAgB;cAMzD,aAAU,OAAW,YAAU,UAAY,kBAAgB;cAM3D,aAAU,GAAA,OAAA,MAAA,SAAA;;;;;cACV,gBAAa,GAAA,OAAA,MAAA,SAAA;;;;;cACb,eAAY,GAAA,OAAA,MAAA,SAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"Items-SfUabNuB.d.mts","names":[],"sources":["../src/domain/Items.ts"],"mappings":";;;;;cAOa,WAAS,OAAA;;;;KAIV,mBAAmB,UAAU;;;;;;;;;cAU5B,YAAU,OAAA;;;;KAIX,oBAAoB,WAAW;;;;;;;;;cAgB9B,aAAW,OAAA;;;;;;;;;KASZ,qBAAqB,YAAY;cAEhC,cAAY,OAAA;;;;;KAKb,sBAAsB,aAAa;cAElC,uBAAqB,OAAA;;;;;;;KAOtB,+BAA+B,sBAAsB;cAEpD,UAAQ,OAAA;;;;;KAKT,kBAAkB,SAAS;cAE1B,YAAU,OAAA,gBAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;KACX,oBAAoB,WAAW;cAE9B,gBAAa,GAAA,OAAA,MAAA,SAAA;;;;;;;;;cACb,iBAAc,GAAA,OAAA,MAAA,SAAA;;;;;cACd,0BAAuB,GAAA,OAAA,MAAA,SAAA;;;;;;;cACvB,aAAU,GAAA,OAAA,MAAA,SAAA;;;;;cAEV,YAAU,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;KAKX,oBAAoB,WAAW;;;;;;;cAQ9B,SAAO,OAAA;;;;KAIR,iBAAiB,QAAQ;cAExB,cAAY,OAAA,gBAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KACb,sBAAsB,aAAa;cAElC,MAAI,OAAA;KACL,cAAc,KAAK;cAqBlB,SAAO,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAMR,iBAAiB,QAAQ;cAExB,UAAQ,OAAA;;;;;;;KAQT,kBAAkB,SAAS;cAE1B,gBAAc,OAAA;;;;;;KAMf,wBAAwB,eAAe;;;;;;;cAQtC,WAAS,OAAA;;;;;;;KAOV,mBAAmB,UAAU;cAE5B,aAAW,OAAA,gBAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KACZ,qBAAqB,YAAY;cAMhC,cAAW,GAAA,OAAA,MAAA,SAAA;;;;cACX,eAAY,GAAA,OAAA,MAAA,SAAA;;;;cACZ,eAAY,GAAA,OAAA,MAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;cACZ,YAAS,GAAA,OAAA,MAAA,SAAA;;;;cAET,YAAS,GAAA,OAAA,MAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cACT,aAAU,GAAA,OAAA,MAAA,SAAA;;;;;;;cACV,mBAAgB,GAAA,OAAA,MAAA,SAAA;;;;;;cAChB,cAAW,GAAA,OAAA,MAAA,SAAA;;;;;;;;;;;;;;;;;;cAiBX,oBAAkB,OAAA;;;;KAInB,4BAA4B,mBAAmB;cAE9C,qBAAmB,OAAA;;;KAGpB,6BAA6B,oBAAoB;cAEhD,OAAK,OAAA;;;;;;;;;;;;KAON,eAAe,MAAM;cAEpB,YAAU,OAAA;KAUX,oBAAoB,WAAW;cAM9B,WAAQ,iBAAmB;cAM3B,aAAU,iBAAmB;cAM7B,gBAAa,iBAAmB;cAMhC,iBAAc,iBAAmB,mBAAmB"}