@jpowersdev/effect-pi 0.1.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/CHANGELOG.md +15 -0
- package/CONTRIBUTING.md +40 -0
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/ClusterSessions.d.ts +2 -0
- package/dist/ClusterSessions.d.ts.map +1 -0
- package/dist/ClusterSessions.js +2 -0
- package/dist/ClusterSessions.js.map +1 -0
- package/dist/LocalSessions.d.ts +2 -0
- package/dist/LocalSessions.d.ts.map +1 -0
- package/dist/LocalSessions.js +2 -0
- package/dist/LocalSessions.js.map +1 -0
- package/dist/ModelRuntime.d.ts +2 -0
- package/dist/ModelRuntime.d.ts.map +1 -0
- package/dist/ModelRuntime.js +2 -0
- package/dist/ModelRuntime.js.map +1 -0
- package/dist/ResourceLoader.d.ts +2 -0
- package/dist/ResourceLoader.d.ts.map +1 -0
- package/dist/ResourceLoader.js +2 -0
- package/dist/ResourceLoader.js.map +1 -0
- package/dist/Session.d.ts +3 -0
- package/dist/Session.d.ts.map +1 -0
- package/dist/Session.js +3 -0
- package/dist/Session.js.map +1 -0
- package/dist/Sessions.d.ts +14 -0
- package/dist/Sessions.d.ts.map +1 -0
- package/dist/Sessions.js +5 -0
- package/dist/Sessions.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/ClusterSessions.d.ts +44 -0
- package/dist/internal/ClusterSessions.d.ts.map +1 -0
- package/dist/internal/ClusterSessions.js +72 -0
- package/dist/internal/ClusterSessions.js.map +1 -0
- package/dist/internal/LocalSessions.d.ts +19 -0
- package/dist/internal/LocalSessions.d.ts.map +1 -0
- package/dist/internal/LocalSessions.js +25 -0
- package/dist/internal/LocalSessions.js.map +1 -0
- package/dist/internal/ModelRuntime.d.ts +42 -0
- package/dist/internal/ModelRuntime.d.ts.map +1 -0
- package/dist/internal/ModelRuntime.js +66 -0
- package/dist/internal/ModelRuntime.js.map +1 -0
- package/dist/internal/Persistence.d.ts +12 -0
- package/dist/internal/Persistence.d.ts.map +1 -0
- package/dist/internal/Persistence.js +26 -0
- package/dist/internal/Persistence.js.map +1 -0
- package/dist/internal/Pi.d.ts +51 -0
- package/dist/internal/Pi.d.ts.map +1 -0
- package/dist/internal/Pi.js +258 -0
- package/dist/internal/Pi.js.map +1 -0
- package/dist/internal/Protocol.d.ts +41 -0
- package/dist/internal/Protocol.d.ts.map +1 -0
- package/dist/internal/Protocol.js +33 -0
- package/dist/internal/Protocol.js.map +1 -0
- package/dist/internal/ResourceLoader.d.ts +46 -0
- package/dist/internal/ResourceLoader.d.ts.map +1 -0
- package/dist/internal/ResourceLoader.js +77 -0
- package/dist/internal/ResourceLoader.js.map +1 -0
- package/dist/internal/Session.d.ts +82 -0
- package/dist/internal/Session.d.ts.map +1 -0
- package/dist/internal/Session.js +63 -0
- package/dist/internal/Session.js.map +1 -0
- package/dist-examples/cluster-session.d.ts +2 -0
- package/dist-examples/cluster-session.d.ts.map +1 -0
- package/dist-examples/cluster-session.js +77 -0
- package/dist-examples/cluster-session.js.map +1 -0
- package/dist-examples/local-session-pool.d.ts +2 -0
- package/dist-examples/local-session-pool.d.ts.map +1 -0
- package/dist-examples/local-session-pool.js +60 -0
- package/dist-examples/local-session-pool.js.map +1 -0
- package/dist-examples/single-session.d.ts +2 -0
- package/dist-examples/single-session.d.ts.map +1 -0
- package/dist-examples/single-session.js +49 -0
- package/dist-examples/single-session.js.map +1 -0
- package/docs/reference.md +248 -0
- package/examples/README.md +111 -0
- package/examples/cluster-session.ts +131 -0
- package/examples/local-session-pool.ts +107 -0
- package/examples/single-session.ts +85 -0
- package/package.json +62 -0
- package/src/ClusterSessions.ts +6 -0
- package/src/LocalSessions.ts +1 -0
- package/src/ModelRuntime.ts +10 -0
- package/src/ResourceLoader.ts +13 -0
- package/src/Session.ts +14 -0
- package/src/Sessions.ts +17 -0
- package/src/index.ts +6 -0
- package/src/internal/ClusterSessions.ts +126 -0
- package/src/internal/LocalSessions.ts +45 -0
- package/src/internal/ModelRuntime.ts +105 -0
- package/src/internal/Persistence.ts +40 -0
- package/src/internal/Pi.ts +346 -0
- package/src/internal/Protocol.ts +39 -0
- package/src/internal/ResourceLoader.ts +128 -0
- package/src/internal/Session.ts +109 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import * as Pi from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Exit from "effect/Exit"
|
|
4
|
+
import * as Fiber from "effect/Fiber"
|
|
5
|
+
import * as FileSystem from "effect/FileSystem"
|
|
6
|
+
import * as Path from "effect/Path"
|
|
7
|
+
import * as PubSub from "effect/PubSub"
|
|
8
|
+
import * as Queue from "effect/Queue"
|
|
9
|
+
import * as Schema from "effect/Schema"
|
|
10
|
+
import * as Scope from "effect/Scope"
|
|
11
|
+
import * as Semaphore from "effect/Semaphore"
|
|
12
|
+
import * as Stream from "effect/Stream"
|
|
13
|
+
import type * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
|
|
14
|
+
|
|
15
|
+
import * as ModelRuntime from "./ModelRuntime.js"
|
|
16
|
+
import * as Persistence from "./Persistence.js"
|
|
17
|
+
import * as Session from "./Session.js"
|
|
18
|
+
|
|
19
|
+
type SdkSession = Pick<Pi.AgentSession,
|
|
20
|
+
"messages" | "isStreaming" | "subscribe" | "prompt" | "dispose" |
|
|
21
|
+
"abortRetry" | "abortCompaction" | "abortBranchSummary" | "abortBash"
|
|
22
|
+
> & { readonly agent: Pick<Pi.AgentSession["agent"], "abort"> }
|
|
23
|
+
|
|
24
|
+
/** Internal SDK boundary for deterministic lifecycle tests. */
|
|
25
|
+
export type CreateSession = (options: Pi.CreateAgentSessionOptions & {
|
|
26
|
+
readonly cwd: string
|
|
27
|
+
readonly sessionManager: Pi.SessionManager
|
|
28
|
+
}) => Promise<{
|
|
29
|
+
readonly session: SdkSession
|
|
30
|
+
readonly modelFallbackMessage?: string
|
|
31
|
+
readonly extensionsResult?: Pick<Pi.CreateAgentSessionResult["extensionsResult"], "errors">
|
|
32
|
+
}>
|
|
33
|
+
|
|
34
|
+
const failureMessage = (cause: unknown): string => {
|
|
35
|
+
if (typeof cause === "object" && cause !== null && "message" in cause) {
|
|
36
|
+
return String(cause.message)
|
|
37
|
+
}
|
|
38
|
+
return String(cause)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sessionError = (
|
|
42
|
+
id: Session.Id,
|
|
43
|
+
operation: Session.Operation,
|
|
44
|
+
cause: unknown
|
|
45
|
+
) => new Session.Error({ sessionId: id, operation, message: failureMessage(cause) })
|
|
46
|
+
|
|
47
|
+
const serialize = (
|
|
48
|
+
id: Session.Id,
|
|
49
|
+
manager: Pi.SessionManager,
|
|
50
|
+
operation: Session.Operation = "save"
|
|
51
|
+
): Effect.Effect<string, Session.Error> => Effect.gen(function*() {
|
|
52
|
+
const header = yield* Effect.try({
|
|
53
|
+
try: () => manager.getHeader(),
|
|
54
|
+
catch: (cause) => sessionError(id, operation, cause)
|
|
55
|
+
})
|
|
56
|
+
if (header === null) return yield* sessionError(id, operation, "Pi session has no header")
|
|
57
|
+
return yield* Effect.try({
|
|
58
|
+
try: () => [header, ...manager.getEntries()].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
59
|
+
catch: (cause) => sessionError(id, operation, cause)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const assistantText = (message: SdkSession["messages"][number]): string => {
|
|
64
|
+
if (message.role !== "assistant") return ""
|
|
65
|
+
return message.content
|
|
66
|
+
.filter((part) => part.type === "text")
|
|
67
|
+
.map((part) => part.text)
|
|
68
|
+
.join("")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const snapshot = (id: Session.Id, session: SdkSession): Session.Snapshot => {
|
|
72
|
+
const lastAssistant = session.messages
|
|
73
|
+
.filter((message) => message.role === "assistant")
|
|
74
|
+
.at(-1)
|
|
75
|
+
return new Session.Snapshot({
|
|
76
|
+
id,
|
|
77
|
+
status: session.isStreaming ? "streaming" : "idle",
|
|
78
|
+
messageCount: session.messages.length,
|
|
79
|
+
lastAssistantText: lastAssistant === undefined ? "" : assistantText(lastAssistant)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Internal constructor seam; all lifecycle and persistence behavior is shared with make. */
|
|
84
|
+
export const makeWith = Effect.fn("Session.make")(function* (
|
|
85
|
+
options: Session.MakeOptions,
|
|
86
|
+
createSession: CreateSession,
|
|
87
|
+
prepare?: ModelRuntime.Operations["sessionOptions"]
|
|
88
|
+
) {
|
|
89
|
+
const id = options.id
|
|
90
|
+
const fs = yield* FileSystem.FileSystem
|
|
91
|
+
const path = yield* Path.Path
|
|
92
|
+
const store = yield* Persistence.make(id, options.keyPrefix)
|
|
93
|
+
const persisted = yield* store.load
|
|
94
|
+
const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ prefix: "effect-pi-" }).pipe(
|
|
95
|
+
Effect.mapError((cause) => sessionError(id, "make", cause))
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
let manager: Pi.SessionManager
|
|
99
|
+
if (persisted === undefined) {
|
|
100
|
+
manager = yield* Effect.try({
|
|
101
|
+
try: () => Pi.SessionManager.create(options.cwd, temporaryDirectory, { id }),
|
|
102
|
+
catch: (cause) => sessionError(id, "make", cause)
|
|
103
|
+
})
|
|
104
|
+
} else {
|
|
105
|
+
// Reject torn JSON instead of allowing Pi's tolerant reader to silently skip
|
|
106
|
+
// it and overwrite the authoritative document with a shorter conversation.
|
|
107
|
+
// Entry shapes, versions, migrations, and tree semantics still belong to Pi.
|
|
108
|
+
yield* Effect.forEach(persisted.split("\n"), (line, index) => line.trim() === ""
|
|
109
|
+
? Effect.void
|
|
110
|
+
: Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(line).pipe(
|
|
111
|
+
Effect.mapError(() => sessionError(id, "load", `Invalid JSON at line ${index + 1}`))
|
|
112
|
+
), { discard: true })
|
|
113
|
+
const sessionFile = path.join(temporaryDirectory, `${id}.jsonl`)
|
|
114
|
+
yield* fs.writeFileString(sessionFile, persisted).pipe(
|
|
115
|
+
Effect.mapError((cause) => sessionError(id, "load", cause))
|
|
116
|
+
)
|
|
117
|
+
manager = yield* Effect.try({
|
|
118
|
+
try: () => Pi.SessionManager.open(sessionFile, temporaryDirectory, options.cwd),
|
|
119
|
+
catch: (cause) => sessionError(id, "load", cause)
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (manager.getSessionId() !== id) {
|
|
124
|
+
return yield* sessionError(id, "load", `stored session id ${manager.getSessionId()} does not match ${id}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const piOptions = yield* Effect.try({
|
|
128
|
+
try: () => options.configure?.(id) ?? {},
|
|
129
|
+
catch: (cause) => sessionError(id, "make", cause)
|
|
130
|
+
})
|
|
131
|
+
const runtimeOptions = prepare === undefined ? {} : yield* prepare(options.cwd).pipe(
|
|
132
|
+
Effect.mapError((cause) => sessionError(id, "make", cause))
|
|
133
|
+
)
|
|
134
|
+
// Pi's factory has no cancellation API. Wait for acquisition before honoring
|
|
135
|
+
// interruption, so even a late result is disposed before its temp directory.
|
|
136
|
+
const result = yield* Effect.acquireRelease(
|
|
137
|
+
Effect.tryPromise({
|
|
138
|
+
try: () => createSession({ ...piOptions, ...runtimeOptions, cwd: options.cwd, sessionManager: manager }),
|
|
139
|
+
catch: (cause) => sessionError(id, "make", cause)
|
|
140
|
+
}),
|
|
141
|
+
(result) => Effect.try({
|
|
142
|
+
try: () => result.session.dispose(),
|
|
143
|
+
catch: (cause) => sessionError(id, "make", cause)
|
|
144
|
+
}).pipe(Effect.orDie)
|
|
145
|
+
)
|
|
146
|
+
const pi = result.session
|
|
147
|
+
if (result.modelFallbackMessage !== undefined) {
|
|
148
|
+
yield* Effect.logWarning("Pi model selection", { sessionId: id, message: result.modelFallbackMessage })
|
|
149
|
+
}
|
|
150
|
+
for (const diagnostic of result.extensionsResult?.errors ?? []) {
|
|
151
|
+
yield* Effect.logWarning("Pi extension failed to load", { sessionId: id, ...diagnostic })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const events = yield* Effect.acquireRelease(PubSub.sliding<Session.Event>(1024), PubSub.shutdown)
|
|
155
|
+
// A checkpoint serializes current state, not the state at notification time.
|
|
156
|
+
const checkpoints = yield* Effect.acquireRelease(Queue.dropping<void>(1), Queue.shutdown)
|
|
157
|
+
const operations = yield* Effect.acquireRelease(Scope.make("parallel"), (scope) => Scope.close(scope, Exit.void))
|
|
158
|
+
const promptGate = yield* Semaphore.make(1)
|
|
159
|
+
const saveGate = yield* Semaphore.make(1)
|
|
160
|
+
let sequence = 0
|
|
161
|
+
let closed = false
|
|
162
|
+
let active: Fiber.Fiber<unknown, unknown> | undefined
|
|
163
|
+
|
|
164
|
+
const ensureOpen = (operation: Session.Operation) => Effect.suspend(() => closed
|
|
165
|
+
? sessionError(id, operation, "Session scope is closed")
|
|
166
|
+
: Effect.void)
|
|
167
|
+
// Don't interrupt an in-flight replacement of the authoritative document.
|
|
168
|
+
// Atomicity and crash durability still depend on the supplied store.
|
|
169
|
+
const save = saveGate.withPermits(1)(
|
|
170
|
+
serialize(id, manager).pipe(Effect.flatMap(store.save), Effect.uninterruptible)
|
|
171
|
+
)
|
|
172
|
+
// Sliding publish is synchronous; publishUnsafe would silently use dropping
|
|
173
|
+
// behavior on overflow. This is the one synchronous SDK-to-Effect boundary.
|
|
174
|
+
const publish = (event: Session.Event): void => { Effect.runSync(PubSub.publish(events, event)) }
|
|
175
|
+
yield* Effect.acquireRelease(
|
|
176
|
+
Effect.try({
|
|
177
|
+
try: () => pi.subscribe((event) => {
|
|
178
|
+
switch (event.type) {
|
|
179
|
+
case "entry_appended":
|
|
180
|
+
case "session_info_changed":
|
|
181
|
+
case "thinking_level_changed":
|
|
182
|
+
case "turn_end":
|
|
183
|
+
case "compaction_end":
|
|
184
|
+
case "agent_settled":
|
|
185
|
+
Queue.offerUnsafe(checkpoints, undefined)
|
|
186
|
+
if (event.type === "agent_settled") {
|
|
187
|
+
publish({ _tag: "Status", sequence: sequence++, status: "idle" })
|
|
188
|
+
}
|
|
189
|
+
return
|
|
190
|
+
case "agent_start":
|
|
191
|
+
publish({ _tag: "Status", sequence: sequence++, status: "streaming" })
|
|
192
|
+
return
|
|
193
|
+
case "message_update":
|
|
194
|
+
if (event.assistantMessageEvent.type === "text_delta") {
|
|
195
|
+
publish({ _tag: "TextDelta", sequence: sequence++, delta: event.assistantMessageEvent.delta })
|
|
196
|
+
}
|
|
197
|
+
return
|
|
198
|
+
case "tool_execution_start":
|
|
199
|
+
publish({ _tag: "ToolStarted", sequence: sequence++, toolName: event.toolName })
|
|
200
|
+
return
|
|
201
|
+
case "tool_execution_end":
|
|
202
|
+
publish({ _tag: "ToolFinished", sequence: sequence++, toolName: event.toolName, isError: event.isError })
|
|
203
|
+
return
|
|
204
|
+
default:
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
}),
|
|
208
|
+
catch: (cause) => sessionError(id, "make", cause)
|
|
209
|
+
}),
|
|
210
|
+
(unsubscribe) => Effect.try({
|
|
211
|
+
try: unsubscribe,
|
|
212
|
+
catch: (cause) => sessionError(id, "events", cause)
|
|
213
|
+
}).pipe(Effect.orDie)
|
|
214
|
+
)
|
|
215
|
+
const checkpointWorker = yield* Queue.take(checkpoints).pipe(
|
|
216
|
+
Effect.andThen(save.pipe(
|
|
217
|
+
Effect.tapError((cause) => Effect.logError("Unable to checkpoint Pi session", {
|
|
218
|
+
sessionId: id, cause: cause.message
|
|
219
|
+
})),
|
|
220
|
+
Effect.ignore
|
|
221
|
+
)),
|
|
222
|
+
Effect.forever,
|
|
223
|
+
Effect.forkScoped
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
// Pi.abort() waits for idle and only signals the agent/retry path. Using the
|
|
227
|
+
// public synchronous signals also stops compaction/summaries/bash and lets us
|
|
228
|
+
// re-signal a later continuation without accumulating pending abort promises.
|
|
229
|
+
const abortSdk = Effect.forEach([
|
|
230
|
+
() => pi.abortRetry(),
|
|
231
|
+
() => pi.abortCompaction(),
|
|
232
|
+
() => pi.abortBranchSummary(),
|
|
233
|
+
() => pi.abortBash(),
|
|
234
|
+
() => pi.agent.abort()
|
|
235
|
+
], (abort) => Effect.try({
|
|
236
|
+
try: abort,
|
|
237
|
+
catch: (cause) => sessionError(id, "abort", cause)
|
|
238
|
+
}).pipe(Effect.ignore), { discard: true })
|
|
239
|
+
const cancel = (wait: Effect.Effect<void, Session.Error>) => Effect.raceFirst(
|
|
240
|
+
wait.pipe(Effect.ignore, Effect.interruptible),
|
|
241
|
+
// Abort signals during async preflight may be a no-op. Keep requesting cancellation
|
|
242
|
+
// until the invocation settles, including any later model run or retry.
|
|
243
|
+
abortSdk.pipe(
|
|
244
|
+
Effect.andThen(Effect.sleep("25 millis")),
|
|
245
|
+
Effect.forever,
|
|
246
|
+
Effect.interruptible
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const runPrompt = (text: string) => promptGate.withPermits(1)(
|
|
251
|
+
Effect.uninterruptibleMask((restore) => Effect.gen(function*() {
|
|
252
|
+
yield* ensureOpen("prompt")
|
|
253
|
+
active = yield* Effect.fiber
|
|
254
|
+
const firstEntry = manager.getEntries().length
|
|
255
|
+
const invocation = Promise.resolve().then(() => pi.prompt(text))
|
|
256
|
+
const wait = Effect.tryPromise({
|
|
257
|
+
try: () => invocation,
|
|
258
|
+
catch: (cause) => sessionError(id, "prompt", cause)
|
|
259
|
+
})
|
|
260
|
+
const exit = yield* Effect.exit(restore(wait).pipe(Effect.onInterrupt(() => cancel(wait))))
|
|
261
|
+
yield* save
|
|
262
|
+
yield* exit
|
|
263
|
+
|
|
264
|
+
// Read newly appended entries, not the active context: compaction can shrink
|
|
265
|
+
// that context, and handled extension commands need not produce a response.
|
|
266
|
+
const entries = manager.getEntries().slice(firstEntry)
|
|
267
|
+
const messages = entries.flatMap((entry) =>
|
|
268
|
+
entry.type === "message" && entry.message.role === "assistant" ? [entry.message] : [])
|
|
269
|
+
const message = messages.at(-1)
|
|
270
|
+
if (message === undefined) {
|
|
271
|
+
return yield* sessionError(id, "prompt", "Pi returned no new assistant response")
|
|
272
|
+
}
|
|
273
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
274
|
+
return yield* sessionError(id, "prompt", message.errorMessage ?? `Pi response ${message.stopReason}`)
|
|
275
|
+
}
|
|
276
|
+
const usage = entries.flatMap((entry) => {
|
|
277
|
+
if (entry.type === "message") {
|
|
278
|
+
const message = entry.message
|
|
279
|
+
return (message.role === "assistant" || message.role === "toolResult") && message.usage !== undefined
|
|
280
|
+
? [message.usage] : []
|
|
281
|
+
}
|
|
282
|
+
return (entry.type === "compaction" || entry.type === "branch_summary") && entry.usage !== undefined
|
|
283
|
+
? [entry.usage] : []
|
|
284
|
+
})
|
|
285
|
+
return new Session.PromptResult({
|
|
286
|
+
snapshot: snapshot(id, pi),
|
|
287
|
+
text: assistantText(message),
|
|
288
|
+
stopReason: message.stopReason,
|
|
289
|
+
inputTokens: usage.reduce((sum, item) => sum + item.input, 0),
|
|
290
|
+
outputTokens: usage.reduce((sum, item) => sum + item.output, 0),
|
|
291
|
+
totalTokens: usage.reduce((sum, item) => sum + item.totalTokens, 0),
|
|
292
|
+
costUsd: usage.reduce((sum, item) => sum + item.cost.total, 0)
|
|
293
|
+
})
|
|
294
|
+
}).pipe(Effect.ensuring(Effect.sync(() => { active = undefined }))))
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
const owned = <A>(operation: Session.Operation, work: Effect.Effect<A, Session.Error>) =>
|
|
298
|
+
Effect.uninterruptibleMask((restore) => Effect.gen(function*() {
|
|
299
|
+
yield* ensureOpen(operation)
|
|
300
|
+
// Own work in the session scope as well as in its caller. Closing either
|
|
301
|
+
// scope must settle Pi before another owner can restore the document.
|
|
302
|
+
const fiber = yield* Effect.forkIn(work, operations)
|
|
303
|
+
return yield* restore(Fiber.join(fiber)).pipe(Effect.onInterrupt(() => Fiber.interrupt(fiber)))
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
const prompt: Session.Session["prompt"] = Effect.fn("Session.prompt")((text) =>
|
|
307
|
+
owned("prompt", Schema.decodeUnknownEffect(Schema.NonEmptyString)(text).pipe(
|
|
308
|
+
Effect.mapError((cause) => sessionError(id, "prompt", cause)),
|
|
309
|
+
Effect.andThen(runPrompt(text))
|
|
310
|
+
)))
|
|
311
|
+
|
|
312
|
+
const abort = owned("abort", Effect.uninterruptible(Effect.gen(function*() {
|
|
313
|
+
const running = active
|
|
314
|
+
if (running !== undefined) yield* Fiber.interrupt(running)
|
|
315
|
+
yield* save
|
|
316
|
+
})))
|
|
317
|
+
|
|
318
|
+
yield* Effect.addFinalizer(() => Effect.gen(function*() {
|
|
319
|
+
closed = true
|
|
320
|
+
yield* Scope.close(operations, Exit.void)
|
|
321
|
+
yield* Fiber.interrupt(checkpointWorker)
|
|
322
|
+
// Finalizer errors cannot inhabit the typed error channel. Surface a defect
|
|
323
|
+
// rather than reporting successful release after losing the last checkpoint.
|
|
324
|
+
yield* save.pipe(Effect.orDie)
|
|
325
|
+
}))
|
|
326
|
+
yield* save
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
id,
|
|
330
|
+
snapshot: ensureOpen("snapshot").pipe(Effect.andThen(Effect.sync(() => snapshot(id, pi)))),
|
|
331
|
+
prompt,
|
|
332
|
+
abort,
|
|
333
|
+
events: Stream.unwrap(ensureOpen("events").pipe(Effect.as(Stream.fromPubSub(events)))),
|
|
334
|
+
jsonl: ensureOpen("jsonl").pipe(Effect.andThen(serialize(id, manager, "jsonl")))
|
|
335
|
+
} satisfies Session.Session
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
/** Construct one scoped Pi SDK session backed by its KeyValueStore document. */
|
|
339
|
+
export const make: (
|
|
340
|
+
options: Session.MakeOptions
|
|
341
|
+
) => Effect.Effect<
|
|
342
|
+
Session.Session,
|
|
343
|
+
Session.Error,
|
|
344
|
+
FileSystem.FileSystem | KeyValueStore.KeyValueStore | Path.Path | Scope.Scope | ModelRuntime.ModelRuntime
|
|
345
|
+
> = (options) => Effect.flatMap(ModelRuntime.ModelRuntime, (runtime) =>
|
|
346
|
+
makeWith(options, Pi.createAgentSession, runtime.sessionOptions))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema"
|
|
2
|
+
import * as Entity from "effect/unstable/cluster/Entity"
|
|
3
|
+
import * as Rpc from "effect/unstable/rpc/Rpc"
|
|
4
|
+
|
|
5
|
+
import * as Session from "./Session.js"
|
|
6
|
+
|
|
7
|
+
export const Snapshot = Rpc.make("Snapshot", {
|
|
8
|
+
success: Session.Snapshot,
|
|
9
|
+
error: Session.Error
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export const Prompt = Rpc.make("Prompt", {
|
|
13
|
+
payload: { text: Schema.NonEmptyString },
|
|
14
|
+
success: Session.PromptResult,
|
|
15
|
+
error: Session.Error
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const Abort = Rpc.make("Abort", {
|
|
19
|
+
error: Session.Error
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const Jsonl = Rpc.make("Jsonl", {
|
|
23
|
+
success: Schema.String,
|
|
24
|
+
error: Session.Error
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const Events = Rpc.make("Events", {
|
|
28
|
+
success: Session.Event,
|
|
29
|
+
error: Session.Error,
|
|
30
|
+
stream: true
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const entity = Entity.make("PiSession", [
|
|
34
|
+
Snapshot,
|
|
35
|
+
Prompt,
|
|
36
|
+
Abort,
|
|
37
|
+
Jsonl,
|
|
38
|
+
Events
|
|
39
|
+
])
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as Pi from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import * as Config from "effect/Config"
|
|
3
|
+
import * as Context from "effect/Context"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Layer from "effect/Layer"
|
|
6
|
+
import * as Schema from "effect/Schema"
|
|
7
|
+
import type * as Scope from "effect/Scope"
|
|
8
|
+
|
|
9
|
+
export type Settings = NonNullable<Parameters<typeof Pi.SettingsManager.inMemory>[0]>
|
|
10
|
+
|
|
11
|
+
type SdkOptions = ConstructorParameters<typeof Pi.DefaultResourceLoader>[0]
|
|
12
|
+
|
|
13
|
+
export interface Options extends Omit<SdkOptions, "cwd" | "settingsManager" | "agentDir"> {
|
|
14
|
+
/** Defaults to Pi's agent directory. Discovery executes trusted extensions. */
|
|
15
|
+
readonly agentDir?: string
|
|
16
|
+
|
|
17
|
+
/** When supplied, use in-memory settings rather than discovering settings files. */
|
|
18
|
+
readonly settings?: Settings
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EmptyOptions {
|
|
22
|
+
readonly systemPrompt?: string
|
|
23
|
+
readonly settings?: Settings
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class Error extends Schema.TaggedError<Error>()("ResourceLoaderError", {
|
|
27
|
+
operation: Schema.Literals(["load", "release"]),
|
|
28
|
+
message: Schema.String
|
|
29
|
+
}) {}
|
|
30
|
+
|
|
31
|
+
/** SDK interoperability values. Each load owns a fresh, session-local extension runtime. */
|
|
32
|
+
export interface Loaded {
|
|
33
|
+
readonly resourceLoader: Pi.ResourceLoader
|
|
34
|
+
readonly settingsManager: Pi.SettingsManager
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Operations {
|
|
38
|
+
readonly load: (cwd: string) => Effect.Effect<Loaded, Error, Scope.Scope>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ResourceLoader extends Context.Service<ResourceLoader, Operations>()(
|
|
42
|
+
"@jpowersdev/effect-pi/ResourceLoader"
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
const scoped = (create: (cwd: string) => Loaded): Operations => ({
|
|
46
|
+
load: Effect.fn("ResourceLoader.load")(function* (cwd) {
|
|
47
|
+
// Register invalidation before reload: even failed/late extension loading must
|
|
48
|
+
// release its tracked event-bus subscriptions. Pi reload has no AbortSignal.
|
|
49
|
+
const loaded = yield* Effect.acquireRelease(
|
|
50
|
+
Effect.try({
|
|
51
|
+
try: () => create(cwd),
|
|
52
|
+
catch: () => new Error({ operation: "load", message: "Unable to construct Pi resources" })
|
|
53
|
+
}),
|
|
54
|
+
({ resourceLoader }) => Effect.try({
|
|
55
|
+
try: () => resourceLoader.getExtensions().runtime.invalidate(),
|
|
56
|
+
catch: () => new Error({ operation: "release", message: "Unable to release Pi resources" })
|
|
57
|
+
}).pipe(Effect.orDie)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
yield* Effect.tryPromise({
|
|
61
|
+
try: () => loaded.resourceLoader.reload(),
|
|
62
|
+
catch: () => new Error({ operation: "load", message: "Unable to load Pi resources" })
|
|
63
|
+
}).pipe(Effect.uninterruptible)
|
|
64
|
+
|
|
65
|
+
return loaded
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/** Opt into Pi resource discovery. Prefer layerEmpty for isolated applications. */
|
|
70
|
+
export const layer = (options: Options = {}): Layer.Layer<ResourceLoader> =>
|
|
71
|
+
Layer.sync(ResourceLoader, () =>
|
|
72
|
+
scoped((cwd) => {
|
|
73
|
+
const { settings, ...loaderOptions } = options
|
|
74
|
+
|
|
75
|
+
const agentDir = options.agentDir ?? Pi.getAgentDir()
|
|
76
|
+
|
|
77
|
+
const settingsManager = settings === undefined
|
|
78
|
+
? Pi.SettingsManager.create(cwd, agentDir)
|
|
79
|
+
: Pi.SettingsManager.inMemory(settings)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
settingsManager,
|
|
83
|
+
resourceLoader: new Pi.DefaultResourceLoader({
|
|
84
|
+
...loaderOptions,
|
|
85
|
+
cwd,
|
|
86
|
+
agentDir,
|
|
87
|
+
settingsManager
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
/** Resolve discovery options using the active ConfigProvider at layer build time. */
|
|
94
|
+
export const layerConfig = (options: Config.Wrap<Options>) =>
|
|
95
|
+
Layer.unwrap(Effect.map(Config.unwrap<Options>(options), layer))
|
|
96
|
+
|
|
97
|
+
/** Resolve isolated resource options using the active ConfigProvider at layer build time. */
|
|
98
|
+
export const layerEmptyConfig = (options: Config.Wrap<EmptyOptions>) =>
|
|
99
|
+
Layer.unwrap(Effect.map(Config.unwrap<EmptyOptions>(options), layerEmpty))
|
|
100
|
+
|
|
101
|
+
/** No filesystem discovery, extensions, context files, or ambient settings. */
|
|
102
|
+
export const layerEmpty = (options: EmptyOptions = {}): Layer.Layer<ResourceLoader> =>
|
|
103
|
+
Layer.sync(ResourceLoader, () =>
|
|
104
|
+
scoped(() => {
|
|
105
|
+
const extensions = {
|
|
106
|
+
extensions: [],
|
|
107
|
+
errors: [],
|
|
108
|
+
runtime: Pi.createExtensionRuntime()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
settingsManager: Pi.SettingsManager.inMemory(options.settings),
|
|
113
|
+
resourceLoader: {
|
|
114
|
+
getExtensions: () => extensions,
|
|
115
|
+
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
116
|
+
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
117
|
+
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
118
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
119
|
+
getSystemPrompt: () => options.systemPrompt,
|
|
120
|
+
getSystemPromptSource: () => undefined,
|
|
121
|
+
getAppendSystemPrompt: () => [],
|
|
122
|
+
getAppendSystemPromptSources: () => [],
|
|
123
|
+
extendResources: () => {},
|
|
124
|
+
reload: async () => {}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type * as Pi from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import type * as Effect from "effect/Effect"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import type * as Stream from "effect/Stream"
|
|
5
|
+
|
|
6
|
+
/** A Pi session id accepted by Pi's SessionManager. */
|
|
7
|
+
export const Id = Schema.String.check(
|
|
8
|
+
Schema.isPattern(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/)
|
|
9
|
+
).pipe(Schema.brand("@jpowersdev/effect-pi/SessionId"))
|
|
10
|
+
export type Id = typeof Id.Type
|
|
11
|
+
|
|
12
|
+
export const Status = Schema.Literals(["idle", "streaming"])
|
|
13
|
+
export type Status = typeof Status.Type
|
|
14
|
+
|
|
15
|
+
export class Snapshot extends Schema.Class<Snapshot>("@jpowersdev/effect-pi/SessionSnapshot")({
|
|
16
|
+
id: Id,
|
|
17
|
+
status: Status,
|
|
18
|
+
messageCount: Schema.Natural,
|
|
19
|
+
lastAssistantText: Schema.String
|
|
20
|
+
}) {}
|
|
21
|
+
|
|
22
|
+
/** Last new assistant response, with usage summed over entries appended by this invocation. */
|
|
23
|
+
export class PromptResult extends Schema.Class<PromptResult>("@jpowersdev/effect-pi/PromptResult")({
|
|
24
|
+
snapshot: Snapshot,
|
|
25
|
+
text: Schema.String,
|
|
26
|
+
stopReason: Schema.String,
|
|
27
|
+
inputTokens: Schema.Natural,
|
|
28
|
+
outputTokens: Schema.Natural,
|
|
29
|
+
totalTokens: Schema.Natural,
|
|
30
|
+
costUsd: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))
|
|
31
|
+
}) {}
|
|
32
|
+
|
|
33
|
+
const StatusEvent = Schema.TaggedStruct("Status", {
|
|
34
|
+
sequence: Schema.Natural,
|
|
35
|
+
status: Status
|
|
36
|
+
})
|
|
37
|
+
const TextDeltaEvent = Schema.TaggedStruct("TextDelta", {
|
|
38
|
+
sequence: Schema.Natural,
|
|
39
|
+
delta: Schema.String
|
|
40
|
+
})
|
|
41
|
+
const ToolStartedEvent = Schema.TaggedStruct("ToolStarted", {
|
|
42
|
+
sequence: Schema.Natural,
|
|
43
|
+
toolName: Schema.String
|
|
44
|
+
})
|
|
45
|
+
const ToolFinishedEvent = Schema.TaggedStruct("ToolFinished", {
|
|
46
|
+
sequence: Schema.Natural,
|
|
47
|
+
toolName: Schema.String,
|
|
48
|
+
isError: Schema.Boolean
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/** Stable, serializable subset of Pi's live session events. */
|
|
52
|
+
export const Event = Schema.Union([
|
|
53
|
+
StatusEvent,
|
|
54
|
+
TextDeltaEvent,
|
|
55
|
+
ToolStartedEvent,
|
|
56
|
+
ToolFinishedEvent
|
|
57
|
+
])
|
|
58
|
+
export type Event = typeof Event.Type
|
|
59
|
+
|
|
60
|
+
export const Operation = Schema.Literals([
|
|
61
|
+
"abort",
|
|
62
|
+
"events",
|
|
63
|
+
"jsonl",
|
|
64
|
+
"load",
|
|
65
|
+
"make",
|
|
66
|
+
"prompt",
|
|
67
|
+
"save",
|
|
68
|
+
"snapshot"
|
|
69
|
+
])
|
|
70
|
+
export type Operation = typeof Operation.Type
|
|
71
|
+
|
|
72
|
+
export class Error extends Schema.TaggedError<Error>()("SessionError", {
|
|
73
|
+
sessionId: Id,
|
|
74
|
+
operation: Operation,
|
|
75
|
+
message: Schema.String
|
|
76
|
+
}) {}
|
|
77
|
+
|
|
78
|
+
/** One scoped Pi session, whether local or represented by a cluster client. */
|
|
79
|
+
export interface Session {
|
|
80
|
+
readonly id: Id
|
|
81
|
+
readonly snapshot: Effect.Effect<Snapshot, Error>
|
|
82
|
+
/** Nonempty text. Interruption waits for SDK settlement and a checkpoint. */
|
|
83
|
+
readonly prompt: (text: string) => Effect.Effect<PromptResult, Error>
|
|
84
|
+
/** Interrupt the active prompt; queued prompts remain eligible to run. */
|
|
85
|
+
readonly abort: Effect.Effect<void, Error>
|
|
86
|
+
/** Ephemeral, bounded stream. Sequence gaps indicate lost events; restoration resets sequence numbers. */
|
|
87
|
+
readonly events: Stream.Stream<Event, Error>
|
|
88
|
+
/** Current serialization, not an acknowledgement that the backing store has been flushed. */
|
|
89
|
+
readonly jsonl: Effect.Effect<string, Error>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type PiOptions = Omit<
|
|
93
|
+
Pi.CreateAgentSessionOptions,
|
|
94
|
+
"cwd" | "sessionManager" | "modelRuntime" | "model" | "resourceLoader" | "settingsManager"
|
|
95
|
+
>
|
|
96
|
+
|
|
97
|
+
/** Configuration shared by direct, local-pool, and cluster session construction. */
|
|
98
|
+
export interface Config {
|
|
99
|
+
/** Tool working directory, not a sandbox or filesystem access boundary. */
|
|
100
|
+
readonly cwd: string
|
|
101
|
+
/** Defaults to "effect-pi/sessions/". Keep stable across all owners of a document. */
|
|
102
|
+
readonly keyPrefix?: string
|
|
103
|
+
/** Per-session SDK options (such as tools). ModelRuntime owns model, auth, resources, and settings. */
|
|
104
|
+
readonly configure?: (sessionId: Id) => PiOptions
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface MakeOptions extends Config {
|
|
108
|
+
readonly id: Id
|
|
109
|
+
}
|