@effect-uai/core 0.14.0 → 0.16.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/README.md +30 -16
- package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
- package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
- package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
- package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
- package/dist/MessengerError-CeK6VAcu.d.mts +64 -0
- package/dist/MessengerError-CeK6VAcu.d.mts.map +1 -0
- package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
- package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
- package/dist/chunker/Chunker.d.mts +1 -1
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +9 -2
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Realtime.d.mts +616 -0
- package/dist/domain/Realtime.d.mts.map +1 -0
- package/dist/domain/Realtime.mjs +21 -0
- package/dist/domain/Realtime.mjs.map +1 -0
- package/dist/domain/Transcript.d.mts +17 -3
- package/dist/domain/Transcript.d.mts.map +1 -1
- package/dist/domain/Transcript.mjs +30 -1
- package/dist/domain/Transcript.mjs.map +1 -1
- package/dist/domain/Transcript.test.d.mts +1 -0
- package/dist/domain/Transcript.test.mjs +110 -0
- package/dist/domain/Transcript.test.mjs.map +1 -0
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/image-generator/ImageGenerator.d.mts +1 -1
- package/dist/index.d.mts +11 -4
- package/dist/index.mjs +9 -2
- package/dist/job/Job.d.mts +1 -1
- package/dist/language-model/LanguageModel.d.mts +2 -2
- package/dist/loop/Loop.d.mts +1 -1
- 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 +1 -1
- package/dist/realtime/RealtimeSession.d.mts +50 -0
- package/dist/realtime/RealtimeSession.d.mts.map +1 -0
- package/dist/realtime/RealtimeSession.mjs +45 -0
- package/dist/realtime/RealtimeSession.mjs.map +1 -0
- package/dist/realtime/RealtimeSession.test.d.mts +1 -0
- package/dist/realtime/RealtimeSession.test.mjs +95 -0
- package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
- package/dist/reranker/Reranker.d.mts +1 -1
- package/dist/research/DeepResearch.d.mts +1 -1
- package/dist/retry/Retry.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/Settle.d.mts +45 -0
- package/dist/streaming/Settle.d.mts.map +1 -0
- package/dist/streaming/Settle.mjs +95 -0
- package/dist/streaming/Settle.mjs.map +1 -0
- package/dist/streaming/Settle.test.d.mts +1 -0
- package/dist/streaming/Settle.test.mjs +252 -0
- package/dist/streaming/Settle.test.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.d.mts +36 -0
- package/dist/streaming/WebSocketSession.d.mts.map +1 -0
- package/dist/streaming/WebSocketSession.mjs +89 -0
- package/dist/streaming/WebSocketSession.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.test.d.mts +1 -0
- package/dist/streaming/WebSocketSession.test.mjs +51 -0
- package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
- package/dist/testing/FakeWebSocket.d.mts +25 -0
- package/dist/testing/FakeWebSocket.d.mts.map +1 -0
- package/dist/testing/FakeWebSocket.mjs +79 -0
- package/dist/testing/FakeWebSocket.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/MockRealtimeSession.d.mts +30 -0
- package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
- package/dist/testing/MockRealtimeSession.mjs +52 -0
- package/dist/testing/MockRealtimeSession.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/dist/web-read/WebRead.d.mts +1 -1
- package/dist/web-read/WebReadTool.d.mts +1 -1
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +1 -1
- package/package.json +41 -1
- package/src/domain/AiError.ts +11 -0
- package/src/domain/Realtime.ts +101 -0
- package/src/domain/Transcript.test.ts +98 -0
- package/src/domain/Transcript.ts +53 -1
- package/src/index.ts +7 -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/realtime/RealtimeSession.test.ts +100 -0
- package/src/realtime/RealtimeSession.ts +77 -0
- package/src/streaming/Settle.test.ts +344 -0
- package/src/streaming/Settle.ts +196 -0
- package/src/streaming/WebSocketSession.test.ts +52 -0
- package/src/streaming/WebSocketSession.ts +132 -0
- package/src/testing/FakeWebSocket.ts +120 -0
- package/src/testing/MockMessenger.ts +147 -0
- package/src/testing/MockRealtimeSession.ts +92 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-uai/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Low-level primitives (loop, conversation, items, tools, streaming codecs) for building AI agents with Effect.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -90,6 +90,14 @@
|
|
|
90
90
|
"types": "./dist/domain/Music.d.mts",
|
|
91
91
|
"import": "./dist/domain/Music.mjs"
|
|
92
92
|
},
|
|
93
|
+
"./Realtime": {
|
|
94
|
+
"types": "./dist/domain/Realtime.d.mts",
|
|
95
|
+
"import": "./dist/domain/Realtime.mjs"
|
|
96
|
+
},
|
|
97
|
+
"./RealtimeSession": {
|
|
98
|
+
"types": "./dist/realtime/RealtimeSession.d.mts",
|
|
99
|
+
"import": "./dist/realtime/RealtimeSession.mjs"
|
|
100
|
+
},
|
|
93
101
|
"./Transcript": {
|
|
94
102
|
"types": "./dist/domain/Transcript.d.mts",
|
|
95
103
|
"import": "./dist/domain/Transcript.mjs"
|
|
@@ -114,6 +122,18 @@
|
|
|
114
122
|
"types": "./dist/language-model/LanguageModel.d.mts",
|
|
115
123
|
"import": "./dist/language-model/LanguageModel.mjs"
|
|
116
124
|
},
|
|
125
|
+
"./Messenger": {
|
|
126
|
+
"types": "./dist/messenger/Messenger.d.mts",
|
|
127
|
+
"import": "./dist/messenger/Messenger.mjs"
|
|
128
|
+
},
|
|
129
|
+
"./MessengerAdapter": {
|
|
130
|
+
"types": "./dist/messenger/MessengerAdapter.d.mts",
|
|
131
|
+
"import": "./dist/messenger/MessengerAdapter.mjs"
|
|
132
|
+
},
|
|
133
|
+
"./MessengerError": {
|
|
134
|
+
"types": "./dist/messenger/MessengerError.d.mts",
|
|
135
|
+
"import": "./dist/messenger/MessengerError.mjs"
|
|
136
|
+
},
|
|
117
137
|
"./MusicGenerator": {
|
|
118
138
|
"types": "./dist/music-generator/MusicGenerator.d.mts",
|
|
119
139
|
"import": "./dist/music-generator/MusicGenerator.mjs"
|
|
@@ -166,6 +186,14 @@
|
|
|
166
186
|
"types": "./dist/web-search/WebSearchTool.d.mts",
|
|
167
187
|
"import": "./dist/web-search/WebSearchTool.mjs"
|
|
168
188
|
},
|
|
189
|
+
"./WebSocketSession": {
|
|
190
|
+
"types": "./dist/streaming/WebSocketSession.d.mts",
|
|
191
|
+
"import": "./dist/streaming/WebSocketSession.mjs"
|
|
192
|
+
},
|
|
193
|
+
"./Settle": {
|
|
194
|
+
"types": "./dist/streaming/Settle.d.mts",
|
|
195
|
+
"import": "./dist/streaming/Settle.mjs"
|
|
196
|
+
},
|
|
169
197
|
"./Loop": {
|
|
170
198
|
"types": "./dist/loop/Loop.d.mts",
|
|
171
199
|
"import": "./dist/loop/Loop.mjs"
|
|
@@ -238,6 +266,10 @@
|
|
|
238
266
|
"types": "./dist/testing/MockProvider.d.mts",
|
|
239
267
|
"import": "./dist/testing/MockProvider.mjs"
|
|
240
268
|
},
|
|
269
|
+
"./testing/MockMessenger": {
|
|
270
|
+
"types": "./dist/testing/MockMessenger.d.mts",
|
|
271
|
+
"import": "./dist/testing/MockMessenger.mjs"
|
|
272
|
+
},
|
|
241
273
|
"./testing/MockMusicGenerator": {
|
|
242
274
|
"types": "./dist/testing/MockMusicGenerator.d.mts",
|
|
243
275
|
"import": "./dist/testing/MockMusicGenerator.mjs"
|
|
@@ -250,6 +282,14 @@
|
|
|
250
282
|
"types": "./dist/testing/MockSandbox.d.mts",
|
|
251
283
|
"import": "./dist/testing/MockSandbox.mjs"
|
|
252
284
|
},
|
|
285
|
+
"./testing/FakeWebSocket": {
|
|
286
|
+
"types": "./dist/testing/FakeWebSocket.d.mts",
|
|
287
|
+
"import": "./dist/testing/FakeWebSocket.mjs"
|
|
288
|
+
},
|
|
289
|
+
"./testing/MockRealtimeSession": {
|
|
290
|
+
"types": "./dist/testing/MockRealtimeSession.d.mts",
|
|
291
|
+
"import": "./dist/testing/MockRealtimeSession.mjs"
|
|
292
|
+
},
|
|
253
293
|
"./testing/MockTranscriber": {
|
|
254
294
|
"types": "./dist/testing/MockTranscriber.d.mts",
|
|
255
295
|
"import": "./dist/testing/MockTranscriber.mjs"
|
package/src/domain/AiError.ts
CHANGED
|
@@ -100,6 +100,15 @@ export class Unsupported extends Data.TaggedError("Unsupported")<{
|
|
|
100
100
|
reason?: string
|
|
101
101
|
}> {}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* A long-lived session hit the provider's lifetime cap. Distinct from
|
|
105
|
+
* `Unavailable`: reconnecting works, retrying the same session does not.
|
|
106
|
+
*/
|
|
107
|
+
export class SessionExpired extends Data.TaggedError("SessionExpired")<{
|
|
108
|
+
provider: string
|
|
109
|
+
raw?: unknown
|
|
110
|
+
}> {}
|
|
111
|
+
|
|
103
112
|
export type AiError =
|
|
104
113
|
| RateLimited
|
|
105
114
|
| Unavailable
|
|
@@ -112,6 +121,7 @@ export type AiError =
|
|
|
112
121
|
| IncompleteTurn
|
|
113
122
|
| GenerationFailed
|
|
114
123
|
| Unsupported
|
|
124
|
+
| SessionExpired
|
|
115
125
|
|
|
116
126
|
const withReason = (base: string, reason: string | undefined): string =>
|
|
117
127
|
reason === undefined ? base : `${base}: ${reason}`
|
|
@@ -136,5 +146,6 @@ export const describe: (e: AiError) => string = Match.type<AiError>().pipe(
|
|
|
136
146
|
IncompleteTurn: () => "the provider stream ended without completing",
|
|
137
147
|
GenerationFailed: (e) => e.message ?? `${e.provider} failed mid-generation`,
|
|
138
148
|
Unsupported: (e) => withReason(`${e.provider} does not support ${e.capability}`, e.reason),
|
|
149
|
+
SessionExpired: (e) => `the ${e.provider} session reached its lifetime limit`,
|
|
139
150
|
}),
|
|
140
151
|
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for a duplex speech-to-speech session: one long-lived connection the
|
|
3
|
+
* server drives, rather than a caller-driven turn.
|
|
4
|
+
*
|
|
5
|
+
* Only what both OpenAI Realtime and Gemini Live do natively lives here. VAD
|
|
6
|
+
* knobs, truncation, resumption, thinking, grounding and noise reduction are
|
|
7
|
+
* provider-typed on each adapter's request.
|
|
8
|
+
*/
|
|
9
|
+
import { Data, type Duration } from "effect"
|
|
10
|
+
import type { AudioFormat } from "./Audio.js"
|
|
11
|
+
import type { ImageSource } from "./Image.js"
|
|
12
|
+
import type { HistoryItem, ToolCall, ToolCallOutput, Usage } from "./Items.js"
|
|
13
|
+
import type { ToolDescriptor } from "../tool/Tool.js"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the caller pushes into a live session.
|
|
17
|
+
*
|
|
18
|
+
* - `Audio`: encoded per `request.inputFormat`. The adapter neither paces nor
|
|
19
|
+
* buffers; send at real time.
|
|
20
|
+
* - `Text`: role `system` maps to a system item where the provider has one and
|
|
21
|
+
* fails `Unsupported` where it does not.
|
|
22
|
+
* - `Interrupt`: cancel the response in flight.
|
|
23
|
+
* - `VideoFrame`: gated by the `RealtimeVideoInput` marker.
|
|
24
|
+
* - `ActivityStart` / `ActivityEnd`: manual turn detection only; `ActivityEnd`
|
|
25
|
+
* also asks for a response. Dropped with a warning on a `"server"` session.
|
|
26
|
+
* - `PlaybackPosition`: how much of a response the user actually heard, so the
|
|
27
|
+
* adapter can trim the rest from context. A no-op where the wire has no
|
|
28
|
+
* truncate.
|
|
29
|
+
*/
|
|
30
|
+
export type RealtimeInput = Data.TaggedEnum<{
|
|
31
|
+
Audio: { readonly bytes: Uint8Array }
|
|
32
|
+
Text: { readonly text: string; readonly role?: "user" | "system" }
|
|
33
|
+
ToolResult: { readonly output: ToolCallOutput }
|
|
34
|
+
Interrupt: {}
|
|
35
|
+
VideoFrame: { readonly frame: ImageSource }
|
|
36
|
+
ActivityStart: {}
|
|
37
|
+
ActivityEnd: {}
|
|
38
|
+
PlaybackPosition: { readonly responseId: string; readonly playedMs: number }
|
|
39
|
+
}>
|
|
40
|
+
|
|
41
|
+
export const RealtimeInput = Data.taggedEnum<RealtimeInput>()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the session emits. Every `AudioDelta` and `OutputTranscriptDelta` is
|
|
45
|
+
* bracketed by `ResponseStarted` and `ResponseDone` carrying the same
|
|
46
|
+
* `responseId`.
|
|
47
|
+
*
|
|
48
|
+
* - `InputTranscript`: transcription of the user's own speech.
|
|
49
|
+
* - `SpeechStarted` / `SpeechStopped`: sparse. OpenAI emits them, Gemini
|
|
50
|
+
* does not.
|
|
51
|
+
* - `Interrupted`: stop and flush playback now. Always precedes the
|
|
52
|
+
* `ResponseDone { reason: "interrupted" }` for that response.
|
|
53
|
+
* - `ToolCallCancelled`: calls of an interrupted response that will never be
|
|
54
|
+
* answered.
|
|
55
|
+
* - `ResumptionHandle`: Gemini only; pass it back as `request.resume`.
|
|
56
|
+
* - `SessionEnding`: the connection is about to close on the server's terms.
|
|
57
|
+
* - `Error`: non-fatal. A fatal error fails the stream instead.
|
|
58
|
+
*/
|
|
59
|
+
export type RealtimeEvent = Data.TaggedEnum<{
|
|
60
|
+
ResponseStarted: { readonly responseId: string }
|
|
61
|
+
AudioDelta: { readonly responseId: string; readonly bytes: Uint8Array }
|
|
62
|
+
OutputTranscriptDelta: { readonly responseId: string; readonly text: string }
|
|
63
|
+
InputTranscript: { readonly text: string; readonly final: boolean }
|
|
64
|
+
SpeechStarted: {}
|
|
65
|
+
SpeechStopped: {}
|
|
66
|
+
ToolCall: { readonly responseId: string; readonly call: ToolCall }
|
|
67
|
+
ToolCallCancelled: { readonly callIds: ReadonlyArray<string> }
|
|
68
|
+
Interrupted: { readonly responseId: string }
|
|
69
|
+
ResponseDone: {
|
|
70
|
+
readonly responseId: string
|
|
71
|
+
readonly reason: "complete" | "interrupted" | "cancelled" | "error"
|
|
72
|
+
readonly usage?: Usage
|
|
73
|
+
}
|
|
74
|
+
ResumptionHandle: { readonly handle: string }
|
|
75
|
+
SessionEnding: { readonly timeLeft?: Duration.Duration }
|
|
76
|
+
Error: { readonly code?: string; readonly message: string }
|
|
77
|
+
}>
|
|
78
|
+
|
|
79
|
+
export const RealtimeEvent = Data.taggedEnum<RealtimeEvent>()
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Cross-provider session setup. Each adapter extends this, narrowing `model`
|
|
83
|
+
* and adding its own knobs.
|
|
84
|
+
*/
|
|
85
|
+
export type CommonSessionRequest = {
|
|
86
|
+
readonly model: string
|
|
87
|
+
readonly instructions?: string
|
|
88
|
+
readonly voiceId?: string
|
|
89
|
+
/** `Toolkit.descriptors(toolkit)`. Immutable for the connection on Gemini. */
|
|
90
|
+
readonly tools?: ReadonlyArray<ToolDescriptor>
|
|
91
|
+
readonly inputFormat: AudioFormat
|
|
92
|
+
readonly outputFormat: AudioFormat
|
|
93
|
+
/** Who decides a turn ended. Defaults to `"server"`. */
|
|
94
|
+
readonly turnDetection?: "server" | "manual"
|
|
95
|
+
/** Transcribe the user's own audio. Defaults to `true`. */
|
|
96
|
+
readonly transcribeInput?: boolean
|
|
97
|
+
/** Text-only conversation seed. */
|
|
98
|
+
readonly history?: ReadonlyArray<HistoryItem>
|
|
99
|
+
/** A `ResumptionHandle` from a previous session. `Unsupported` off Gemini. */
|
|
100
|
+
readonly resume?: string
|
|
101
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Cause, Effect, Queue, Ref, Stream } from "effect"
|
|
3
|
+
import { TestClock } from "effect/testing"
|
|
4
|
+
import { expect } from "vitest"
|
|
5
|
+
import { accumulatePartials, type TranscriptEvent } from "./Transcript.js"
|
|
6
|
+
|
|
7
|
+
const collecting = (stream: Stream.Stream<TranscriptEvent, never>) =>
|
|
8
|
+
Effect.gen(function* () {
|
|
9
|
+
const seen = yield* Ref.make<ReadonlyArray<TranscriptEvent>>([])
|
|
10
|
+
yield* Effect.forkChild(Stream.runForEach(stream, (e) => Ref.update(seen, (xs) => [...xs, e])))
|
|
11
|
+
return seen
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
/** Deltas arriving faster than any settle window. */
|
|
15
|
+
const speak = (
|
|
16
|
+
queue: Queue.Queue<TranscriptEvent, Cause.Done>,
|
|
17
|
+
tokens: ReadonlyArray<string>,
|
|
18
|
+
): Effect.Effect<void> =>
|
|
19
|
+
Effect.forEach(
|
|
20
|
+
tokens,
|
|
21
|
+
(text) =>
|
|
22
|
+
Effect.andThen(Queue.offer(queue, { _tag: "partial", text }), TestClock.adjust("100 millis")),
|
|
23
|
+
{ discard: true },
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const opened = Effect.gen(function* () {
|
|
27
|
+
const queue = yield* Queue.unbounded<TranscriptEvent, Cause.Done>()
|
|
28
|
+
const seen = yield* collecting(
|
|
29
|
+
Stream.fromQueue(queue).pipe(accumulatePartials({ silence: "1 second" })),
|
|
30
|
+
)
|
|
31
|
+
return { queue, seen }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("accumulatePartials", () => {
|
|
35
|
+
it.effect("joins deltas into the running hypothesis, forwarding each as it arrives", () =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const { queue, seen } = yield* opened
|
|
38
|
+
|
|
39
|
+
yield* speak(queue, [" Hi", ",", " how"])
|
|
40
|
+
|
|
41
|
+
// Three in, three out, each the whole sentence so far, none delayed.
|
|
42
|
+
expect(yield* Ref.get(seen)).toEqual([
|
|
43
|
+
{ _tag: "partial", text: "Hi" },
|
|
44
|
+
{ _tag: "partial", text: "Hi," },
|
|
45
|
+
{ _tag: "partial", text: "Hi, how" },
|
|
46
|
+
])
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
it.effect("commits on silence and starts the next utterance from empty", () =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const { queue, seen } = yield* opened
|
|
53
|
+
|
|
54
|
+
yield* speak(queue, [" Hi", " there"])
|
|
55
|
+
yield* TestClock.adjust("3 seconds")
|
|
56
|
+
yield* speak(queue, [" Bye"])
|
|
57
|
+
yield* TestClock.adjust("3 seconds")
|
|
58
|
+
|
|
59
|
+
expect(yield* Ref.get(seen)).toEqual([
|
|
60
|
+
{ _tag: "partial", text: "Hi" },
|
|
61
|
+
{ _tag: "partial", text: "Hi there" },
|
|
62
|
+
{ _tag: "final", text: "Hi there" },
|
|
63
|
+
{ _tag: "partial", text: "Bye" },
|
|
64
|
+
{ _tag: "final", text: "Bye" },
|
|
65
|
+
])
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
it.effect("a provider's own final resets the accumulator, so silence adds no duplicate", () =>
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
const { queue, seen } = yield* opened
|
|
72
|
+
|
|
73
|
+
yield* speak(queue, [" Hi"])
|
|
74
|
+
yield* Queue.offer(queue, { _tag: "final", text: "Hi." })
|
|
75
|
+
yield* TestClock.adjust("3 seconds")
|
|
76
|
+
|
|
77
|
+
expect(yield* Ref.get(seen)).toEqual([
|
|
78
|
+
{ _tag: "partial", text: "Hi" },
|
|
79
|
+
{ _tag: "final", text: "Hi." },
|
|
80
|
+
])
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
it.effect("commits the pending text when the stream ends mid-utterance", () =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const { queue, seen } = yield* opened
|
|
87
|
+
|
|
88
|
+
yield* speak(queue, [" Half a sen"])
|
|
89
|
+
yield* Queue.end(queue)
|
|
90
|
+
yield* TestClock.adjust("10 millis")
|
|
91
|
+
|
|
92
|
+
expect(yield* Ref.get(seen)).toEqual([
|
|
93
|
+
{ _tag: "partial", text: "Half a sen" },
|
|
94
|
+
{ _tag: "final", text: "Half a sen" },
|
|
95
|
+
])
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
})
|
package/src/domain/Transcript.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Duration, Effect, Match, Option, Ref, Stream } from "effect"
|
|
2
|
+
import * as Settle from "../streaming/Settle.js"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Per-word timing + metadata. `confidence` and `speakerId` are optional
|
|
@@ -87,3 +88,54 @@ export const isMetadata = (
|
|
|
87
88
|
): e is Extract<TranscriptEvent, { _tag: "metadata" }> => e._tag === "metadata"
|
|
88
89
|
export const isError = (e: TranscriptEvent): e is Extract<TranscriptEvent, { _tag: "error" }> =>
|
|
89
90
|
e._tag === "error"
|
|
91
|
+
|
|
92
|
+
export type AccumulatePartialsOptions = {
|
|
93
|
+
/** Silence after the last `partial` before the text so far is committed. */
|
|
94
|
+
readonly silence?: Duration.Input
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_SILENCE = "700 millis"
|
|
98
|
+
|
|
99
|
+
const finalOf = (text: string): Option.Option<TranscriptEvent> =>
|
|
100
|
+
text.trim().length === 0 ? Option.none() : Option.some({ _tag: "final", text: text.trim() })
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Join fragment `partial`s into the running hypothesis and commit it as a
|
|
104
|
+
* `final` after `silence`. For providers that stream token-sized deltas
|
|
105
|
+
* (OpenAI Realtime) or never segment; piping one that already accumulates
|
|
106
|
+
* repeats its text.
|
|
107
|
+
*
|
|
108
|
+
* Partials are forwarded as they arrive; only the synthetic `final` waits.
|
|
109
|
+
* A real `final` resets the accumulator, and the stream end flushes it.
|
|
110
|
+
*/
|
|
111
|
+
export const accumulatePartials =
|
|
112
|
+
(options?: AccumulatePartialsOptions) =>
|
|
113
|
+
<E, R>(self: Stream.Stream<TranscriptEvent, E, R>): Stream.Stream<TranscriptEvent, E, R> =>
|
|
114
|
+
Stream.unwrap(
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const acc = yield* Ref.make("")
|
|
117
|
+
const flush = Effect.map(Ref.getAndSet(acc, ""), finalOf)
|
|
118
|
+
|
|
119
|
+
const rewrite = (event: TranscriptEvent): Effect.Effect<TranscriptEvent> =>
|
|
120
|
+
Match.value(event).pipe(
|
|
121
|
+
Match.when({ _tag: "partial" }, (e) =>
|
|
122
|
+
Effect.map(
|
|
123
|
+
Ref.updateAndGet(acc, (text) => text + e.text),
|
|
124
|
+
(text): TranscriptEvent => ({ ...e, text: text.trim() }),
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
Match.when({ _tag: "final" }, (e) => Effect.as(Ref.set(acc, ""), e as TranscriptEvent)),
|
|
128
|
+
Match.orElse((e) => Effect.succeed(e as TranscriptEvent)),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return self.pipe(
|
|
132
|
+
Stream.mapEffect(rewrite),
|
|
133
|
+
Settle.onQuiet(options?.silence ?? DEFAULT_SILENCE, flush),
|
|
134
|
+
Stream.concat(
|
|
135
|
+
Stream.unwrap(
|
|
136
|
+
Effect.map(flush, Option.match({ onNone: () => Stream.empty, onSome: Stream.make })),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
}),
|
|
141
|
+
)
|
package/src/index.ts
CHANGED
|
@@ -13,12 +13,17 @@ export * as Job from "./job/Job.js"
|
|
|
13
13
|
export * as Media from "./domain/Media.js"
|
|
14
14
|
export * as Music from "./domain/Music.js"
|
|
15
15
|
export * as Research from "./domain/Research.js"
|
|
16
|
+
export * as Realtime from "./domain/Realtime.js"
|
|
17
|
+
export * as RealtimeSession from "./realtime/RealtimeSession.js"
|
|
16
18
|
export * as Transcript from "./domain/Transcript.js"
|
|
17
19
|
export * as Turn from "./domain/Turn.js"
|
|
18
20
|
export * as Embedding from "./embedding-model/Embedding.js"
|
|
19
21
|
export * as EmbeddingModel from "./embedding-model/EmbeddingModel.js"
|
|
20
22
|
export * as ImageGenerator from "./image-generator/ImageGenerator.js"
|
|
21
23
|
export * as LanguageModel from "./language-model/LanguageModel.js"
|
|
24
|
+
export * as Messenger from "./messenger/Messenger.js"
|
|
25
|
+
export * as MessengerAdapter from "./messenger/MessengerAdapter.js"
|
|
26
|
+
export * as MessengerError from "./messenger/MessengerError.js"
|
|
22
27
|
export * as MusicGenerator from "./music-generator/MusicGenerator.js"
|
|
23
28
|
export * as Reranker from "./reranker/Reranker.js"
|
|
24
29
|
export * as Memory from "./sandbox/Memory.js"
|
|
@@ -34,6 +39,8 @@ export * as WebSearch from "./web-search/WebSearch.js"
|
|
|
34
39
|
export * as WebSearchTool from "./web-search/WebSearchTool.js"
|
|
35
40
|
export * as Vector from "./math/Vector.js"
|
|
36
41
|
export * as Tokenizer from "./tokenizer/Tokenizer.js"
|
|
42
|
+
export * as Settle from "./streaming/Settle.js"
|
|
43
|
+
export * as WebSocketSession from "./streaming/WebSocketSession.js"
|
|
37
44
|
export * as Loop from "./loop/Loop.js"
|
|
38
45
|
export * as Retry from "./retry/Retry.js"
|
|
39
46
|
export * as Tool from "./tool/Tool.js"
|