@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/CONTRIBUTING.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +91 -0
  5. package/dist/ClusterSessions.d.ts +2 -0
  6. package/dist/ClusterSessions.d.ts.map +1 -0
  7. package/dist/ClusterSessions.js +2 -0
  8. package/dist/ClusterSessions.js.map +1 -0
  9. package/dist/LocalSessions.d.ts +2 -0
  10. package/dist/LocalSessions.d.ts.map +1 -0
  11. package/dist/LocalSessions.js +2 -0
  12. package/dist/LocalSessions.js.map +1 -0
  13. package/dist/ModelRuntime.d.ts +2 -0
  14. package/dist/ModelRuntime.d.ts.map +1 -0
  15. package/dist/ModelRuntime.js +2 -0
  16. package/dist/ModelRuntime.js.map +1 -0
  17. package/dist/ResourceLoader.d.ts +2 -0
  18. package/dist/ResourceLoader.d.ts.map +1 -0
  19. package/dist/ResourceLoader.js +2 -0
  20. package/dist/ResourceLoader.js.map +1 -0
  21. package/dist/Session.d.ts +3 -0
  22. package/dist/Session.d.ts.map +1 -0
  23. package/dist/Session.js +3 -0
  24. package/dist/Session.js.map +1 -0
  25. package/dist/Sessions.d.ts +14 -0
  26. package/dist/Sessions.d.ts.map +1 -0
  27. package/dist/Sessions.js +5 -0
  28. package/dist/Sessions.js.map +1 -0
  29. package/dist/index.d.ts +7 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +7 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/internal/ClusterSessions.d.ts +44 -0
  34. package/dist/internal/ClusterSessions.d.ts.map +1 -0
  35. package/dist/internal/ClusterSessions.js +72 -0
  36. package/dist/internal/ClusterSessions.js.map +1 -0
  37. package/dist/internal/LocalSessions.d.ts +19 -0
  38. package/dist/internal/LocalSessions.d.ts.map +1 -0
  39. package/dist/internal/LocalSessions.js +25 -0
  40. package/dist/internal/LocalSessions.js.map +1 -0
  41. package/dist/internal/ModelRuntime.d.ts +42 -0
  42. package/dist/internal/ModelRuntime.d.ts.map +1 -0
  43. package/dist/internal/ModelRuntime.js +66 -0
  44. package/dist/internal/ModelRuntime.js.map +1 -0
  45. package/dist/internal/Persistence.d.ts +12 -0
  46. package/dist/internal/Persistence.d.ts.map +1 -0
  47. package/dist/internal/Persistence.js +26 -0
  48. package/dist/internal/Persistence.js.map +1 -0
  49. package/dist/internal/Pi.d.ts +51 -0
  50. package/dist/internal/Pi.d.ts.map +1 -0
  51. package/dist/internal/Pi.js +258 -0
  52. package/dist/internal/Pi.js.map +1 -0
  53. package/dist/internal/Protocol.d.ts +41 -0
  54. package/dist/internal/Protocol.d.ts.map +1 -0
  55. package/dist/internal/Protocol.js +33 -0
  56. package/dist/internal/Protocol.js.map +1 -0
  57. package/dist/internal/ResourceLoader.d.ts +46 -0
  58. package/dist/internal/ResourceLoader.d.ts.map +1 -0
  59. package/dist/internal/ResourceLoader.js +77 -0
  60. package/dist/internal/ResourceLoader.js.map +1 -0
  61. package/dist/internal/Session.d.ts +82 -0
  62. package/dist/internal/Session.d.ts.map +1 -0
  63. package/dist/internal/Session.js +63 -0
  64. package/dist/internal/Session.js.map +1 -0
  65. package/dist-examples/cluster-session.d.ts +2 -0
  66. package/dist-examples/cluster-session.d.ts.map +1 -0
  67. package/dist-examples/cluster-session.js +77 -0
  68. package/dist-examples/cluster-session.js.map +1 -0
  69. package/dist-examples/local-session-pool.d.ts +2 -0
  70. package/dist-examples/local-session-pool.d.ts.map +1 -0
  71. package/dist-examples/local-session-pool.js +60 -0
  72. package/dist-examples/local-session-pool.js.map +1 -0
  73. package/dist-examples/single-session.d.ts +2 -0
  74. package/dist-examples/single-session.d.ts.map +1 -0
  75. package/dist-examples/single-session.js +49 -0
  76. package/dist-examples/single-session.js.map +1 -0
  77. package/docs/reference.md +248 -0
  78. package/examples/README.md +111 -0
  79. package/examples/cluster-session.ts +131 -0
  80. package/examples/local-session-pool.ts +107 -0
  81. package/examples/single-session.ts +85 -0
  82. package/package.json +62 -0
  83. package/src/ClusterSessions.ts +6 -0
  84. package/src/LocalSessions.ts +1 -0
  85. package/src/ModelRuntime.ts +10 -0
  86. package/src/ResourceLoader.ts +13 -0
  87. package/src/Session.ts +14 -0
  88. package/src/Sessions.ts +17 -0
  89. package/src/index.ts +6 -0
  90. package/src/internal/ClusterSessions.ts +126 -0
  91. package/src/internal/LocalSessions.ts +45 -0
  92. package/src/internal/ModelRuntime.ts +105 -0
  93. package/src/internal/Persistence.ts +40 -0
  94. package/src/internal/Pi.ts +346 -0
  95. package/src/internal/Protocol.ts +39 -0
  96. package/src/internal/ResourceLoader.ts +128 -0
  97. 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
+ }