@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.
- package/dist/{Audio-60lKKHxS.d.mts → Audio-_nGR4V0X.d.mts} +2 -2
- package/dist/{Audio-60lKKHxS.d.mts.map → Audio-_nGR4V0X.d.mts.map} +1 -1
- package/dist/Image-Cv6iBLTy.d.mts +99 -0
- package/dist/Image-Cv6iBLTy.d.mts.map +1 -0
- package/dist/{Items-SfUabNuB.d.mts → Items-C7MyrYPX.d.mts} +35 -4
- package/dist/Items-C7MyrYPX.d.mts.map +1 -0
- package/dist/Media-BbX7BAaO.d.mts +60 -0
- package/dist/Media-BbX7BAaO.d.mts.map +1 -0
- package/dist/MessengerError-CeK6VAcu.d.mts +64 -0
- package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
- package/dist/{Tool-DE-rnvkw.d.mts → Tool-BF78QWtn.d.mts} +2 -2
- package/dist/{Tool-DE-rnvkw.d.mts.map → Tool-BF78QWtn.d.mts.map} +1 -1
- package/dist/{ToolEvent-D99MGQYH.d.mts → ToolEvent-Di4a723B.d.mts} +2 -2
- package/dist/{ToolEvent-D99MGQYH.d.mts.map → ToolEvent-Di4a723B.d.mts.map} +1 -1
- package/dist/{ToolResult-Cj8ChodL.d.mts → ToolResult-BsYQRjmW.d.mts} +2 -2
- package/dist/{ToolResult-Cj8ChodL.d.mts.map → ToolResult-BsYQRjmW.d.mts.map} +1 -1
- package/dist/{Toolkit-BEZrNPTI.d.mts → Toolkit-DAjJ9Tvl.d.mts} +5 -5
- package/dist/{Toolkit-BEZrNPTI.d.mts.map → Toolkit-DAjJ9Tvl.d.mts.map} +1 -1
- package/dist/browser/BrowserTool.d.mts +1 -1
- package/dist/capabilities/Capabilities.d.mts +26 -2
- package/dist/capabilities/Capabilities.d.mts.map +1 -1
- package/dist/capabilities/Capabilities.mjs +32 -1
- package/dist/capabilities/Capabilities.mjs.map +1 -1
- package/dist/domain/Audio.d.mts +1 -1
- package/dist/domain/Image.d.mts +3 -2
- package/dist/domain/Image.mjs +29 -3
- package/dist/domain/Image.mjs.map +1 -1
- package/dist/domain/Items.d.mts +2 -2
- package/dist/domain/Items.mjs +19 -1
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Media.d.mts +2 -2
- package/dist/domain/Media.mjs +51 -2
- package/dist/domain/Media.mjs.map +1 -1
- package/dist/domain/Music.d.mts +3 -8
- package/dist/domain/Music.d.mts.map +1 -1
- package/dist/domain/Music.mjs.map +1 -1
- package/dist/domain/Research.d.mts +1 -1
- package/dist/domain/Turn.d.mts +73 -9
- package/dist/domain/Turn.d.mts.map +1 -1
- package/dist/domain/Turn.mjs +38 -2
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/embedding-model/Embedding.d.mts +1 -1
- package/dist/image-generator/ImageGenerator.d.mts +188 -0
- package/dist/image-generator/ImageGenerator.d.mts.map +1 -0
- package/dist/image-generator/ImageGenerator.mjs +50 -0
- package/dist/image-generator/ImageGenerator.mjs.map +1 -0
- package/dist/inbox/Inbox.d.mts +16 -0
- package/dist/inbox/Inbox.d.mts.map +1 -0
- package/dist/inbox/Inbox.mjs +28 -0
- package/dist/inbox/Inbox.mjs.map +1 -0
- package/dist/inbox/Inbox.test.d.mts +1 -0
- package/dist/inbox/Inbox.test.mjs +42 -0
- package/dist/inbox/Inbox.test.mjs.map +1 -0
- package/dist/index.d.mts +15 -9
- package/dist/index.mjs +8 -2
- package/dist/language-model/LanguageModel.d.mts +2 -2
- package/dist/messenger/Messenger.d.mts +472 -0
- package/dist/messenger/Messenger.d.mts.map +1 -0
- package/dist/messenger/Messenger.mjs +66 -0
- package/dist/messenger/Messenger.mjs.map +1 -0
- package/dist/messenger/MessengerAdapter.d.mts +35 -0
- package/dist/messenger/MessengerAdapter.d.mts.map +1 -0
- package/dist/messenger/MessengerAdapter.mjs +102 -0
- package/dist/messenger/MessengerAdapter.mjs.map +1 -0
- package/dist/messenger/MessengerAdapter.test.d.mts +1 -0
- package/dist/messenger/MessengerAdapter.test.mjs +138 -0
- package/dist/messenger/MessengerAdapter.test.mjs.map +1 -0
- package/dist/messenger/MessengerError.d.mts +2 -0
- package/dist/messenger/MessengerError.mjs +39 -0
- package/dist/messenger/MessengerError.mjs.map +1 -0
- package/dist/music-generator/MusicGenerator.d.mts +3 -2
- package/dist/music-generator/MusicGenerator.d.mts.map +1 -1
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/Multipart.d.mts +21 -0
- package/dist/streaming/Multipart.d.mts.map +1 -0
- package/dist/streaming/Multipart.mjs +26 -0
- package/dist/streaming/Multipart.mjs.map +1 -0
- package/dist/testing/MockMessenger.d.mts +192 -0
- package/dist/testing/MockMessenger.d.mts.map +1 -0
- package/dist/testing/MockMessenger.mjs +76 -0
- package/dist/testing/MockMessenger.mjs.map +1 -0
- package/dist/testing/MockMusicGenerator.d.mts +1 -1
- package/dist/testing/MockProvider.d.mts +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
- package/dist/tool/Approval.d.mts +3 -3
- package/dist/tool/HistoryCheck.d.mts +2 -2
- package/dist/tool/Tool.d.mts +1 -1
- package/dist/tool/ToolEvent.d.mts +1 -1
- package/dist/tool/ToolResult.d.mts +1 -1
- package/dist/tool/Toolkit.d.mts +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/dist/web-read/WebReadTool.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +1 -1
- package/package.json +29 -1
- package/src/capabilities/Capabilities.ts +38 -0
- package/src/domain/Image.ts +68 -2
- package/src/domain/Items.ts +18 -1
- package/src/domain/Media.ts +66 -0
- package/src/domain/Music.ts +4 -3
- package/src/domain/Turn.ts +56 -0
- package/src/image-generator/ImageGenerator.ts +165 -0
- package/src/inbox/Inbox.test.ts +52 -0
- package/src/inbox/Inbox.ts +33 -0
- package/src/index.ts +6 -0
- package/src/messenger/Messenger.ts +293 -0
- package/src/messenger/MessengerAdapter.test.ts +179 -0
- package/src/messenger/MessengerAdapter.ts +189 -0
- package/src/messenger/MessengerError.ts +67 -0
- package/src/streaming/Multipart.ts +29 -0
- package/src/testing/MockMessenger.ts +147 -0
- package/dist/Image-BK8-4H6s.d.mts +0 -59
- package/dist/Image-BK8-4H6s.d.mts.map +0 -1
- package/dist/Items-SfUabNuB.d.mts.map +0 -1
- package/dist/Media-DwgpifPD.d.mts +0 -57
- package/dist/Media-DwgpifPD.d.mts.map +0 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Context, Data, Effect, Stream } from "effect"
|
|
2
|
+
import type * as AiError from "../domain/AiError.js"
|
|
3
|
+
import type {
|
|
4
|
+
AspectRatio,
|
|
5
|
+
GeneratedImage,
|
|
6
|
+
ImageResolution,
|
|
7
|
+
ImageSource,
|
|
8
|
+
ProviderData,
|
|
9
|
+
} from "../domain/Image.js"
|
|
10
|
+
|
|
11
|
+
export type { AspectRatio, GeneratedImage, ImageResolution, ProviderData } from "../domain/Image.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Cross-provider text-to-image request. Shape and resolution are given
|
|
15
|
+
* as a ratio plus a tier rather than exact pixels: every provider takes
|
|
16
|
+
* one or derives the other. Exact dimensions, quality tiers, output
|
|
17
|
+
* encodings, and moderation knobs are vendor-specific and live on each
|
|
18
|
+
* provider's typed request.
|
|
19
|
+
*/
|
|
20
|
+
export type CommonImageGenerateRequest = {
|
|
21
|
+
readonly prompt: string
|
|
22
|
+
/** Each provider narrows this to its typed literal union. */
|
|
23
|
+
readonly model: string
|
|
24
|
+
readonly aspectRatio?: AspectRatio
|
|
25
|
+
readonly resolution?: ImageResolution
|
|
26
|
+
/** Default 1. Implementors without multi-image output fail `Unsupported` for more. */
|
|
27
|
+
readonly n?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Edit request: the same prompt-driven generation, conditioned on
|
|
32
|
+
* reference images. Separate from `generate` because the reference set
|
|
33
|
+
* is required here and absent there, and providers expose the two as
|
|
34
|
+
* distinct wire shapes. Per-provider caps on the array length apply.
|
|
35
|
+
*/
|
|
36
|
+
export type CommonImageEditRequest = CommonImageGenerateRequest & {
|
|
37
|
+
readonly images: ReadonlyArray<ImageSource>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Gated by {@link ImageStreaming}; `partialImages` is the preview-frame count. */
|
|
41
|
+
export type CommonStreamImageRequest = CommonImageGenerateRequest & {
|
|
42
|
+
readonly partialImages: 1 | 2 | 3
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The same previews, for an edit. Gated by {@link ImageStreaming}. */
|
|
46
|
+
export type CommonStreamImageEditRequest = CommonImageEditRequest & {
|
|
47
|
+
readonly partialImages: 1 | 2 | 3
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Optional throughout: not every provider bills or reports per token. */
|
|
51
|
+
export type ImageUsage = {
|
|
52
|
+
readonly inputTokens?: number
|
|
53
|
+
readonly outputTokens?: number
|
|
54
|
+
readonly totalTokens?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ImageResponse = {
|
|
58
|
+
readonly images: ReadonlyArray<GeneratedImage>
|
|
59
|
+
readonly usage: ImageUsage
|
|
60
|
+
/**
|
|
61
|
+
* Response-level extras, keyed by provider name: the seed a request
|
|
62
|
+
* resolved to, timings, safety flags. Per-image extras live on each
|
|
63
|
+
* {@link GeneratedImage} instead. See `Image.ProviderData`.
|
|
64
|
+
*/
|
|
65
|
+
readonly providerData?: ProviderData
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Zero or more `PartialImage` previews, then exactly one `Complete`.
|
|
70
|
+
* `Complete` carries the `ImageResponse` fields flat, so it is
|
|
71
|
+
* assignable to `ImageResponse` and needs no unwrapping.
|
|
72
|
+
*/
|
|
73
|
+
export type ImageStreamEvent = Data.TaggedEnum<{
|
|
74
|
+
PartialImage: {
|
|
75
|
+
readonly image: ImageSource
|
|
76
|
+
/** Counts from 0 in emission order. */
|
|
77
|
+
readonly index: number
|
|
78
|
+
}
|
|
79
|
+
Complete: {
|
|
80
|
+
readonly images: ReadonlyArray<GeneratedImage>
|
|
81
|
+
readonly usage: ImageUsage
|
|
82
|
+
}
|
|
83
|
+
}>
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Namespace of constructors, type guards, and matchers for
|
|
87
|
+
* `ImageStreamEvent`, provided by `Data.taggedEnum`.
|
|
88
|
+
*/
|
|
89
|
+
export const ImageStreamEvent = Data.taggedEnum<ImageStreamEvent>()
|
|
90
|
+
|
|
91
|
+
export const isPartialImage = ImageStreamEvent.$is("PartialImage")
|
|
92
|
+
export const isComplete = ImageStreamEvent.$is("Complete")
|
|
93
|
+
|
|
94
|
+
export type ImageGeneratorService = {
|
|
95
|
+
/** Prompt in, images out. Universally supported. */
|
|
96
|
+
readonly generate: (
|
|
97
|
+
request: CommonImageGenerateRequest,
|
|
98
|
+
) => Effect.Effect<ImageResponse, AiError.AiError>
|
|
99
|
+
/** Prompt plus reference images in, images out. Universally supported. */
|
|
100
|
+
readonly edit: (request: CommonImageEditRequest) => Effect.Effect<ImageResponse, AiError.AiError>
|
|
101
|
+
/**
|
|
102
|
+
* Preview frames as the image resolves, then the final response.
|
|
103
|
+
* Implementors without a partial-image wire return `Unsupported` and
|
|
104
|
+
* do NOT register {@link ImageStreaming}, so the top-level helper is
|
|
105
|
+
* a compile-time error against their Layer.
|
|
106
|
+
*/
|
|
107
|
+
readonly streamGeneration: (
|
|
108
|
+
request: CommonStreamImageRequest,
|
|
109
|
+
) => Stream.Stream<ImageStreamEvent, AiError.AiError>
|
|
110
|
+
/** {@link streamGeneration} conditioned on reference images. */
|
|
111
|
+
readonly streamEdit: (
|
|
112
|
+
request: CommonStreamImageEditRequest,
|
|
113
|
+
) => Stream.Stream<ImageStreamEvent, AiError.AiError>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class ImageGenerator extends Context.Service<ImageGenerator, ImageGeneratorService>()(
|
|
117
|
+
"@betalyra/effect-uai/ImageGenerator",
|
|
118
|
+
) {}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Capability marker for partial-image streaming. Phantom: the value is
|
|
122
|
+
* `void`, providers register with
|
|
123
|
+
* `Layer.succeed(ImageStreaming, undefined)`. Calling
|
|
124
|
+
* {@link streamGeneration} while only a non-streaming Layer is in scope
|
|
125
|
+
* fails at `Effect.provide` with a type error.
|
|
126
|
+
*/
|
|
127
|
+
export class ImageStreaming extends Context.Service<ImageStreaming, void>()(
|
|
128
|
+
"@betalyra/effect-uai/capability/ImageStreaming",
|
|
129
|
+
) {}
|
|
130
|
+
|
|
131
|
+
/** Text to image. */
|
|
132
|
+
export const generate = (
|
|
133
|
+
request: CommonImageGenerateRequest,
|
|
134
|
+
): Effect.Effect<ImageResponse, AiError.AiError, ImageGenerator> =>
|
|
135
|
+
Effect.flatMap(ImageGenerator, (g) => g.generate(request))
|
|
136
|
+
|
|
137
|
+
/** Text plus reference images to image. */
|
|
138
|
+
export const edit = (
|
|
139
|
+
request: CommonImageEditRequest,
|
|
140
|
+
): Effect.Effect<ImageResponse, AiError.AiError, ImageGenerator> =>
|
|
141
|
+
Effect.flatMap(ImageGenerator, (g) => g.edit(request))
|
|
142
|
+
|
|
143
|
+
/** Preview frames then the final response. Requires {@link ImageStreaming} in R. */
|
|
144
|
+
export const streamGeneration = (
|
|
145
|
+
request: CommonStreamImageRequest,
|
|
146
|
+
): Stream.Stream<ImageStreamEvent, AiError.AiError, ImageGenerator | ImageStreaming> =>
|
|
147
|
+
Stream.unwrap(
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const g = yield* ImageGenerator
|
|
150
|
+
yield* ImageStreaming
|
|
151
|
+
return g.streamGeneration(request)
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
/** The same, conditioned on reference images. Requires {@link ImageStreaming} in R. */
|
|
156
|
+
export const streamEdit = (
|
|
157
|
+
request: CommonStreamImageEditRequest,
|
|
158
|
+
): Stream.Stream<ImageStreamEvent, AiError.AiError, ImageGenerator | ImageStreaming> =>
|
|
159
|
+
Stream.unwrap(
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
const g = yield* ImageGenerator
|
|
162
|
+
yield* ImageStreaming
|
|
163
|
+
return g.streamEdit(request)
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Effect, Fiber, Option, Queue } from "effect"
|
|
3
|
+
import { TestClock } from "effect/testing"
|
|
4
|
+
import { expect } from "vitest"
|
|
5
|
+
import { drainBurst } from "./Inbox.js"
|
|
6
|
+
|
|
7
|
+
describe("drainBurst", () => {
|
|
8
|
+
it.effect("resets the window on every arrival, so a burst outlives one settle", () =>
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const queue = yield* Queue.unbounded<string>()
|
|
11
|
+
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
12
|
+
|
|
13
|
+
yield* Queue.offer(queue, "a")
|
|
14
|
+
yield* TestClock.adjust("900 millis")
|
|
15
|
+
yield* Queue.offer(queue, "b")
|
|
16
|
+
yield* TestClock.adjust("900 millis")
|
|
17
|
+
// 1.8s elapsed, more than one settle, and the burst is still open.
|
|
18
|
+
expect(burst.pollUnsafe()).toBeUndefined()
|
|
19
|
+
|
|
20
|
+
yield* TestClock.adjust("1 second")
|
|
21
|
+
expect(yield* Fiber.join(burst)).toEqual(["a", "b"])
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
it.effect("waits for the first item without a deadline", () =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const queue = yield* Queue.unbounded<string>()
|
|
28
|
+
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
29
|
+
|
|
30
|
+
yield* TestClock.adjust("1 hour")
|
|
31
|
+
expect(burst.pollUnsafe()).toBeUndefined()
|
|
32
|
+
|
|
33
|
+
yield* Queue.offer(queue, "late")
|
|
34
|
+
yield* TestClock.adjust("1 second")
|
|
35
|
+
expect(yield* Fiber.join(burst)).toEqual(["late"])
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
it.effect("ends on the first quiet gap and leaves later items for the next drain", () =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const queue = yield* Queue.unbounded<string>()
|
|
42
|
+
const burst = yield* Effect.forkChild(drainBurst(queue, "1 second"))
|
|
43
|
+
|
|
44
|
+
yield* Queue.offer(queue, "first")
|
|
45
|
+
yield* TestClock.adjust("1 second")
|
|
46
|
+
expect(yield* Fiber.join(burst)).toEqual(["first"])
|
|
47
|
+
|
|
48
|
+
yield* Queue.offer(queue, "second")
|
|
49
|
+
expect(yield* Queue.poll(queue)).toEqual(Option.some("second"))
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input a long-lived agent reads between turns. A chat user rarely sends one
|
|
3
|
+
* tidy message: they type three lines in a row, then go quiet. The loop wants
|
|
4
|
+
* that as one batch, taken at a clean turn boundary.
|
|
5
|
+
*/
|
|
6
|
+
import { Array as Arr, type Duration, Effect, Option, Queue } from "effect"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Block for the first item, then keep taking while the next one lands within
|
|
10
|
+
* `settle` of the previous. The window resets on every arrival, so a burst
|
|
11
|
+
* becomes one batch and a lone message followed by silence returns at once.
|
|
12
|
+
*
|
|
13
|
+
* A take that loses to the window is interrupted before it removes anything,
|
|
14
|
+
* so late arrivals stay queued for the next drain.
|
|
15
|
+
*/
|
|
16
|
+
export const drainBurst = <A, E>(
|
|
17
|
+
queue: Queue.Dequeue<A, E>,
|
|
18
|
+
settle: Duration.Input,
|
|
19
|
+
): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> => {
|
|
20
|
+
const settled = (
|
|
21
|
+
batch: Arr.NonEmptyReadonlyArray<A>,
|
|
22
|
+
): Effect.Effect<Arr.NonEmptyReadonlyArray<A>, E> =>
|
|
23
|
+
Queue.take(queue).pipe(
|
|
24
|
+
Effect.timeoutOption(settle),
|
|
25
|
+
Effect.flatMap(
|
|
26
|
+
Option.match({
|
|
27
|
+
onNone: () => Effect.succeed(batch),
|
|
28
|
+
onSome: (item) => settled(Arr.append(batch, item)),
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
return Effect.flatMap(Queue.take(queue), (first) => settled([first]))
|
|
33
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,11 @@ export * as Transcript from "./domain/Transcript.js"
|
|
|
17
17
|
export * as Turn from "./domain/Turn.js"
|
|
18
18
|
export * as Embedding from "./embedding-model/Embedding.js"
|
|
19
19
|
export * as EmbeddingModel from "./embedding-model/EmbeddingModel.js"
|
|
20
|
+
export * as ImageGenerator from "./image-generator/ImageGenerator.js"
|
|
20
21
|
export * as LanguageModel from "./language-model/LanguageModel.js"
|
|
22
|
+
export * as Messenger from "./messenger/Messenger.js"
|
|
23
|
+
export * as MessengerAdapter from "./messenger/MessengerAdapter.js"
|
|
24
|
+
export * as MessengerError from "./messenger/MessengerError.js"
|
|
21
25
|
export * as MusicGenerator from "./music-generator/MusicGenerator.js"
|
|
22
26
|
export * as Reranker from "./reranker/Reranker.js"
|
|
23
27
|
export * as Memory from "./sandbox/Memory.js"
|
|
@@ -33,6 +37,7 @@ export * as WebSearch from "./web-search/WebSearch.js"
|
|
|
33
37
|
export * as WebSearchTool from "./web-search/WebSearchTool.js"
|
|
34
38
|
export * as Vector from "./math/Vector.js"
|
|
35
39
|
export * as Tokenizer from "./tokenizer/Tokenizer.js"
|
|
40
|
+
export * as Inbox from "./inbox/Inbox.js"
|
|
36
41
|
export * as Loop from "./loop/Loop.js"
|
|
37
42
|
export * as Retry from "./retry/Retry.js"
|
|
38
43
|
export * as Tool from "./tool/Tool.js"
|
|
@@ -43,6 +48,7 @@ export * as Approval from "./tool/Approval.js"
|
|
|
43
48
|
export * as HistoryCheck from "./tool/HistoryCheck.js"
|
|
44
49
|
export * as JSONL from "./streaming/JSONL.js"
|
|
45
50
|
export * as Lines from "./streaming/Lines.js"
|
|
51
|
+
export * as Multipart from "./streaming/Multipart.js"
|
|
46
52
|
export * as SSE from "./streaming/SSE.js"
|
|
47
53
|
export * as StructuredFormat from "./structured-format/StructuredFormat.js"
|
|
48
54
|
export * as Metrics from "./observability/Metrics.js"
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { Brand, Context, Data, Effect, type Option, type Scope, Stream } from "effect"
|
|
2
|
+
import type { MediaSource } from "../domain/Media.js"
|
|
3
|
+
import type * as MessengerError from "./MessengerError.js"
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Identity
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/** Where messages go. Providers mint these per their own scheme; core treats them opaquely. */
|
|
10
|
+
export type ChannelId = Brand.Branded<string, "ChannelId">
|
|
11
|
+
export const ChannelId = Brand.nominal<ChannelId>()
|
|
12
|
+
|
|
13
|
+
/** One message inside a channel. Only a named message can be edited or reacted to. */
|
|
14
|
+
export type MessageId = Brand.Branded<string, "MessageId">
|
|
15
|
+
export const MessageId = Brand.nominal<MessageId>()
|
|
16
|
+
|
|
17
|
+
/** The human (or bot) an event came from. */
|
|
18
|
+
export type UserId = Brand.Branded<string, "UserId">
|
|
19
|
+
export const UserId = Brand.nominal<UserId>()
|
|
20
|
+
|
|
21
|
+
/** A sub-conversation inside a channel, where the platform has one. Opaque, provider-minted. */
|
|
22
|
+
export type ThreadId = Brand.Branded<string, "ThreadId">
|
|
23
|
+
export const ThreadId = Brand.nominal<ThreadId>()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where a conversation happens. `thread` is provider-interpreted: a Slack
|
|
27
|
+
* `thread_ts`, a Telegram forum topic, unset on Discord (where a thread is
|
|
28
|
+
* its own channel).
|
|
29
|
+
*/
|
|
30
|
+
export type ConversationRef = {
|
|
31
|
+
readonly channel: ChannelId
|
|
32
|
+
readonly thread?: ThreadId
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type MessageRef = {
|
|
36
|
+
readonly conversation: ConversationRef
|
|
37
|
+
readonly id: MessageId
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stable key for a conversation. Index per-conversation state with it. */
|
|
41
|
+
export const conversationKey = (ref: ConversationRef): string =>
|
|
42
|
+
ref.thread === undefined ? ref.channel : `${ref.channel}/${ref.thread}`
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Inbound
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the world says to the agent. `raw` is the untouched platform payload,
|
|
50
|
+
* for everything the shared shape drops.
|
|
51
|
+
*
|
|
52
|
+
* `addressed` is the rule recipes branch on: a DM, a mention of the bot, or
|
|
53
|
+
* a reply to one of its messages. Each adapter owns that decision (and hides
|
|
54
|
+
* Telegram privacy mode / Discord intents behind it), and delivers `text`
|
|
55
|
+
* with the bot's own mention stripped.
|
|
56
|
+
*
|
|
57
|
+
* `Reaction.emoji` is the platform's own spelling, the same one `react`
|
|
58
|
+
* accepts: unicode where the platform speaks unicode, a shortcode where it
|
|
59
|
+
* speaks shortcodes.
|
|
60
|
+
*/
|
|
61
|
+
export type InboundEvent = Data.TaggedEnum<{
|
|
62
|
+
Message: {
|
|
63
|
+
readonly conversation: ConversationRef
|
|
64
|
+
readonly id: MessageId
|
|
65
|
+
readonly author: UserId
|
|
66
|
+
readonly text: string
|
|
67
|
+
readonly addressed: boolean
|
|
68
|
+
readonly replyTo?: MessageId
|
|
69
|
+
readonly raw: unknown
|
|
70
|
+
}
|
|
71
|
+
Reaction: {
|
|
72
|
+
readonly conversation: ConversationRef
|
|
73
|
+
readonly message: MessageId
|
|
74
|
+
readonly emoji: string
|
|
75
|
+
readonly author: UserId
|
|
76
|
+
readonly raw: unknown
|
|
77
|
+
}
|
|
78
|
+
Command: {
|
|
79
|
+
readonly conversation: ConversationRef
|
|
80
|
+
readonly name: string
|
|
81
|
+
readonly args: string
|
|
82
|
+
readonly author: UserId
|
|
83
|
+
readonly raw: unknown
|
|
84
|
+
}
|
|
85
|
+
Action: {
|
|
86
|
+
readonly conversation: ConversationRef
|
|
87
|
+
readonly actionId: string
|
|
88
|
+
readonly value?: string
|
|
89
|
+
readonly author: UserId
|
|
90
|
+
readonly raw: unknown
|
|
91
|
+
}
|
|
92
|
+
}>
|
|
93
|
+
|
|
94
|
+
export const InboundEvent = Data.taggedEnum<InboundEvent>()
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Outbound
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What the agent says. `Text` is sent verbatim: each provider layer says
|
|
102
|
+
* which markup it expects (Telegram HTML, Slack markdown), and the prompt is
|
|
103
|
+
* where that gets decided. Nothing here converts. `Media` reuses the core
|
|
104
|
+
* {@link MediaSource}, so a URL, base64 or raw bytes all work; the adapter
|
|
105
|
+
* routes on `mimeType` (`image/*` to a photo endpoint, `audio/*` to an audio
|
|
106
|
+
* one, everything else to a file upload), which is why a URL source without
|
|
107
|
+
* a `mimeType` goes as a plain file. `Raw` bypasses the shape entirely and
|
|
108
|
+
* reaches the platform as-is: the escape hatch for buttons, cards and
|
|
109
|
+
* everything the verbs do not unify.
|
|
110
|
+
*/
|
|
111
|
+
export type OutboundBody = Data.TaggedEnum<{
|
|
112
|
+
Text: { readonly text: string }
|
|
113
|
+
Media: {
|
|
114
|
+
readonly media: MediaSource
|
|
115
|
+
readonly caption?: string
|
|
116
|
+
readonly filename?: string
|
|
117
|
+
}
|
|
118
|
+
Raw: { readonly payload: unknown }
|
|
119
|
+
}>
|
|
120
|
+
|
|
121
|
+
export const OutboundBody = Data.taggedEnum<OutboundBody>()
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A body plus the envelope fields true of any message. `replyTo` is ignored
|
|
125
|
+
* for a `Raw` body, which names its own wire fields.
|
|
126
|
+
*/
|
|
127
|
+
export type Outbound = {
|
|
128
|
+
readonly body: OutboundBody
|
|
129
|
+
readonly replyTo?: MessageId
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type OutboundOptions = { readonly replyTo?: MessageId }
|
|
133
|
+
|
|
134
|
+
const envelope = (body: OutboundBody, options?: OutboundOptions): Outbound => ({
|
|
135
|
+
body,
|
|
136
|
+
...(options?.replyTo !== undefined && { replyTo: options.replyTo }),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
/** Text message. The overwhelmingly common case: `post(text("hi"))`. */
|
|
140
|
+
export const text = (body: string, options?: OutboundOptions): Outbound =>
|
|
141
|
+
envelope(OutboundBody.Text({ text: body }), options)
|
|
142
|
+
|
|
143
|
+
/** Media message. `caption` and `filename` describe the media, not the envelope. */
|
|
144
|
+
export const media = (
|
|
145
|
+
source: MediaSource,
|
|
146
|
+
options?: OutboundOptions & { readonly caption?: string; readonly filename?: string },
|
|
147
|
+
): Outbound =>
|
|
148
|
+
envelope(
|
|
149
|
+
OutboundBody.Media({
|
|
150
|
+
media: source,
|
|
151
|
+
...(options?.caption !== undefined && { caption: options.caption }),
|
|
152
|
+
...(options?.filename !== undefined && { filename: options.filename }),
|
|
153
|
+
}),
|
|
154
|
+
options,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
/** Platform payload, passed through untouched. */
|
|
158
|
+
export const raw = (payload: unknown): Outbound => ({ body: OutboundBody.Raw({ payload }) })
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Ambient conversation targeting
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The conversation the outbound verbs target. A tag with no default, so
|
|
166
|
+
* posting outside an established conversation is a compile error. Deep code
|
|
167
|
+
* (a tool posting progress, an approval resolver) inherits it rather than
|
|
168
|
+
* threading a ref down.
|
|
169
|
+
*/
|
|
170
|
+
export class CurrentConversation extends Context.Service<CurrentConversation, ConversationRef>()(
|
|
171
|
+
"@betalyra/effect-uai/Messenger/CurrentConversation",
|
|
172
|
+
) {}
|
|
173
|
+
|
|
174
|
+
/** Run `effect` against `ref`. Wrap a conversation fiber once; every nested post lands. */
|
|
175
|
+
export const inConversation =
|
|
176
|
+
(ref: ConversationRef) =>
|
|
177
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, Exclude<R, CurrentConversation>> =>
|
|
178
|
+
Effect.provideService(effect, CurrentConversation, ref)
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Service definition
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** Envelope of a streamed reply. `replyTo` lands on the first message it posts. */
|
|
185
|
+
export type StreamOptions = OutboundOptions
|
|
186
|
+
|
|
187
|
+
/** Platform ceilings, so recipes can see them before composing a message. */
|
|
188
|
+
export type MessengerLimits = {
|
|
189
|
+
/** Longest single text message. */
|
|
190
|
+
readonly maxText: number
|
|
191
|
+
/** Longest caption on a media message. */
|
|
192
|
+
readonly maxCaption: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Cross-platform messaging: one inbound stream, five outbound verbs.
|
|
197
|
+
*
|
|
198
|
+
* `events` is single-consumer and provider-owned. The connection opens when
|
|
199
|
+
* the layer is built and closes with its scope, so there is no connect /
|
|
200
|
+
* disconnect here, and reconnects, acks and callback answering all happen
|
|
201
|
+
* below this line.
|
|
202
|
+
*
|
|
203
|
+
* `post`, `typing` and `stream` target the ambient
|
|
204
|
+
* {@link CurrentConversation}; `edit` and `react` name a message outright.
|
|
205
|
+
*/
|
|
206
|
+
export type MessengerService = {
|
|
207
|
+
readonly events: Stream.Stream<InboundEvent, MessengerError.MessengerError>
|
|
208
|
+
|
|
209
|
+
/** Text past `limits.maxText` goes out as several messages; the id is the last. */
|
|
210
|
+
readonly post: (
|
|
211
|
+
msg: Outbound,
|
|
212
|
+
) => Effect.Effect<MessageId, MessengerError.MessengerError, CurrentConversation>
|
|
213
|
+
|
|
214
|
+
readonly edit: (
|
|
215
|
+
msg: MessageRef,
|
|
216
|
+
next: Outbound,
|
|
217
|
+
) => Effect.Effect<void, MessengerError.MessengerError>
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* `emoji` in the platform's own spelling, as its `Reaction` events deliver
|
|
221
|
+
* it. Nothing is translated; one the platform rejects fails
|
|
222
|
+
* `MessengerUnsupported`.
|
|
223
|
+
*/
|
|
224
|
+
readonly react: (
|
|
225
|
+
msg: MessageRef,
|
|
226
|
+
emoji: string,
|
|
227
|
+
) => Effect.Effect<void, MessengerError.MessengerError>
|
|
228
|
+
|
|
229
|
+
/** Activity indicator, kept alive by the adapter until the scope closes. */
|
|
230
|
+
readonly typing: Effect.Effect<
|
|
231
|
+
void,
|
|
232
|
+
MessengerError.MessengerError,
|
|
233
|
+
CurrentConversation | Scope.Scope
|
|
234
|
+
>
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Progressive delivery of a text stream; the mechanism is the adapter's.
|
|
238
|
+
* The id is the last message posted, `None` when the stream had no text.
|
|
239
|
+
*/
|
|
240
|
+
readonly stream: <E, R>(
|
|
241
|
+
deltas: Stream.Stream<string, E, R>,
|
|
242
|
+
options?: StreamOptions,
|
|
243
|
+
) => Effect.Effect<
|
|
244
|
+
Option.Option<MessageId>,
|
|
245
|
+
MessengerError.MessengerError | E,
|
|
246
|
+
R | CurrentConversation
|
|
247
|
+
>
|
|
248
|
+
|
|
249
|
+
readonly limits: MessengerLimits
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class Messenger extends Context.Service<Messenger, MessengerService>()(
|
|
253
|
+
"@betalyra/effect-uai/Messenger",
|
|
254
|
+
) {}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Free helpers
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export const events: Stream.Stream<InboundEvent, MessengerError.MessengerError, Messenger> =
|
|
261
|
+
Stream.unwrap(Effect.map(Messenger, (m) => m.events))
|
|
262
|
+
|
|
263
|
+
export const post = (
|
|
264
|
+
msg: Outbound,
|
|
265
|
+
): Effect.Effect<MessageId, MessengerError.MessengerError, Messenger | CurrentConversation> =>
|
|
266
|
+
Effect.flatMap(Messenger, (m) => m.post(msg))
|
|
267
|
+
|
|
268
|
+
export const edit = (
|
|
269
|
+
msg: MessageRef,
|
|
270
|
+
next: Outbound,
|
|
271
|
+
): Effect.Effect<void, MessengerError.MessengerError, Messenger> =>
|
|
272
|
+
Effect.flatMap(Messenger, (m) => m.edit(msg, next))
|
|
273
|
+
|
|
274
|
+
export const react = (
|
|
275
|
+
msg: MessageRef,
|
|
276
|
+
emoji: string,
|
|
277
|
+
): Effect.Effect<void, MessengerError.MessengerError, Messenger> =>
|
|
278
|
+
Effect.flatMap(Messenger, (m) => m.react(msg, emoji))
|
|
279
|
+
|
|
280
|
+
export const typing: Effect.Effect<
|
|
281
|
+
void,
|
|
282
|
+
MessengerError.MessengerError,
|
|
283
|
+
Messenger | CurrentConversation | Scope.Scope
|
|
284
|
+
> = Effect.flatMap(Messenger, (m) => m.typing)
|
|
285
|
+
|
|
286
|
+
export const stream = <E, R>(
|
|
287
|
+
deltas: Stream.Stream<string, E, R>,
|
|
288
|
+
options?: StreamOptions,
|
|
289
|
+
): Effect.Effect<
|
|
290
|
+
Option.Option<MessageId>,
|
|
291
|
+
MessengerError.MessengerError | E,
|
|
292
|
+
R | Messenger | CurrentConversation
|
|
293
|
+
> => Effect.flatMap(Messenger, (m) => m.stream(deltas, options))
|