@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,248 @@
1
+ # API and behavior reference
2
+
3
+ This guide covers installation, configuration, the public API, persistence, and cancellation. See the [README](../README.md) for an overview and the [examples](../examples/README.md) for runnable programs.
4
+
5
+ ## Installation and compatibility
6
+
7
+ The package is named `@jpowersdev/effect-pi`. To install a local build, run this from a checkout:
8
+
9
+ ```sh
10
+ pnpm install --frozen-lockfile
11
+ pnpm pack --pack-destination /tmp/effect-pi-pack
12
+ ```
13
+
14
+ Then, from the consuming project:
15
+
16
+ ```sh
17
+ pnpm add /tmp/effect-pi-pack/jpowersdev-effect-pi-0.1.0.tgz effect@4.0.0-rc.116
18
+ ```
19
+
20
+ For a published release, use the package name in place of the tarball path.
21
+
22
+ The supported configuration is Node.js 26+, ESM, Effect `4.0.0-rc.116`, and Pi `0.86.1`. Effect is an exact peer dependency while these APIs are release candidates. Keep all `@effect/*` packages on the matching release.
23
+
24
+ The Pi SDK is an exact runtime dependency. Normal usage and the examples don't import it directly. Add `@earendil-works/pi-coding-agent@0.86.1` only for advanced SDK interoperability. The optional Node/SQLite example dependencies are:
25
+
26
+ ```sh
27
+ pnpm add @effect/platform-node@4.0.0-rc.116 @effect/sql-sqlite-node@4.0.0-rc.116
28
+ ```
29
+
30
+ TypeScript consumers currently need `skipLibCheck: true` because the pinned upstream Pi/provider declarations have NodeNext compatibility issues. The library and examples are otherwise checked with strict TypeScript. Browser and CommonJS usage are not supported.
31
+
32
+ ## Public API
33
+
34
+ Six concepts are exported from the package root:
35
+
36
+ | Export | Purpose |
37
+ | --- | --- |
38
+ | `ResourceLoader` | `layer` / `layerConfig` for Pi discovery; `layerEmpty` / `layerEmptyConfig` for isolation |
39
+ | `ModelRuntime` | `layer` / `layerConfig`, depending on `ResourceLoader` |
40
+ | `Session` | Id/event/result schemas, `Session.Error`, the session interface, and scoped `Session.make` |
41
+ | `Sessions` | Service providing `open(id)` within a scope |
42
+ | `LocalSessions` | `layer` / `layerConfig`, backed by `RcMap` |
43
+ | `ClusterSessions` | `runnerLayer` / `runnerLayerConfig`, and `clientLayer` |
44
+
45
+ A session provides:
46
+
47
+ - `prompt(text)` — accept a nonempty prompt, wait its turn, run Pi, save a checkpoint, and return a result.
48
+ - `abort` — interrupt the active prompt and wait for settlement and a checkpoint. Queued prompts are not cancelled by this call.
49
+ - `snapshot` — current status, message count, and last assistant text.
50
+ - `events` — ephemeral status, text-delta, and tool lifecycle events.
51
+ - `jsonl` — serialize current state. This is **not** a store flush operation.
52
+
53
+ `configure(id)` synchronously returns per-session SDK options such as tool policy and thinking level. The library owns `cwd`, `sessionManager`, `modelRuntime`, `model`, `resourceLoader`, and `settingsManager`. SDK interoperability options remain version-coupled.
54
+
55
+ ### Resources and models
56
+
57
+ Declare reusable configuration recipes and layers at module scope. Compose them at the application boundary, rather than building the graph inside the application Effect:
58
+
59
+ ```ts
60
+ import * as Config from "effect/Config"
61
+ import * as Layer from "effect/Layer"
62
+
63
+ import { LocalSessions, ModelRuntime, ResourceLoader } from "@jpowersdev/effect-pi"
64
+
65
+ const ModelConfig = Config.all({
66
+ provider: Config.NonEmptyString("EFFECT_PI_PROVIDER"),
67
+ modelId: Config.NonEmptyString("EFFECT_PI_MODEL"),
68
+ apiKey: Config.Redacted("EFFECT_PI_API_KEY")
69
+ }).pipe(
70
+ Config.map(({ provider, modelId, apiKey }) => ({
71
+ model: { provider, id: modelId },
72
+ apiKeys: { [provider]: apiKey },
73
+ authPath: ".data/auth.json",
74
+ modelsPath: null,
75
+ refreshOnCreate: false
76
+ }))
77
+ )
78
+
79
+ const ResourcesLive = ResourceLoader.layerEmpty({
80
+ systemPrompt: "You are a helpful assistant.",
81
+ settings: { retry: { enabled: false } }
82
+ })
83
+
84
+ const ModelLive = ModelRuntime.layerConfig(ModelConfig).pipe(
85
+ Layer.provide(ResourcesLive)
86
+ )
87
+
88
+ const SessionsLive = LocalSessions.layerConfig({
89
+ cwd: Config.NonEmptyString("EFFECT_PI_CWD").pipe(Config.withDefault(".")),
90
+ configure: Config.succeed(() => ({ tools: ["read", "grep", "find", "ls"] }))
91
+ }).pipe(
92
+ Layer.provide(ModelLive)
93
+ )
94
+
95
+ // Provide Node services and a KeyValueStore at the application boundary.
96
+ ```
97
+
98
+ The config constructors accept `Config.Wrap<Options>`: either a complete `Config<Options>` or a nested record of individual `Config` values. Use `Config.succeed` for constant fields and callbacks, or use the plain `layer` constructors when all options are already known. Config is parsed **when the layer builds**, using the active `ConfigProvider`, so the same layer definition can be composed with different providers. Missing or invalid config fails the layer with `Config.ConfigError`, before sessions are opened.
99
+
100
+ For genuinely effectful construction that needs other services, `Layer.unwrap` remains available. Keep such work at the relevant layer boundary; the application Effect should consume services, not assemble them.
101
+
102
+ Supply runtime-only keys with `apiKeys: { [provider]: apiKey }`, where `apiKey` is an Effect `Redacted<string>` (for example, from `Config.Redacted`). The library installs these keys without persisting them; Pi may still create an empty auth file. Other model options follow Pi's `CreateModelRuntimeOptions`, except that the library supplies the cancellation signal. Omitting `model` lets Pi restore/select it from session history and settings.
103
+
104
+ `ResourceLoader.layerEmpty` does no filesystem discovery and uses in-memory settings. `ResourceLoader.layer` opts into trusted Pi discovery, defaulting to Pi's agent directory; `agentDir` overrides the resource directory, and `settings` replaces discovered settings with an in-memory configuration. Configure model/auth paths separately on `ModelRuntime` when using a custom directory.
105
+
106
+ These services capture reusable configuration, **not shared mutable SDK instances**. The `ResourceLoader` service's `load(cwd)` and the `ModelRuntime` service's `sessionOptions(cwd)` are scoped Effects; sessions call them automatically. Each live session receives a fresh resource loader, settings manager, and model runtime. Extension-provided models are registered before model selection. Reusing a layer does not share extension bindings or runtime credentials between live sessions.
107
+
108
+ Direct service calls fail with `ResourceLoader.Error` or `ModelRuntime.Error`, each carrying `operation` and `message`. Session construction maps these to `Session.Error` with operation `make`. Raw SDK error payloads are not retained because they can contain credentials. Loaded/binding values expose SDK handles only as an advanced interoperability boundary; don't share them between live sessions or use them after their scope closes.
109
+
110
+ ### Direct sessions
111
+
112
+ `Session.make({ id, cwd, configure? })` needs `ModelRuntime`, `FileSystem`, `Path`, `KeyValueStore`, and a `Scope`. It does not require `Sessions` or Cluster. Each call creates its own resource; do not independently construct the same stored id twice.
113
+
114
+ See [single-session.ts](../examples/single-session.ts).
115
+
116
+ ### Local sessions
117
+
118
+ `LocalSessions.layer` shares one live resource per id **within that layer instance**. Reuse the layer rather than making one per request. `open` holds a reference until its scope closes; unreferenced resources stay resident for `idleTimeToLive` (default: 15 minutes). Closing the layer releases them immediately.
119
+
120
+ This is the preferred choice for a single owning Node process. Other processes can use that application's existing HTTP/RPC interface.
121
+
122
+ See [local-session-pool.ts](../examples/local-session-pool.ts).
123
+
124
+ ### Cluster sessions
125
+
126
+ Clients provide `ClusterSessions.clientLayer`; runners register `ClusterSessions.runnerLayer`. Both need Effect Cluster sharding. Runners additionally need `ModelRuntime`, the Node services, and the authoritative store. Cluster clients do not need model credentials or resource-loading layers. A client `open` is lightweight; construction/load errors can arrive on the first operation rather than at `open`.
127
+
128
+ Runner defaults:
129
+
130
+ | Option | Default |
131
+ | --- | --- |
132
+ | `resourceIdleTimeToLive` | 15 minutes |
133
+ | `entityMaxIdleTime` | 20 minutes |
134
+ | `mailboxCapacity` | 128 |
135
+
136
+ Long-lived event subscriptions keep resources in use. Runner and client processes must use compatible package versions and cluster configuration. The mailbox limit is not a global cap on active prompt requests; handlers run concurrently so aborts and events can proceed during prompts.
137
+
138
+ The self-contained [cluster example](../examples/cluster-session.ts) starts a runner and a client in one process. They use separate sharding runtimes and communicate over a loopback socket. Discovery and cluster message storage are shared in memory; SQLite stores the conversation documents. `Layer.fresh` keeps the differently configured cluster runtimes from sharing memoized services. Their lifetimes are scoped together, so both shut down when the example finishes.
139
+
140
+ SQLite supports this local demo. Across machines, use a shared database for coordination and session documents; do not put SQLite on a network filesystem.
141
+
142
+ ## Local setup using existing Pi configuration
143
+
144
+ The [runnable examples](../examples/README.md) configure Pi explicitly without loading your usual extensions, settings, or context files. If you instead want to reuse your existing **trusted** Pi setup, you can provide a smaller configuration:
145
+
146
+ ```ts
147
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
148
+ import * as NodeServices from "@effect/platform-node/NodeServices"
149
+ import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"
150
+
151
+ import * as Console from "effect/Console"
152
+ import * as Effect from "effect/Effect"
153
+ import * as Layer from "effect/Layer"
154
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
155
+
156
+ import { LocalSessions, ModelRuntime, ResourceLoader, Session, Sessions } from "@jpowersdev/effect-pi"
157
+
158
+ const StoreLive = KeyValueStore.layerSql({ table: "pi_sessions" }).pipe(
159
+ Layer.provide(
160
+ SqliteClient.layer({ filename: ".data/effect-pi.sqlite" })
161
+ )
162
+ )
163
+
164
+ const ModelLive = ModelRuntime.layer().pipe(
165
+ Layer.provide(ResourceLoader.layer())
166
+ )
167
+
168
+ const SessionsLive = LocalSessions.layer({
169
+ cwd: process.cwd(),
170
+ configure: () => ({ tools: ["read", "grep", "find", "ls"] })
171
+ }).pipe(
172
+ Layer.provide([ModelLive, NodeServices.layer, StoreLive])
173
+ )
174
+
175
+ const program = Effect.gen(function* () {
176
+ const sessions = yield* Sessions
177
+
178
+ const id = Session.Id.make("experiment-1")
179
+
180
+ const session = yield* sessions.open(id)
181
+
182
+ const result = yield* session.prompt("Describe the top-level files in this repository")
183
+
184
+ yield* Console.log(result.text)
185
+ }).pipe(
186
+ Effect.scoped,
187
+ Effect.provide(SessionsLive)
188
+ )
189
+
190
+ NodeRuntime.runMain(program)
191
+ ```
192
+
193
+ Create a private `.data/` directory first (`mkdir -p .data && chmod 700 .data` on Unix). The SQLite database contains unencrypted conversation data.
194
+
195
+ This example uses Pi's existing model/auth configuration and resource discovery. Its read-only tool allowlist does **not** disable extension discovery or prevent reads outside `cwd`. Running it again with the same id and store restores the conversation.
196
+
197
+ ## Cancellation and lifetime
198
+
199
+ - Prompt effects are owned by both the caller and the live session. Interrupting either cancels the SDK invocation and waits for it to settle before releasing the prompt gate.
200
+ - Cancellation signals the agent, retries, compaction, summaries, and SDK bash execution. During asynchronous preflight it keeps requesting abort, so a subsequently started model run is also cancelled.
201
+ - Interrupting a queued prompt does not abort the currently running prompt.
202
+ - Resource release stops owned work, stops the checkpoint worker, saves final state, unsubscribes, disposes Pi, and removes temporary files.
203
+ - Pi's resource reload and session factory have no cancellation API. Acquisition waits for them to return so late resources can be released. Resource scopes invalidate extension runtimes even when later model/session setup fails. Model creation and credential setup receive Effect's cancellation signal. An in-flight store commit is also protected from interruption.
204
+
205
+ **Cancellation is cooperative, not a hard deadline.** Broken/stalled tools, extensions, SDK acquisition, or stores can delay timeout completion and shutdown. Use bounded backend operations and process isolation when you need a forced termination boundary.
206
+
207
+ Cluster prompt handlers are interruptible and prompt RPCs are **not persisted**. Cancellation must reach the runner to stop remote work. A lost connection, timeout, or runner failure does not prove that no model/tool work occurred; do not automatically retry an uncertain prompt.
208
+
209
+ ## Results, errors, and events
210
+
211
+ `PromptResult.text` and `stopReason` describe the last **new** assistant response. An extension command that produces no assistant response fails rather than returning an old answer. SDK terminal `error`/`aborted` responses become `Session.Error` failures with the SDK diagnostic when available.
212
+
213
+ Token and cost fields sum usage recorded in entries appended during the invocation: assistant responses, tool-reported nested usage, compaction, and branch summaries. Total tokens can include cached tokens. These are SDK estimates, not billing guarantees; unreported extension/provider work cannot be counted.
214
+
215
+ Operational failures use `_tag: "SessionError"` with `sessionId`, `operation`, and `message`. An explicit abort or caller interruption interrupts the prompt Effect. A failed release checkpoint surfaces as a **defect carrying `Session.Error`**, since scope finalizers do not have a typed error channel. Model-fallback and extension-load diagnostics are logged as warnings; background checkpoint failures are logged and retried at subsequent checkpoint boundaries.
216
+
217
+ Events have monotonically increasing sequence numbers **per live resource**, resetting on restoration. The live buffer holds 1,024 events and discards oldest events on overflow. Slow subscribers can see gaps. There is no replay or subscription persistence. Reconcile using `snapshot`/`jsonl` or the completed prompt result; an event is not a durability acknowledgement.
218
+
219
+ Start the stream before prompting. Across Cluster, starting a client fiber does not acknowledge that the remote subscription is established, so initial events can be missed. See [cluster-session.ts](../examples/cluster-session.ts) for best-effort event consumption in a scope.
220
+
221
+ ## Persistence contract
222
+
223
+ The configured `KeyValueStore` holds `<keyPrefix><sessionId>`; the default prefix is `effect-pi/sessions/`. Each value is one complete Pi JSONL document.
224
+
225
+ The document is materialized in a scoped temporary directory and opened with Pi's `SessionManager`. Pi owns entry formats, migrations, and conversation-tree behavior. Malformed JSON syntax and mismatched session ids fail loading without overwriting the store; this is not a complete validator for hostile documents.
226
+
227
+ - Successful construction, prompt completion, and abort include a successful checkpoint.
228
+ - SDK state-change notifications request background checkpoints; redundant pending requests are coalesced.
229
+ - Failed/interrupted calls may still have changed state or performed external work.
230
+ - After abrupt process loss, recovery is limited to the last **successfully committed** document.
231
+
232
+ The backend must provide **atomic replacement**, suitable durability, and enough capacity for a whole conversation document. `KeyValueStore.layerMemory` is useful only for ephemeral sessions/tests. The pinned Effect `layerFileSystem` overwrites files directly and is **not crash-atomic**; the examples use `layerSql` instead.
233
+
234
+ There must be one writer per key. Separate local pools, direct constructors, or unrelated clusters must not share ownership accidentally. This library does not add compare-and-swap revisions or fencing; deployments that need protection from independent writers or stale owners need stronger coordination at the storage boundary. Back up the store and define retention/deletion policies in the host application.
235
+
236
+ ## Trust and security
237
+
238
+ Without explicit configuration, Pi can inherit credentials, model settings, extensions, skills, and context from the environment, home directory, and workspace. Its default tool set includes shell execution and file mutation. Extensions are executable code; `noTools` does not turn them into a sandbox.
239
+
240
+ Host applications are responsible for:
241
+
242
+ - Authenticating callers and authorizing access to session ids and JSONL.
243
+ - Isolating untrusted code/prompts from credentials, files, and network access.
244
+ - Protecting transcripts and logs, which may contain source code, tool output, or secrets.
245
+ - Model spending limits, request/concurrency limits, tool policies, storage permissions, encryption, backups, and retention.
246
+ - Securing cluster transport and database access. The example binds loopback and has no application authentication.
247
+
248
+ There is no multi-tenant security boundary here. Treat stored JSONL, configured SDK resources, and runner configuration as trusted inputs. Report security concerns privately to the maintainer rather than including credentials or private transcripts in an issue.
@@ -0,0 +1,111 @@
1
+ # Examples
2
+
3
+ Each example is a complete program. Pick one file and copy it into your project—there are no imports from other examples or shared helpers. The repeated setup is intentional.
4
+
5
+ | Example | What it shows |
6
+ | --- | --- |
7
+ | [single-session.ts](single-session.ts) | Create one session, send a prompt, and save its history in SQLite. |
8
+ | [local-session-pool.ts](local-session-pool.ts) | Open a session through a pool, release it, then reopen it in another request scope. |
9
+ | [cluster-session.ts](cluster-session.ts) | Start a runner and a client in the same process, send requests over a local socket, then shut both down. |
10
+
11
+ ## Setup
12
+
13
+ Use Node 26 and the pnpm version in `package.json`:
14
+
15
+ ```sh
16
+ pnpm install --frozen-lockfile
17
+ pnpm build
18
+ ```
19
+
20
+ Create the fixed data directory before running (Unix commands):
21
+
22
+ ```sh
23
+ mkdir -p .data/effect-pi
24
+ chmod 700 .data/effect-pi
25
+ ```
26
+
27
+ Choose a model in Pi's built-in catalog and supply its API key. For example:
28
+
29
+ ```sh
30
+ export EFFECT_PI_PROVIDER=anthropic
31
+ export EFFECT_PI_MODEL=claude-sonnet-4-5
32
+ # Set EFFECT_PI_API_KEY through your shell or secret manager. Don't commit it.
33
+ ```
34
+
35
+ The layers are defined at module scope: resources feed the model runtime, which feeds sessions. Only the model and credentials come from configuration; incidental paths and settings are ordinary literals you can edit.
36
+
37
+ There are no Pi imports or Promise adapters. Your API key is not saved, and your usual Pi extensions, skills, prompts, and context files aren't loaded. The enabled tools are `read`, `grep`, `find`, and `ls`.
38
+
39
+ ## Run an example
40
+
41
+ Effect CLI handles the optional prompt, `--snapshot`, and `--help`. With no prompt argument, the default is “Say hello in one short sentence.” Quote prompts containing spaces. `--help` needs neither credentials nor the data directory.
42
+
43
+ ```sh
44
+ pnpm example:session "Say hello in one sentence"
45
+ pnpm example:pool "Say hello in one sentence"
46
+ pnpm example:cluster "Say hello in one sentence"
47
+ ```
48
+
49
+ Or run the compiled files directly:
50
+
51
+ ```sh
52
+ node dist-examples/single-session.js "Say hello in one sentence"
53
+ node dist-examples/local-session-pool.js "Say hello in one sentence"
54
+ node dist-examples/cluster-session.js "Say hello in one sentence"
55
+ ```
56
+
57
+ All three save conversations in `.data/effect-pi/sessions.sqlite`. Run an example again from the same working directory to continue its conversation. The pool example also reopens the session before exiting and prints its snapshot, showing how separate requests can use the same pool.
58
+
59
+ ### Try it without a model request
60
+
61
+ Pass `--snapshot` instead of a prompt. This creates or restores a real session but doesn't invoke a model or any tools, so a dummy key is sufficient:
62
+
63
+ ```sh
64
+ EFFECT_PI_API_KEY=unused-snapshot-only pnpm example:cluster --snapshot
65
+ ```
66
+
67
+ Keep the provider and model variables set. Normal prompt mode makes paid model calls and prints live events followed by the result.
68
+
69
+ ## Defaults you can edit
70
+
71
+ Paths, session ids, and the cluster port are fixed in the source. Tools work in `"."`; session ids are `single-session`, `pooled-session`, and `cluster-session`. The cluster runner listens on `127.0.0.1:34431`. Change those literals if your application needs something different.
72
+
73
+ Independent owners must not write to the same session id in the same database concurrently.
74
+
75
+ ## How the cluster example runs
76
+
77
+ You only need **one command and one terminal**. `cluster-session.ts` starts the runner, creates a separate client runtime, and uses the public `Sessions` API to make a request. The client discovers the runner through a shared in-memory store and connects over a real loopback socket; this isn't a fake transport or a direct call to the runner's session.
78
+
79
+ Both runtimes live in the same Effect scope. When the program finishes or is interrupted, it closes the client and runner and releases their resources. There is no background server to stop manually.
80
+
81
+ This is a local demonstration. Running across machines requires a shared database such as Postgres and secured transport, not SQLite on a network share. The example uses in-memory storage for cluster coordination and SQLite for saved conversations. Prompt requests are not persisted for replay. See the [reference guide](../docs/reference.md#cluster-sessions) for deployment details.
82
+
83
+ ## Safety and cancellation
84
+
85
+ - Read-only tools are **not a sandbox**: they can read outside the working directory. Use trusted workspaces.
86
+ - Keep `.data/effect-pi` private (the setup command uses mode `0700` on Unix). Conversations and logs are not encrypted and may contain private data.
87
+ - Live events are best-effort. A remote subscription can miss initial events; use the completed result or a snapshot to reconcile.
88
+ - Prompts have a two-minute timeout. Cancellation and Ctrl+C still wait for Pi, its tools, and storage writes to settle. They do not undo file changes or model charges.
89
+
90
+ ## Copying an example into another project
91
+
92
+ Install `@jpowersdev/effect-pi` using a [local tarball or published release](../docs/reference.md#installation-and-compatibility), then add the packages imported by the example:
93
+
94
+ ```sh
95
+ pnpm add effect@4.0.0-rc.116 \
96
+ @effect/platform-node@4.0.0-rc.116 @effect/sql-sqlite-node@4.0.0-rc.116
97
+ ```
98
+
99
+ Use an ESM project (`"type": "module"`). Node 26 can run the copied TypeScript file directly:
100
+
101
+ ```sh
102
+ node single-session.ts --snapshot
103
+ ```
104
+
105
+ Create `.data/effect-pi` and set the model/credential variables as described above. If you compile with TypeScript, use NodeNext and `skipLibCheck` for the pinned upstream declarations.
106
+
107
+ The npm tarball includes the source and compiled examples too. With their optional dependencies installed, you can run one without copying it:
108
+
109
+ ```sh
110
+ node node_modules/@jpowersdev/effect-pi/dist-examples/single-session.js --snapshot
111
+ ```
@@ -0,0 +1,131 @@
1
+ /** Start a runner and a separate cluster client in one process, then shut both down. */
2
+ import * as NodeClusterSocket from "@effect/platform-node/NodeClusterSocket"
3
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
4
+ import * as NodeServices from "@effect/platform-node/NodeServices"
5
+ import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"
6
+
7
+ import * as Config from "effect/Config"
8
+ import * as Console from "effect/Console"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Layer from "effect/Layer"
11
+ import * as Option from "effect/Option"
12
+ import * as Stream from "effect/Stream"
13
+ import * as Argument from "effect/unstable/cli/Argument"
14
+ import * as Command from "effect/unstable/cli/Command"
15
+ import * as Flag from "effect/unstable/cli/Flag"
16
+ import * as MessageStorage from "effect/unstable/cluster/MessageStorage"
17
+ import * as RunnerAddress from "effect/unstable/cluster/RunnerAddress"
18
+ import * as RunnerStorage from "effect/unstable/cluster/RunnerStorage"
19
+ import * as ShardingConfig from "effect/unstable/cluster/ShardingConfig"
20
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
21
+
22
+ import { ClusterSessions, ModelRuntime, ResourceLoader, Session, Sessions } from "@jpowersdev/effect-pi"
23
+
24
+ // Model and resource configuration belongs to the runner, not the cluster client.
25
+ const ResourcesLive = ResourceLoader.layerEmpty({
26
+ systemPrompt: "You are a helpful assistant. Use the read-only tools only when asked to inspect files.",
27
+ settings: { retry: { enabled: false } }
28
+ })
29
+
30
+ const ModelLive = ModelRuntime.layerConfig(
31
+ Config.all({
32
+ provider: Config.NonEmptyString("EFFECT_PI_PROVIDER"),
33
+ modelId: Config.NonEmptyString("EFFECT_PI_MODEL"),
34
+ apiKey: Config.Redacted("EFFECT_PI_API_KEY")
35
+ }).pipe(
36
+ Config.map(({ provider, modelId, apiKey }) => ({
37
+ model: { provider, id: modelId },
38
+ apiKeys: { [provider]: apiKey },
39
+ authPath: ".data/effect-pi/auth.json",
40
+ modelsPath: null,
41
+ refreshOnCreate: false
42
+ }))
43
+ )
44
+ ).pipe(
45
+ Layer.provide(ResourcesLive)
46
+ )
47
+
48
+ const StoreLive = KeyValueStore.layerSql({ table: "pi_sessions" }).pipe(
49
+ Layer.provide(
50
+ SqliteClient.layer({ filename: ".data/effect-pi/sessions.sqlite" })
51
+ )
52
+ )
53
+
54
+ // Both runtimes share in-memory discovery; only conversation documents use SQLite.
55
+ const DiscoveryLive = Layer.merge(RunnerStorage.layerMemory, MessageStorage.layerMemory).pipe(
56
+ Layer.provide(ShardingConfig.layer())
57
+ )
58
+
59
+ const RunnerSharding = NodeClusterSocket.layer({
60
+ storage: "byo",
61
+ serialization: "ndjson",
62
+ shardingConfig: {
63
+ runnerAddress: Option.some(RunnerAddress.make("127.0.0.1", 34431))
64
+ }
65
+ }).pipe(
66
+ Layer.fresh,
67
+ Layer.provide(DiscoveryLive)
68
+ )
69
+
70
+ // Fresh sharding runtimes make requests travel over the socket, not local dispatch.
71
+ const ClientSharding = NodeClusterSocket.layer({
72
+ clientOnly: true,
73
+ storage: "byo",
74
+ serialization: "ndjson",
75
+ shardingConfig: { runnerAddress: Option.none() }
76
+ }).pipe(
77
+ Layer.fresh,
78
+ Layer.provide(DiscoveryLive)
79
+ )
80
+
81
+ const RunnerLive = ClusterSessions.runnerLayer({
82
+ cwd: ".",
83
+ configure: () => ({ tools: ["read", "grep", "find", "ls"] })
84
+ }).pipe(
85
+ Layer.provide([ModelLive, StoreLive, RunnerSharding])
86
+ )
87
+
88
+ const ClientLive = ClusterSessions.clientLayer.pipe(
89
+ Layer.provide(ClientSharding),
90
+ // Register the runner first and keep it alive until the client exits.
91
+ Layer.provide(RunnerLive)
92
+ )
93
+
94
+ const cli = Command.make("cluster-session", {
95
+ prompt: Argument.String("prompt").pipe(
96
+ Argument.withDefault("Say hello in one short sentence.")
97
+ ),
98
+ snapshot: Flag.Boolean("snapshot").pipe(
99
+ Flag.withDefault(false),
100
+ Flag.withDescription("Print saved state without calling a model")
101
+ )
102
+ }, ({ prompt, snapshot }) =>
103
+ Effect.gen(function* () {
104
+ const sessions = yield* Sessions
105
+
106
+ const session = yield* sessions.open(Session.Id.make("cluster-session"))
107
+
108
+ if (snapshot) {
109
+ return yield* Console.log(yield* session.snapshot)
110
+ }
111
+
112
+ // Remote subscriptions are best-effort; use the prompt result to reconcile.
113
+ yield* session.events.pipe(
114
+ Stream.runForEach((event) => Console.log(event)),
115
+ Effect.catch((error) => Console.warn(error)),
116
+ Effect.forkScoped({ startImmediately: true })
117
+ )
118
+
119
+ const result = yield* session.prompt(prompt).pipe(Effect.timeout("2 minutes"))
120
+
121
+ yield* Console.log("Result:", result)
122
+ }).pipe(Effect.scoped)
123
+ ).pipe(
124
+ Command.provide(ClientLive)
125
+ )
126
+
127
+ const program = Command.run(cli, { version: "0.1.0" }).pipe(
128
+ Effect.provide(NodeServices.layer)
129
+ )
130
+
131
+ NodeRuntime.runMain(program)
@@ -0,0 +1,107 @@
1
+ /** Share sessions by id and reopen them across separate request scopes. */
2
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
3
+ import * as NodeServices from "@effect/platform-node/NodeServices"
4
+ import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"
5
+
6
+ import * as Config from "effect/Config"
7
+ import * as Console from "effect/Console"
8
+ import * as Effect from "effect/Effect"
9
+ import * as Layer from "effect/Layer"
10
+ import * as Stream from "effect/Stream"
11
+ import * as Argument from "effect/unstable/cli/Argument"
12
+ import * as Command from "effect/unstable/cli/Command"
13
+ import * as Flag from "effect/unstable/cli/Flag"
14
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
15
+
16
+ import { LocalSessions, ModelRuntime, ResourceLoader, Session, Sessions } from "@jpowersdev/effect-pi"
17
+
18
+ const ResourcesLive = ResourceLoader.layerEmpty({
19
+ systemPrompt: "You are a helpful assistant. Use the read-only tools only when asked to inspect files.",
20
+ settings: { retry: { enabled: false } }
21
+ })
22
+
23
+ const ModelLive = ModelRuntime.layerConfig(
24
+ Config.all({
25
+ provider: Config.NonEmptyString("EFFECT_PI_PROVIDER"),
26
+ modelId: Config.NonEmptyString("EFFECT_PI_MODEL"),
27
+ apiKey: Config.Redacted("EFFECT_PI_API_KEY")
28
+ }).pipe(
29
+ Config.map(({ provider, modelId, apiKey }) => ({
30
+ model: { provider, id: modelId },
31
+ apiKeys: { [provider]: apiKey },
32
+ authPath: ".data/effect-pi/auth.json",
33
+ modelsPath: null,
34
+ refreshOnCreate: false
35
+ }))
36
+ )
37
+ ).pipe(
38
+ Layer.provide(ResourcesLive)
39
+ )
40
+
41
+ const StoreLive = KeyValueStore.layerSql({ table: "pi_sessions" }).pipe(
42
+ Layer.provide(
43
+ SqliteClient.layer({ filename: ".data/effect-pi/sessions.sqlite" })
44
+ )
45
+ )
46
+
47
+ const SessionsLive = LocalSessions.layer({
48
+ cwd: ".",
49
+ idleTimeToLive: "1 minute",
50
+ configure: () => ({ tools: ["read", "grep", "find", "ls"] })
51
+ }).pipe(
52
+ Layer.provide([ModelLive, StoreLive])
53
+ )
54
+
55
+ const cli = Command.make("local-session-pool", {
56
+ prompt: Argument.String("prompt").pipe(
57
+ Argument.withDefault("Say hello in one short sentence.")
58
+ ),
59
+ snapshot: Flag.Boolean("snapshot").pipe(
60
+ Flag.withDefault(false),
61
+ Flag.withDescription("Print saved state without calling a model")
62
+ )
63
+ }, ({ prompt, snapshot }) =>
64
+ Effect.gen(function* () {
65
+ const sessions = yield* Sessions
66
+
67
+ const id = Session.Id.make("pooled-session")
68
+
69
+ // A request holds a reference to the session for the duration of its scope.
70
+ yield* Effect.scoped(
71
+ Effect.gen(function* () {
72
+ const session = yield* sessions.open(id)
73
+
74
+ if (snapshot) {
75
+ return yield* Console.log(yield* session.snapshot)
76
+ }
77
+
78
+ yield* session.events.pipe(
79
+ Stream.runForEach((event) => Console.log(event)),
80
+ Effect.catch((error) => Console.warn(error)),
81
+ Effect.forkScoped({ startImmediately: true })
82
+ )
83
+
84
+ const result = yield* session.prompt(prompt).pipe(Effect.timeout("2 minutes"))
85
+
86
+ yield* Console.log("Result:", result)
87
+ })
88
+ )
89
+
90
+ // Reuse the same live session before the idle TTL; after eviction, restore from SQLite.
91
+ yield* Effect.scoped(
92
+ Effect.gen(function* () {
93
+ const session = yield* sessions.open(id)
94
+
95
+ yield* Console.log("Reopened:", yield* session.snapshot)
96
+ })
97
+ )
98
+ }).pipe(Effect.scoped)
99
+ ).pipe(
100
+ Command.provide(SessionsLive)
101
+ )
102
+
103
+ const program = Command.run(cli, { version: "0.1.0" }).pipe(
104
+ Effect.provide(NodeServices.layer)
105
+ )
106
+
107
+ NodeRuntime.runMain(program)