@jpowersdev/effect-pi 0.1.0 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +9 -1
  3. package/dist/Session.d.ts +1 -1
  4. package/dist/Session.d.ts.map +1 -1
  5. package/dist/Session.js +1 -1
  6. package/dist/Session.js.map +1 -1
  7. package/dist/internal/ClusterSessions.d.ts +10 -1
  8. package/dist/internal/ClusterSessions.d.ts.map +1 -1
  9. package/dist/internal/ClusterSessions.js +2 -2
  10. package/dist/internal/ClusterSessions.js.map +1 -1
  11. package/dist/internal/Pi.d.ts +1 -18
  12. package/dist/internal/Pi.d.ts.map +1 -1
  13. package/dist/internal/Pi.js +28 -3
  14. package/dist/internal/Pi.js.map +1 -1
  15. package/dist/internal/Protocol.d.ts +20 -2
  16. package/dist/internal/Protocol.d.ts.map +1 -1
  17. package/dist/internal/Protocol.js +1 -1
  18. package/dist/internal/Protocol.js.map +1 -1
  19. package/dist/internal/Session.d.ts +83 -7
  20. package/dist/internal/Session.d.ts.map +1 -1
  21. package/dist/internal/Session.js +108 -7
  22. package/dist/internal/Session.js.map +1 -1
  23. package/dist-examples/cluster-session.js +4 -2
  24. package/dist-examples/cluster-session.js.map +1 -1
  25. package/dist-examples/local-session-pool.js +4 -2
  26. package/dist-examples/local-session-pool.js.map +1 -1
  27. package/dist-examples/single-session.js +4 -2
  28. package/dist-examples/single-session.js.map +1 -1
  29. package/docs/reference.md +14 -4
  30. package/examples/cluster-session.ts +4 -2
  31. package/examples/local-session-pool.ts +4 -2
  32. package/examples/single-session.ts +4 -2
  33. package/package.json +16 -24
  34. package/src/Session.ts +10 -2
  35. package/src/internal/ClusterSessions.ts +6 -3
  36. package/src/internal/Pi.ts +33 -5
  37. package/src/internal/Protocol.ts +1 -1
  38. package/src/internal/Session.ts +165 -11
@@ -1,7 +1,12 @@
1
1
  import type * as Pi from "@earendil-works/pi-coding-agent"
2
- import type * as Effect from "effect/Effect"
2
+ import * as Deferred from "effect/Deferred"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Exit from "effect/Exit"
5
+ import * as PubSub from "effect/PubSub"
6
+ import * as Queue from "effect/Queue"
3
7
  import * as Schema from "effect/Schema"
4
- import type * as Stream from "effect/Stream"
8
+ import * as Stream from "effect/Stream"
9
+ import * as Take from "effect/Take"
5
10
 
6
11
  /** A Pi session id accepted by Pi's SessionManager. */
7
12
  export const Id = Schema.String.check(
@@ -30,32 +35,71 @@ export class PromptResult extends Schema.Class<PromptResult>("@jpowersdev/effect
30
35
  costUsd: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))
31
36
  }) {}
32
37
 
33
- const StatusEvent = Schema.TaggedStruct("Status", {
38
+ export const StatusEvent = Schema.TaggedStruct("Status", {
34
39
  sequence: Schema.Natural,
35
40
  status: Status
36
41
  })
37
- const TextDeltaEvent = Schema.TaggedStruct("TextDelta", {
42
+ export type StatusEvent = typeof StatusEvent.Type
43
+
44
+ export const MessageStart = Schema.TaggedStruct("MessageStart", {
45
+ sequence: Schema.Natural,
46
+ messageSequence: Schema.Natural
47
+ })
48
+ export type MessageStart = typeof MessageStart.Type
49
+
50
+ export const MessageDelta = Schema.TaggedStruct("MessageDelta", {
38
51
  sequence: Schema.Natural,
52
+ messageSequence: Schema.Natural,
39
53
  delta: Schema.String
40
54
  })
41
- const ToolStartedEvent = Schema.TaggedStruct("ToolStarted", {
55
+ export type MessageDelta = typeof MessageDelta.Type
56
+
57
+ export const MessageEnd = Schema.TaggedStruct("MessageEnd", {
58
+ sequence: Schema.Natural,
59
+ messageSequence: Schema.Natural,
60
+ content: Schema.String,
61
+ stopReason: Schema.String
62
+ })
63
+ export type MessageEnd = typeof MessageEnd.Type
64
+
65
+ export const MessagePart = Schema.Union([MessageStart, MessageDelta, MessageEnd])
66
+ export type MessagePart = typeof MessagePart.Type
67
+
68
+ export const ToolStartedEvent = Schema.TaggedStruct("ToolStarted", {
42
69
  sequence: Schema.Natural,
43
70
  toolName: Schema.String
44
71
  })
45
- const ToolFinishedEvent = Schema.TaggedStruct("ToolFinished", {
72
+ export type ToolStartedEvent = typeof ToolStartedEvent.Type
73
+
74
+ export const ToolFinishedEvent = Schema.TaggedStruct("ToolFinished", {
46
75
  sequence: Schema.Natural,
47
76
  toolName: Schema.String,
48
77
  isError: Schema.Boolean
49
78
  })
79
+ export type ToolFinishedEvent = typeof ToolFinishedEvent.Type
50
80
 
51
- /** Stable, serializable subset of Pi's live session events. */
52
- export const Event = Schema.Union([
81
+ /** Flat, serializable event protocol used across Cluster transport. */
82
+ export const WireEvent = Schema.Union([
53
83
  StatusEvent,
54
- TextDeltaEvent,
84
+ MessageStart,
85
+ MessageDelta,
86
+ MessageEnd,
55
87
  ToolStartedEvent,
56
88
  ToolFinishedEvent
57
89
  ])
58
- export type Event = typeof Event.Type
90
+ export type WireEvent = typeof WireEvent.Type
91
+
92
+ /** One finite assistant response within a possibly multi-turn tool-using prompt. */
93
+ export interface AssistantMessage {
94
+ readonly _tag: "AssistantMessage"
95
+ readonly sequence: number
96
+ readonly stream: Stream.Stream<MessagePart, Error>
97
+ /** Await the authoritative text carried by MessageEnd. */
98
+ readonly content: Effect.Effect<string, Error>
99
+ }
100
+
101
+ /** High-level live event stream exposed by a Session. */
102
+ export type Event = StatusEvent | AssistantMessage | ToolStartedEvent | ToolFinishedEvent
59
103
 
60
104
  export const Operation = Schema.Literals([
61
105
  "abort",
@@ -75,6 +119,111 @@ export class Error extends Schema.TaggedError<Error>()("SessionError", {
75
119
  message: Schema.String
76
120
  }) {}
77
121
 
122
+ interface ActiveMessage {
123
+ readonly messageSequence: number
124
+ readonly parts: Queue.Queue<Take.Take<MessagePart, Error>>
125
+ readonly content: Deferred.Deferred<string, Error>
126
+ }
127
+
128
+ /** Reconstruct high-level assistant-message resources from serializable event frames. */
129
+ export const eventsFromWire = (
130
+ id: Id,
131
+ wireEvents: Stream.Stream<WireEvent, Error>
132
+ ): Stream.Stream<Event, Error> => Stream.unwrap(Effect.gen(function* () {
133
+ const output = yield* Effect.acquireRelease(
134
+ PubSub.sliding<Take.Take<Event, Error>>({ capacity: 1024, replay: 1024 }),
135
+ PubSub.shutdown
136
+ )
137
+ let active: ActiveMessage | undefined
138
+
139
+ const finishActive = (error: Error) => {
140
+ const current = active
141
+ active = undefined
142
+ if (current === undefined) return Effect.void
143
+ return Effect.all([
144
+ Queue.offer(current.parts, Exit.fail(error)),
145
+ Deferred.fail(current.content, error)
146
+ ], { discard: true })
147
+ }
148
+
149
+ const consumeFrames: Effect.Effect<void, Error> = wireEvents.pipe(
150
+ Stream.runForEach((event) => Effect.gen(function* () {
151
+ switch (event._tag) {
152
+ case "MessageStart": {
153
+ yield* finishActive(new Error({
154
+ sessionId: id,
155
+ operation: "events",
156
+ message: "A new assistant message started before the previous message ended"
157
+ }))
158
+ const parts = yield* Queue.unbounded<Take.Take<MessagePart, Error>>()
159
+ const content = yield* Deferred.make<string, Error>()
160
+ active = { messageSequence: event.messageSequence, parts, content }
161
+ yield* PubSub.publish(output, [{
162
+ _tag: "AssistantMessage",
163
+ sequence: event.sequence,
164
+ stream: Stream.fromQueue(parts).pipe(Stream.flattenTake),
165
+ content: Deferred.await(content)
166
+ }])
167
+ yield* Queue.offer(parts, [event])
168
+ return
169
+ }
170
+ case "MessageDelta":
171
+ if (active?.messageSequence === event.messageSequence) {
172
+ yield* Queue.offer(active.parts, [event])
173
+ }
174
+ return
175
+ case "MessageEnd":
176
+ if (active?.messageSequence === event.messageSequence) {
177
+ const completed = active
178
+ active = undefined
179
+ yield* Queue.offer(completed.parts, [event])
180
+ yield* Deferred.succeed(completed.content, event.content)
181
+ yield* Queue.offer(completed.parts, Exit.void)
182
+ }
183
+ return
184
+ default:
185
+ if (event._tag === "Status" && event.status === "idle" && active !== undefined) {
186
+ yield* finishActive(new Error({
187
+ sessionId: id,
188
+ operation: "events",
189
+ message: "The assistant message ended with missing event frames"
190
+ }))
191
+ }
192
+ yield* PubSub.publish(output, [event])
193
+ }
194
+ }))
195
+ )
196
+ const consume = consumeFrames.pipe(
197
+ Effect.onExit((exit) => Effect.gen(function* () {
198
+ if (active !== undefined) {
199
+ if (Exit.isFailure(exit)) {
200
+ yield* Queue.offer(active.parts, exit)
201
+ yield* Deferred.failCause(active.content, exit.cause)
202
+ } else {
203
+ yield* finishActive(new Error({
204
+ sessionId: id,
205
+ operation: "events",
206
+ message: "The event stream ended before the assistant message completed"
207
+ }))
208
+ }
209
+ }
210
+ yield* PubSub.publish(output, exit)
211
+ }))
212
+ )
213
+
214
+ yield* Effect.forkScoped(consume, { startImmediately: true })
215
+
216
+ return Stream.fromPubSubTake(output)
217
+ }))
218
+
219
+ /** Flatten high-level events into the serializable Cluster wire protocol. */
220
+ export const eventsToWire = (
221
+ events: Stream.Stream<Event, Error>
222
+ ): Stream.Stream<WireEvent, Error> => events.pipe(
223
+ Stream.flatMap((event): Stream.Stream<WireEvent, Error> =>
224
+ event._tag === "AssistantMessage" ? event.stream : Stream.fromIterable<WireEvent>([event]))
225
+ )
226
+
78
227
  /** One scoped Pi session, whether local or represented by a cluster client. */
79
228
  export interface Session {
80
229
  readonly id: Id
@@ -83,7 +232,12 @@ export interface Session {
83
232
  readonly prompt: (text: string) => Effect.Effect<PromptResult, Error>
84
233
  /** Interrupt the active prompt; queued prompts remain eligible to run. */
85
234
  readonly abort: Effect.Effect<void, Error>
86
- /** Ephemeral, bounded stream. Sequence gaps indicate lost events; restoration resets sequence numbers. */
235
+ /**
236
+ * Ephemeral, bounded activity stream. Each AssistantMessage owns one finite,
237
+ * single-consumer part stream and an independently awaitable final content effect.
238
+ * Sequence numbers are shared by top-level events and nested message parts;
239
+ * restoration resets them.
240
+ */
87
241
  readonly events: Stream.Stream<Event, Error>
88
242
  /** Current serialization, not an acknowledgement that the backing store has been flushed. */
89
243
  readonly jsonl: Effect.Effect<string, Error>