effect-assistant 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/AppEvent.d.ts +111 -0
- package/dist/AppEvent.d.ts.map +1 -0
- package/dist/AppEvent.js +71 -0
- package/dist/AppEvent.js.map +1 -0
- package/dist/Assistant.d.ts +403 -0
- package/dist/Assistant.d.ts.map +1 -0
- package/dist/Assistant.js +126 -0
- package/dist/Assistant.js.map +1 -0
- package/dist/AssistantActivity.d.ts +85 -0
- package/dist/AssistantActivity.d.ts.map +1 -0
- package/dist/AssistantActivity.js +105 -0
- package/dist/AssistantActivity.js.map +1 -0
- package/dist/AssistantEngine.d.ts +116 -0
- package/dist/AssistantEngine.d.ts.map +1 -0
- package/dist/AssistantEngine.js +255 -0
- package/dist/AssistantEngine.js.map +1 -0
- package/dist/AssistantEvent.d.ts +551 -0
- package/dist/AssistantEvent.d.ts.map +1 -0
- package/dist/AssistantEvent.js +473 -0
- package/dist/AssistantEvent.js.map +1 -0
- package/dist/AssistantLoop.d.ts +173 -0
- package/dist/AssistantLoop.d.ts.map +1 -0
- package/dist/AssistantLoop.js +111 -0
- package/dist/AssistantLoop.js.map +1 -0
- package/dist/AssistantToolCall.d.ts +32 -0
- package/dist/AssistantToolCall.d.ts.map +1 -0
- package/dist/AssistantToolCall.js +37 -0
- package/dist/AssistantToolCall.js.map +1 -0
- package/dist/Block.d.ts +69 -0
- package/dist/Block.d.ts.map +1 -0
- package/dist/Block.js +39 -0
- package/dist/Block.js.map +1 -0
- package/dist/BlockKit.d.ts +180 -0
- package/dist/BlockKit.d.ts.map +1 -0
- package/dist/BlockKit.js +148 -0
- package/dist/BlockKit.js.map +1 -0
- package/dist/ChatId.d.ts +31 -0
- package/dist/ChatId.d.ts.map +1 -0
- package/dist/ChatId.js +36 -0
- package/dist/ChatId.js.map +1 -0
- package/dist/ChatLog.d.ts +109 -0
- package/dist/ChatLog.d.ts.map +1 -0
- package/dist/ChatLog.js +177 -0
- package/dist/ChatLog.js.map +1 -0
- package/dist/ChatView.d.ts +408 -0
- package/dist/ChatView.d.ts.map +1 -0
- package/dist/ChatView.js +443 -0
- package/dist/ChatView.js.map +1 -0
- package/dist/Chats.d.ts +110 -0
- package/dist/Chats.d.ts.map +1 -0
- package/dist/Chats.js +98 -0
- package/dist/Chats.js.map +1 -0
- package/dist/Compaction.d.ts +105 -0
- package/dist/Compaction.d.ts.map +1 -0
- package/dist/Compaction.js +126 -0
- package/dist/Compaction.js.map +1 -0
- package/dist/CurrentChat.d.ts +40 -0
- package/dist/CurrentChat.d.ts.map +1 -0
- package/dist/CurrentChat.js +33 -0
- package/dist/CurrentChat.js.map +1 -0
- package/dist/InputId.d.ts +38 -0
- package/dist/InputId.d.ts.map +1 -0
- package/dist/InputId.js +43 -0
- package/dist/InputId.js.map +1 -0
- package/dist/Instructions.d.ts +56 -0
- package/dist/Instructions.d.ts.map +1 -0
- package/dist/Instructions.js +50 -0
- package/dist/Instructions.js.map +1 -0
- package/dist/Memory.d.ts +190 -0
- package/dist/Memory.d.ts.map +1 -0
- package/dist/Memory.js +216 -0
- package/dist/Memory.js.map +1 -0
- package/dist/RunId.d.ts +41 -0
- package/dist/RunId.d.ts.map +1 -0
- package/dist/RunId.js +46 -0
- package/dist/RunId.js.map +1 -0
- package/dist/Skill.d.ts +72 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +58 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Specialist.d.ts +267 -0
- package/dist/Specialist.d.ts.map +1 -0
- package/dist/Specialist.js +119 -0
- package/dist/Specialist.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +107 -0
- package/dist/ToolDiscovery.d.ts.map +1 -0
- package/dist/ToolDiscovery.js +140 -0
- package/dist/ToolDiscovery.js.map +1 -0
- package/dist/ToolFailure.d.ts +66 -0
- package/dist/ToolFailure.d.ts.map +1 -0
- package/dist/ToolFailure.js +79 -0
- package/dist/ToolFailure.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/internal/chat.js +177 -0
- package/dist/internal/chat.js.map +1 -0
- package/dist/internal/events.d.ts +46 -0
- package/dist/internal/events.d.ts.map +1 -0
- package/dist/internal/events.js +65 -0
- package/dist/internal/events.js.map +1 -0
- package/dist/internal/history.js +147 -0
- package/dist/internal/history.js.map +1 -0
- package/dist/internal/instructions.js +11 -0
- package/dist/internal/instructions.js.map +1 -0
- package/dist/internal/loop.d.ts +73 -0
- package/dist/internal/loop.d.ts.map +1 -0
- package/dist/internal/loop.js +131 -0
- package/dist/internal/loop.js.map +1 -0
- package/dist/internal/schema.js +14 -0
- package/dist/internal/schema.js.map +1 -0
- package/dist/internal/skills.js +28 -0
- package/dist/internal/skills.js.map +1 -0
- package/dist/internal/specialists.js +272 -0
- package/dist/internal/specialists.js.map +1 -0
- package/dist/internal/telemetry.js +85 -0
- package/dist/internal/telemetry.js.map +1 -0
- package/dist/internal/toolkit.d.ts +62 -0
- package/dist/internal/toolkit.d.ts.map +1 -0
- package/dist/internal/toolkit.js +108 -0
- package/dist/internal/toolkit.js.map +1 -0
- package/dist/internal/tools.js +113 -0
- package/dist/internal/tools.js.map +1 -0
- package/dist/internal/turns.d.ts +64 -0
- package/dist/internal/turns.d.ts.map +1 -0
- package/dist/internal/turns.js +214 -0
- package/dist/internal/turns.js.map +1 -0
- package/dist/internal/uuid.js +45 -0
- package/dist/internal/uuid.js.map +1 -0
- package/dist/internal/writer.d.ts +12 -0
- package/dist/internal/writer.d.ts.map +1 -0
- package/package.json +52 -3
- package/src/AppEvent.ts +134 -0
- package/src/Assistant.ts +589 -0
- package/src/AssistantActivity.ts +150 -0
- package/src/AssistantEngine.ts +343 -0
- package/src/AssistantEvent.ts +569 -0
- package/src/AssistantLoop.ts +209 -0
- package/src/AssistantToolCall.ts +40 -0
- package/src/Block.ts +84 -0
- package/src/BlockKit.ts +298 -0
- package/src/ChatId.ts +36 -0
- package/src/ChatLog.ts +247 -0
- package/src/ChatView.ts +587 -0
- package/src/Chats.ts +122 -0
- package/src/Compaction.ts +207 -0
- package/src/CurrentChat.ts +47 -0
- package/src/InputId.ts +43 -0
- package/src/Instructions.ts +72 -0
- package/src/Memory.ts +276 -0
- package/src/RunId.ts +46 -0
- package/src/Skill.ts +91 -0
- package/src/Specialist.ts +355 -0
- package/src/ToolDiscovery.ts +179 -0
- package/src/ToolFailure.ts +74 -0
- package/src/index.ts +120 -0
- package/src/internal/chat.ts +323 -0
- package/src/internal/events.ts +100 -0
- package/src/internal/history.ts +294 -0
- package/src/internal/instructions.ts +9 -0
- package/src/internal/loop.ts +274 -0
- package/src/internal/schema.ts +25 -0
- package/src/internal/skills.ts +37 -0
- package/src/internal/specialists.ts +410 -0
- package/src/internal/telemetry.ts +125 -0
- package/src/internal/toolkit.ts +188 -0
- package/src/internal/tools.ts +159 -0
- package/src/internal/turns.ts +410 -0
- package/src/internal/uuid.ts +50 -0
- package/src/internal/writer.ts +12 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effect-assistant: a colleague assistant on Effect.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.1.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @since 0.1.0
|
|
9
|
+
*/
|
|
10
|
+
export * as AppEvent from "./AppEvent.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @since 0.1.0
|
|
14
|
+
*/
|
|
15
|
+
export * as Assistant from "./Assistant.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @since 0.1.0
|
|
19
|
+
*/
|
|
20
|
+
export * as AssistantActivity from "./AssistantActivity.ts"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export * as AssistantEngine from "./AssistantEngine.ts"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @since 0.1.0
|
|
29
|
+
*/
|
|
30
|
+
export * as AssistantEvent from "./AssistantEvent.ts"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @since 0.1.0
|
|
34
|
+
*/
|
|
35
|
+
export * as AssistantLoop from "./AssistantLoop.ts"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @since 0.1.0
|
|
39
|
+
*/
|
|
40
|
+
export * as AssistantToolCall from "./AssistantToolCall.ts"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @since 0.1.0
|
|
44
|
+
*/
|
|
45
|
+
export * as Block from "./Block.ts"
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @since 0.1.0
|
|
49
|
+
*/
|
|
50
|
+
export * as BlockKit from "./BlockKit.ts"
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @since 0.1.0
|
|
54
|
+
*/
|
|
55
|
+
export * as ChatId from "./ChatId.ts"
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @since 0.1.0
|
|
59
|
+
*/
|
|
60
|
+
export * as ChatLog from "./ChatLog.ts"
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @since 0.1.0
|
|
64
|
+
*/
|
|
65
|
+
export * as Chats from "./Chats.ts"
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @since 0.1.0
|
|
69
|
+
*/
|
|
70
|
+
export * as ChatView from "./ChatView.ts"
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @since 0.1.0
|
|
74
|
+
*/
|
|
75
|
+
export * as Compaction from "./Compaction.ts"
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @since 0.1.0
|
|
79
|
+
*/
|
|
80
|
+
export * as CurrentChat from "./CurrentChat.ts"
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @since 0.1.0
|
|
84
|
+
*/
|
|
85
|
+
export * as InputId from "./InputId.ts"
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @since 0.1.0
|
|
89
|
+
*/
|
|
90
|
+
export * as Instructions from "./Instructions.ts"
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @since 0.1.0
|
|
94
|
+
*/
|
|
95
|
+
export * as Memory from "./Memory.ts"
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @since 0.1.0
|
|
99
|
+
*/
|
|
100
|
+
export * as RunId from "./RunId.ts"
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @since 0.1.0
|
|
104
|
+
*/
|
|
105
|
+
export * as Skill from "./Skill.ts"
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @since 0.1.0
|
|
109
|
+
*/
|
|
110
|
+
export * as Specialist from "./Specialist.ts"
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @since 0.1.0
|
|
114
|
+
*/
|
|
115
|
+
export * as ToolDiscovery from "./ToolDiscovery.ts"
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @since 0.1.0
|
|
119
|
+
*/
|
|
120
|
+
export * as ToolFailure from "./ToolFailure.ts"
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import * as Cause from "effect/Cause"
|
|
2
|
+
import * as Context from "effect/Context"
|
|
3
|
+
import * as Deferred from "effect/Deferred"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Exit from "effect/Exit"
|
|
6
|
+
import * as Fiber from "effect/Fiber"
|
|
7
|
+
import * as FiberSet from "effect/FiberSet"
|
|
8
|
+
import * as Predicate from "effect/Predicate"
|
|
9
|
+
import * as PubSub from "effect/PubSub"
|
|
10
|
+
import * as Ref from "effect/Ref"
|
|
11
|
+
import * as Scope from "effect/Scope"
|
|
12
|
+
import * as Semaphore from "effect/Semaphore"
|
|
13
|
+
import * as Stream from "effect/Stream"
|
|
14
|
+
import * as SynchronizedRef from "effect/SynchronizedRef"
|
|
15
|
+
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
16
|
+
import type { ChatId } from "../ChatId.ts"
|
|
17
|
+
import { ChatLog } from "../ChatLog.ts"
|
|
18
|
+
import { CurrentChat } from "../CurrentChat.ts"
|
|
19
|
+
import * as Chats from "../Chats.ts"
|
|
20
|
+
import * as SpecialistService from "../Specialist.ts"
|
|
21
|
+
import * as AppEvents from "./events.ts"
|
|
22
|
+
import * as History from "./history.ts"
|
|
23
|
+
import * as Specialists from "./specialists.ts"
|
|
24
|
+
import * as Turns from "./turns.ts"
|
|
25
|
+
import type { Writer } from "./writer.ts"
|
|
26
|
+
|
|
27
|
+
const idOf = (event: { readonly _tag: string }): string | undefined =>
|
|
28
|
+
AssistantEvent.isLibraryEvent(event)
|
|
29
|
+
? AssistantEvent.isInput(event)
|
|
30
|
+
? event.id
|
|
31
|
+
: undefined
|
|
32
|
+
: Predicate.hasProperty(event, "id") && Predicate.isString(event.id)
|
|
33
|
+
? event.id
|
|
34
|
+
: undefined
|
|
35
|
+
|
|
36
|
+
const makeChannelPubSub = Effect.acquireRelease(PubSub.unbounded<AssistantEvent.Entry>(), PubSub.shutdown)
|
|
37
|
+
|
|
38
|
+
const makeHolding = Effect.fnUntraced(function* (keepAlive: (enabled: boolean) => Effect.Effect<void>) {
|
|
39
|
+
const holds = yield* SynchronizedRef.make(0)
|
|
40
|
+
|
|
41
|
+
const acquire = SynchronizedRef.updateEffect(
|
|
42
|
+
holds,
|
|
43
|
+
Effect.fnUntraced(function* (n: number) {
|
|
44
|
+
if (n !== 0) return n + 1
|
|
45
|
+
|
|
46
|
+
yield* keepAlive(true)
|
|
47
|
+
|
|
48
|
+
return 1
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const release = SynchronizedRef.updateEffect(
|
|
53
|
+
holds,
|
|
54
|
+
Effect.fnUntraced(function* (n: number) {
|
|
55
|
+
if (n !== 1) return n - 1
|
|
56
|
+
|
|
57
|
+
yield* keepAlive(false)
|
|
58
|
+
|
|
59
|
+
return 0
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return (effect: Effect.Effect<void>) =>
|
|
64
|
+
Effect.acquireUseRelease(
|
|
65
|
+
acquire,
|
|
66
|
+
() => effect,
|
|
67
|
+
// An interrupted effect (a shutdown) keeps its hold, so the chat starts again and picks up where it was cut off.
|
|
68
|
+
(_, exit) => (Exit.hasInterrupts(exit) ? Effect.void : release)
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/** @internal */
|
|
73
|
+
export interface Chat {
|
|
74
|
+
readonly submit: (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) => Effect.Effect<void>
|
|
75
|
+
readonly submitInBackground: (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) => Effect.Effect<void>
|
|
76
|
+
// For inputs no turn has read, like a report stored during a shutdown.
|
|
77
|
+
readonly resume: Effect.Effect<void>
|
|
78
|
+
readonly stream: (after: number) => Stream.Stream<AssistantEvent.Entry>
|
|
79
|
+
// Stops what works in the chat and ends its subscribers' streams; its log stays.
|
|
80
|
+
readonly delete: Effect.Effect<void>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isChatDeleted = (entry: AssistantEvent.Entry) => Predicate.isTagged(entry.event, "ChatDeleted")
|
|
84
|
+
|
|
85
|
+
const deletedChat = (entries: ReadonlyArray<AssistantEvent.Entry>): Chat => ({
|
|
86
|
+
submit: () => Effect.void,
|
|
87
|
+
submitInBackground: () => Effect.void,
|
|
88
|
+
resume: Effect.void,
|
|
89
|
+
stream: (after) => Stream.fromIterable(entries.filter((entry) => entry.seq > after)),
|
|
90
|
+
delete: Effect.void
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const refused = (input: { readonly _tag: string }, why: string) =>
|
|
94
|
+
Effect.logDebug(`assistant chat ${why}: ${input._tag} not stored`)
|
|
95
|
+
|
|
96
|
+
/** @internal */
|
|
97
|
+
export const make = Effect.fnUntraced(function* (options: {
|
|
98
|
+
readonly registration: Turns.Registration
|
|
99
|
+
readonly chatId: ChatId
|
|
100
|
+
readonly keepAlive: (enabled: boolean) => Effect.Effect<void>
|
|
101
|
+
}) {
|
|
102
|
+
const { registration, chatId } = options
|
|
103
|
+
const { app } = registration.definition
|
|
104
|
+
const { wakes } = app
|
|
105
|
+
const storage = yield* ChatLog
|
|
106
|
+
const chats = yield* Chats.Chats
|
|
107
|
+
const initial = yield* storage.read(chatId)
|
|
108
|
+
|
|
109
|
+
if (initial.some(isChatDeleted)) return deletedChat(initial)
|
|
110
|
+
|
|
111
|
+
const channel = yield* makeChannelPubSub
|
|
112
|
+
const writes = yield* Semaphore.make(1)
|
|
113
|
+
const turnsLock = yield* Semaphore.make(1)
|
|
114
|
+
const holding = yield* makeHolding(options.keepAlive)
|
|
115
|
+
const deleted = yield* Deferred.make<undefined>()
|
|
116
|
+
// So deleting the chat stops all its work: its turns, runs, and the builds' resources.
|
|
117
|
+
const working = yield* Scope.fork(yield* Effect.scope)
|
|
118
|
+
const inWorking = Effect.provideService(Scope.Scope, working)
|
|
119
|
+
const background = yield* inWorking(FiberSet.make())
|
|
120
|
+
const requests = yield* inWorking(FiberSet.make())
|
|
121
|
+
|
|
122
|
+
const isDeleted = Deferred.isDone(deleted)
|
|
123
|
+
|
|
124
|
+
// The chat is its log's only writer, so it reads the log once and turns read memory.
|
|
125
|
+
const entries = yield* Ref.make(initial)
|
|
126
|
+
|
|
127
|
+
// Every writer goes through here, one at a time, so each append is numbered after the last. After a delete it
|
|
128
|
+
// stores nothing.
|
|
129
|
+
const write = Effect.fnUntraced(function* (
|
|
130
|
+
events: ReadonlyArray<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent>
|
|
131
|
+
) {
|
|
132
|
+
const unknown = events.find((event) => !AssistantEvent.isLibraryEvent(event) && !AppEvents.isRegistered(app, event))
|
|
133
|
+
|
|
134
|
+
if (unknown !== undefined) {
|
|
135
|
+
return yield* Effect.die(`${unknown._tag} isn't one of the ${registration.definition.name} assistant's events.`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (yield* isDeleted) return []
|
|
139
|
+
|
|
140
|
+
return yield* stored(events)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Uninterruptible: a stored append must reach memory and the subscribers too, or a turn misses it and replies twice.
|
|
144
|
+
const stored = Effect.fnUntraced(function* (
|
|
145
|
+
events: ReadonlyArray<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent>
|
|
146
|
+
) {
|
|
147
|
+
const stored = yield* storage.append(chatId, events)
|
|
148
|
+
|
|
149
|
+
yield* Ref.update(entries, (all) => [...all, ...stored])
|
|
150
|
+
yield* PubSub.publishAll(channel, stored)
|
|
151
|
+
|
|
152
|
+
return stored
|
|
153
|
+
}, Effect.uninterruptible)
|
|
154
|
+
|
|
155
|
+
const chat: Writer = {
|
|
156
|
+
chatId,
|
|
157
|
+
entries: Ref.get(entries),
|
|
158
|
+
append: (events) => Semaphore.withPermit(writes)(write(events))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const toStored = (event: {
|
|
162
|
+
readonly _tag: string
|
|
163
|
+
}): Effect.Effect<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent> =>
|
|
164
|
+
AssistantEvent.isLibraryEvent(event)
|
|
165
|
+
? Effect.succeed(event)
|
|
166
|
+
: AppEvents.isRegistered(app, event)
|
|
167
|
+
? AppEvents.encode(app, event)
|
|
168
|
+
: Effect.die(`${event._tag} isn't one of the ${registration.definition.name} assistant's events.`)
|
|
169
|
+
|
|
170
|
+
// What handlers, tools, and runs see: the assistant's own events decoded, and encoded when appended.
|
|
171
|
+
const handle: Turns.TypedChat = {
|
|
172
|
+
chatId,
|
|
173
|
+
entries: Effect.map(chat.entries, (all) => AppEvents.decodeEntries(app, all)),
|
|
174
|
+
append: (...events) => Effect.asVoid(Effect.flatMap(Effect.forEach(events, toStored), chat.append))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A chat the app deleted is deleted here too, which stops what works in it: a turn that checks stops there.
|
|
178
|
+
const checkAvailable: Effect.Effect<boolean> = chats.get(chatId).pipe(
|
|
179
|
+
Effect.as(true),
|
|
180
|
+
Effect.catchTag("GetChatError", (unavailable) =>
|
|
181
|
+
unavailable.reason === "Deleted"
|
|
182
|
+
? Effect.as(
|
|
183
|
+
Effect.suspend(() => remove),
|
|
184
|
+
false
|
|
185
|
+
)
|
|
186
|
+
: Effect.succeed(false)
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const store = Effect.fnUntraced(function* (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) {
|
|
191
|
+
if (yield* isDeleted) return yield* refused(input, "is deleted")
|
|
192
|
+
|
|
193
|
+
if (!(yield* checkAvailable)) return yield* refused(input, "is unavailable")
|
|
194
|
+
|
|
195
|
+
// Checked under the write lock: a retry that arrives while the first submit is storing is stored once.
|
|
196
|
+
yield* Semaphore.withPermit(writes)(
|
|
197
|
+
Effect.gen(function* () {
|
|
198
|
+
if (yield* isDeleted) return yield* refused(input, "is deleted")
|
|
199
|
+
|
|
200
|
+
const id = idOf(input)
|
|
201
|
+
|
|
202
|
+
if (id !== undefined && (yield* Ref.get(entries)).some(({ event }) => idOf(event) === id)) return
|
|
203
|
+
|
|
204
|
+
yield* write([input])
|
|
205
|
+
})
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// The handlers' builds run in the chat's working scope: what they set up lives until the chat closes or is deleted.
|
|
210
|
+
const handler = yield* inWorking(Effect.provideService(registration.open(handle), CurrentChat, handle))
|
|
211
|
+
|
|
212
|
+
const specialists = yield* Effect.forEach(
|
|
213
|
+
registration.specialists,
|
|
214
|
+
Effect.fnUntraced(function* ({ specialist, built }) {
|
|
215
|
+
const handler = yield* inWorking(Effect.provideService(built.open, CurrentChat, handle))
|
|
216
|
+
|
|
217
|
+
return { specialist, handler }
|
|
218
|
+
})
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const inBackground = (effect: Effect.Effect<void>) => FiberSet.run(background, holding(effect)).pipe(Effect.asVoid)
|
|
222
|
+
|
|
223
|
+
// A request's turns run in the working scope too, so a delete stops them; the request ends quietly then.
|
|
224
|
+
const inRequest = Effect.fnUntraced(function* (effect: Effect.Effect<void>) {
|
|
225
|
+
const fiber = yield* FiberSet.run(requests, effect)
|
|
226
|
+
const exit = yield* Fiber.await(fiber).pipe(Effect.onInterrupt(() => Fiber.interrupt(fiber)))
|
|
227
|
+
|
|
228
|
+
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
|
|
229
|
+
return yield* Effect.failCause(exit.cause)
|
|
230
|
+
}
|
|
231
|
+
}, Effect.orDie)
|
|
232
|
+
|
|
233
|
+
const drain: Effect.Effect<void> = Semaphore.withPermit(turnsLock)(
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries, wakes)) {
|
|
236
|
+
yield* Turns.run({ registration, handler, chat, services, checkOpen: Effect.asVoid(checkAvailable) })
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const runner = yield* inWorking(
|
|
242
|
+
Specialists.makeRunner({
|
|
243
|
+
chat,
|
|
244
|
+
handle,
|
|
245
|
+
specialists,
|
|
246
|
+
model: registration.model,
|
|
247
|
+
holding,
|
|
248
|
+
report: Effect.fnUntraced(function* (finished) {
|
|
249
|
+
yield* store(finished)
|
|
250
|
+
yield* inBackground(drain)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
const services = Context.make(CurrentChat, handle).pipe(
|
|
256
|
+
Context.add(SpecialistService.Runner, runner),
|
|
257
|
+
Context.merge(registration.model)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
const remove = Effect.gen(function* () {
|
|
261
|
+
// Under the write lock: an append in flight is stored before the `ChatDeleted`; none comes after.
|
|
262
|
+
const first = yield* Semaphore.withPermit(writes)(
|
|
263
|
+
Effect.gen(function* () {
|
|
264
|
+
if (yield* isDeleted) return false
|
|
265
|
+
|
|
266
|
+
yield* stored([AssistantEvent.ChatDeleted.make({})])
|
|
267
|
+
|
|
268
|
+
return yield* Deferred.succeed(deleted, undefined)
|
|
269
|
+
})
|
|
270
|
+
).pipe(Effect.uninterruptible)
|
|
271
|
+
|
|
272
|
+
if (!first) return
|
|
273
|
+
|
|
274
|
+
// Its own fiber: a tool that deletes its own chat is in the working scope, and closing it interrupts that tool.
|
|
275
|
+
// Interrupted work keeps its hold (see `makeHolding`), so the chat lets go of it here.
|
|
276
|
+
const closing = yield* Effect.forkDetach(
|
|
277
|
+
Effect.gen(function* () {
|
|
278
|
+
yield* Scope.close(working, Exit.void)
|
|
279
|
+
yield* options.keepAlive(false)
|
|
280
|
+
})
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
yield* Fiber.await(closing)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// A chat the app deleted while it was closed is deleted now, so its subscribers hear it.
|
|
287
|
+
yield* checkAvailable
|
|
288
|
+
|
|
289
|
+
const chatApi: Chat = {
|
|
290
|
+
submit: Effect.fnUntraced(function* (input) {
|
|
291
|
+
yield* store(input)
|
|
292
|
+
yield* inRequest(drain)
|
|
293
|
+
}),
|
|
294
|
+
submitInBackground: Effect.fnUntraced(function* (input) {
|
|
295
|
+
yield* store(input)
|
|
296
|
+
yield* inBackground(drain)
|
|
297
|
+
}),
|
|
298
|
+
resume: Effect.gen(function* () {
|
|
299
|
+
if (History.needsTurn(yield* chat.entries, wakes)) yield* inBackground(drain)
|
|
300
|
+
}),
|
|
301
|
+
stream: (after) =>
|
|
302
|
+
Stream.unwrap(
|
|
303
|
+
Effect.gen(function* () {
|
|
304
|
+
// Subscribes before it reads, so nothing appended in between is missed.
|
|
305
|
+
const subscription = yield* PubSub.subscribe(channel)
|
|
306
|
+
const all = yield* chat.entries
|
|
307
|
+
const stored = all.filter((entry) => entry.seq > after)
|
|
308
|
+
const last = stored.at(-1)?.seq ?? after
|
|
309
|
+
|
|
310
|
+
// Deleted: what the reader hasn't seen, then the end. Otherwise the stream ends with the `ChatDeleted`.
|
|
311
|
+
if (all.some(isChatDeleted)) return Stream.fromIterable(stored)
|
|
312
|
+
|
|
313
|
+
return Stream.concat(
|
|
314
|
+
Stream.fromIterable(stored),
|
|
315
|
+
Stream.fromSubscription(subscription).pipe(Stream.filter((entry) => entry.seq > last))
|
|
316
|
+
).pipe(Stream.takeUntil(isChatDeleted))
|
|
317
|
+
})
|
|
318
|
+
),
|
|
319
|
+
delete: remove
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return chatApi
|
|
323
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Option from "effect/Option"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import type * as AppEvent from "../AppEvent.ts"
|
|
5
|
+
import type * as Assistant from "../Assistant.ts"
|
|
6
|
+
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
7
|
+
import { toJsonText } from "./schema.ts"
|
|
8
|
+
|
|
9
|
+
/** @internal */
|
|
10
|
+
export interface AppEvents {
|
|
11
|
+
readonly byTag: ReadonlyMap<string, Registered>
|
|
12
|
+
readonly wakes: ReadonlySet<string>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Registered {
|
|
16
|
+
readonly schema: AppEvent.EventSchema
|
|
17
|
+
readonly render: AppEvent.Render<{ readonly _tag: string }> | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @internal */
|
|
21
|
+
export const make = (events: ReadonlyArray<AppEvent.Any>): AppEvents => {
|
|
22
|
+
const byTag = new Map(
|
|
23
|
+
events.map((event): [string, Registered] => [event.tag, { schema: event.schema, render: event.render }])
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
for (const tag of byTag.keys()) {
|
|
27
|
+
if (AssistantEvent.isLibraryEvent({ _tag: tag })) throw new Error(`${tag} is one of the library's events.`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (byTag.size !== events.length) throw new Error("An assistant's events have different tags.")
|
|
31
|
+
|
|
32
|
+
return { byTag, wakes: new Set(events.flatMap((event) => (event.wakes ? [event.tag] : []))) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @internal */
|
|
36
|
+
export const encode = Effect.fnUntraced(function* (app: AppEvents, event: { readonly _tag: string }) {
|
|
37
|
+
const registered = app.byTag.get(event._tag)
|
|
38
|
+
|
|
39
|
+
// The types only let registered events through, so one with no schema here is a defect.
|
|
40
|
+
if (registered === undefined) return yield* Effect.die(`${event._tag} isn't one of this assistant's events.`)
|
|
41
|
+
|
|
42
|
+
const json = yield* Schema.encodeUnknownEffect(Schema.toCodecJson(registered.schema))(event)
|
|
43
|
+
|
|
44
|
+
return yield* Schema.decodeUnknownEffect(AssistantEvent.StoredAppEvent)(json)
|
|
45
|
+
}, Effect.orDie)
|
|
46
|
+
|
|
47
|
+
/** @internal */
|
|
48
|
+
export const isRegistered = (app: AppEvents, event: { readonly _tag: string }): boolean => app.byTag.has(event._tag)
|
|
49
|
+
|
|
50
|
+
// An app event that doesn't decode (say, one from an older version of its schema) is left out.
|
|
51
|
+
/** @internal */
|
|
52
|
+
export const decodeEntry = (app: AppEvents, entry: AssistantEvent.AnyEntry): Option.Option<Assistant.Entry<any>> => {
|
|
53
|
+
const { event } = entry
|
|
54
|
+
|
|
55
|
+
if (AssistantEvent.isLibraryEvent(event)) return Option.some({ ...toEntry(entry), event })
|
|
56
|
+
|
|
57
|
+
const registered = app.byTag.get(event._tag)
|
|
58
|
+
|
|
59
|
+
if (registered === undefined) return Option.none()
|
|
60
|
+
|
|
61
|
+
return Option.map(Schema.decodeUnknownOption(Schema.toCodecJson(registered.schema))(event), (decoded) => ({
|
|
62
|
+
...toEntry(entry),
|
|
63
|
+
event: decoded
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @internal */
|
|
68
|
+
export const decodeEntries = (
|
|
69
|
+
app: AppEvents,
|
|
70
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>
|
|
71
|
+
): ReadonlyArray<Assistant.Entry<any>> => entries.flatMap((entry) => Option.toArray(decodeEntry(app, entry)))
|
|
72
|
+
|
|
73
|
+
/** @internal */
|
|
74
|
+
export type DecodedEntries = ReturnType<typeof decodeEntries>
|
|
75
|
+
|
|
76
|
+
const decodeStored = Schema.decodeUnknownOption(AssistantEvent.StoredAppEvent)
|
|
77
|
+
|
|
78
|
+
const toEntry = (entry: AssistantEvent.AnyEntry) => ({
|
|
79
|
+
chatId: entry.chatId,
|
|
80
|
+
seq: entry.seq,
|
|
81
|
+
at: entry.at
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/** @internal */
|
|
85
|
+
export const read = (app: AppEvents, event: { readonly _tag: string }): string | undefined => {
|
|
86
|
+
const registered = app.byTag.get(event._tag)
|
|
87
|
+
|
|
88
|
+
if (registered?.render === undefined) return undefined
|
|
89
|
+
|
|
90
|
+
if (registered.render !== "json") return registered.render(event)
|
|
91
|
+
|
|
92
|
+
const stored = Schema.encodeUnknownOption(Schema.toCodecJson(registered.schema))(event).pipe(
|
|
93
|
+
Option.flatMap(decodeStored)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return Option.match(stored, {
|
|
97
|
+
onNone: () => undefined,
|
|
98
|
+
onSome: ({ _tag, ...fields }) => `<${_tag}>\n${toJsonText(fields)}\n</${_tag}>`
|
|
99
|
+
})
|
|
100
|
+
}
|