@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,85 @@
1
+ /** Create one session, send a prompt, and save its history in SQLite. */
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 { ModelRuntime, ResourceLoader, Session } 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 cli = Command.make("single-session", {
48
+ prompt: Argument.String("prompt").pipe(
49
+ Argument.withDefault("Say hello in one short sentence.")
50
+ ),
51
+ snapshot: Flag.Boolean("snapshot").pipe(
52
+ Flag.withDefault(false),
53
+ Flag.withDescription("Print saved state without calling a model")
54
+ )
55
+ }, ({ prompt, snapshot }) =>
56
+ Effect.gen(function* () {
57
+ const session = yield* Session.make({
58
+ id: Session.Id.make("single-session"),
59
+ cwd: ".",
60
+ configure: () => ({ tools: ["read", "grep", "find", "ls"] })
61
+ })
62
+
63
+ if (snapshot) {
64
+ return yield* Console.log(yield* session.snapshot)
65
+ }
66
+
67
+ yield* session.events.pipe(
68
+ Stream.runForEach((event) => Console.log(event)),
69
+ Effect.catch((error) => Console.warn(error)),
70
+ Effect.forkScoped({ startImmediately: true })
71
+ )
72
+
73
+ const result = yield* session.prompt(prompt).pipe(Effect.timeout("2 minutes"))
74
+
75
+ yield* Console.log("Result:", result)
76
+ }).pipe(Effect.scoped)
77
+ ).pipe(
78
+ Command.provide(Layer.merge(ModelLive, StoreLive))
79
+ )
80
+
81
+ const program = Command.run(cli, { version: "0.1.0" }).pipe(
82
+ Effect.provide(NodeServices.layer)
83
+ )
84
+
85
+ NodeRuntime.runMain(program)
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@jpowersdev/effect-pi",
3
+ "version": "0.1.0",
4
+ "description": "Local and clustered Effect services for durable Pi coding-agent sessions",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jpowersdev/effect-pi.git"
9
+ },
10
+ "homepage": "https://github.com/jpowersdev/effect-pi#readme",
11
+ "bugs": "https://github.com/jpowersdev/effect-pi/issues",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "src/**/*.ts",
22
+ "examples/*.ts",
23
+ "examples/README.md",
24
+ "dist-examples",
25
+ "docs/**/*.md",
26
+ "CHANGELOG.md",
27
+ "CONTRIBUTING.md"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "engines": {
36
+ "node": ">=26.0.0"
37
+ },
38
+ "dependencies": {
39
+ "@earendil-works/pi-coding-agent": "0.86.1"
40
+ },
41
+ "peerDependencies": {
42
+ "effect": "4.0.0-rc.116"
43
+ },
44
+ "devDependencies": {
45
+ "@effect/platform-node": "4.0.0-rc.116",
46
+ "@effect/sql-sqlite-node": "4.0.0-rc.116",
47
+ "effect": "4.0.0-rc.116",
48
+ "@effect/vitest": "4.0.0-rc.116",
49
+ "@types/node": "26.4.1",
50
+ "typescript": "7.0.2",
51
+ "vitest": "5.0.1"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc -b src examples",
55
+ "check": "tsc -b",
56
+ "clean": "tsc -b --clean && rm -rf dist dist-test dist-examples .tsbuildinfo",
57
+ "test": "vitest run",
58
+ "example:session": "pnpm build && node dist-examples/single-session.js",
59
+ "example:pool": "pnpm build && node dist-examples/local-session-pool.js",
60
+ "example:cluster": "pnpm build && node dist-examples/cluster-session.js"
61
+ }
62
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ clientLayer,
3
+ runnerLayer,
4
+ runnerLayerConfig,
5
+ type Options
6
+ } from "./internal/ClusterSessions.js"
@@ -0,0 +1 @@
1
+ export { layer, layerConfig, type Options } from "./internal/LocalSessions.js"
@@ -0,0 +1,10 @@
1
+ export {
2
+ type Binding,
3
+ Error,
4
+ layer,
5
+ layerConfig,
6
+ make,
7
+ ModelRuntime,
8
+ type Operations,
9
+ type Options
10
+ } from "./internal/ModelRuntime.js"
@@ -0,0 +1,13 @@
1
+ export {
2
+ Error,
3
+ layer,
4
+ layerConfig,
5
+ layerEmpty,
6
+ layerEmptyConfig,
7
+ type EmptyOptions,
8
+ type Loaded,
9
+ type Operations,
10
+ type Options,
11
+ ResourceLoader,
12
+ type Settings
13
+ } from "./internal/ResourceLoader.js"
package/src/Session.ts ADDED
@@ -0,0 +1,14 @@
1
+ export {
2
+ Error,
3
+ Event,
4
+ Id,
5
+ type Config,
6
+ type MakeOptions,
7
+ type Operation,
8
+ type PiOptions,
9
+ PromptResult,
10
+ type Session,
11
+ Snapshot,
12
+ Status
13
+ } from "./internal/Session.js"
14
+ export { make } from "./internal/Pi.js"
@@ -0,0 +1,17 @@
1
+ import * as Context from "effect/Context"
2
+ import type * as Effect from "effect/Effect"
3
+ import type * as Scope from "effect/Scope"
4
+
5
+ import type * as Session from "./internal/Session.js"
6
+
7
+ export interface Operations {
8
+ /** Acquire the session for an id within the current scope. */
9
+ readonly open: (
10
+ id: Session.Id
11
+ ) => Effect.Effect<Session.Session, Session.Error, Scope.Scope>
12
+ }
13
+
14
+ /** Location-independent access to keyed Pi sessions. */
15
+ export class Sessions extends Context.Service<Sessions, Operations>()(
16
+ "@jpowersdev/effect-pi/Sessions"
17
+ ) {}
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * as ClusterSessions from "./ClusterSessions.js"
2
+ export * as LocalSessions from "./LocalSessions.js"
3
+ export * as ModelRuntime from "./ModelRuntime.js"
4
+ export * as ResourceLoader from "./ResourceLoader.js"
5
+ export * as Session from "./Session.js"
6
+ export { Sessions } from "./Sessions.js"
@@ -0,0 +1,126 @@
1
+ import * as Config from "effect/Config"
2
+ import type * as Duration from "effect/Duration"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Layer from "effect/Layer"
5
+ import * as Schema from "effect/Schema"
6
+ import type * as Scope from "effect/Scope"
7
+ import * as Stream from "effect/Stream"
8
+ import * as Entity from "effect/unstable/cluster/Entity"
9
+ import * as EntityResource from "effect/unstable/cluster/EntityResource"
10
+ import type * as Sharding from "effect/unstable/cluster/Sharding"
11
+
12
+ import { Sessions } from "../Sessions.js"
13
+ import * as Pi from "./Pi.js"
14
+ import * as Protocol from "./Protocol.js"
15
+ import * as Session from "./Session.js"
16
+
17
+ export interface Options extends Session.Config {
18
+ /** Idle live-resource lifetime; defaults to 15 minutes. */
19
+ readonly resourceIdleTimeToLive?: Duration.Input
20
+ readonly entityMaxIdleTime?: Duration.Input
21
+ /** Queued entity messages; does not limit all concurrently executing handlers. Defaults to 128. */
22
+ readonly mailboxCapacity?: number | "unbounded"
23
+ }
24
+
25
+ type MakeSession<R> = (
26
+ options: Session.MakeOptions
27
+ ) => Effect.Effect<Session.Session, Session.Error, Scope.Scope | R>
28
+
29
+ const failureMessage = (cause: unknown): string => {
30
+ if (typeof cause === "object" && cause !== null && "message" in cause) {
31
+ return String(cause.message)
32
+ }
33
+ return String(cause)
34
+ }
35
+
36
+ const transportError = (
37
+ id: Session.Id,
38
+ operation: Session.Operation,
39
+ cause: unknown
40
+ ) => cause instanceof Session.Error
41
+ ? cause
42
+ : new Session.Error({
43
+ sessionId: id,
44
+ operation,
45
+ message: failureMessage(cause)
46
+ })
47
+
48
+ /** Internal constructor seam used to test entity behavior without model calls. */
49
+ export const runnerLayerWith = <R>(
50
+ options: Options,
51
+ makeSession: MakeSession<R>
52
+ ): Layer.Layer<never, never, Sharding.Sharding | R> => {
53
+ const handlers = Effect.gen(function*() {
54
+ const address = yield* Entity.CurrentAddress
55
+ const id = yield* Schema.decodeUnknownEffect(Session.Id)(address.entityId).pipe(
56
+ Effect.orDie
57
+ )
58
+ const resource = yield* EntityResource.make({
59
+ acquire: makeSession({
60
+ id,
61
+ cwd: options.cwd,
62
+ ...(options.keyPrefix === undefined ? {} : { keyPrefix: options.keyPrefix }),
63
+ ...(options.configure === undefined ? {} : { configure: options.configure })
64
+ }),
65
+ idleTimeToLive: options.resourceIdleTimeToLive ?? "15 minutes"
66
+ }).pipe(Effect.orDie)
67
+
68
+ return Protocol.entity.of({
69
+ Snapshot: () => resource.get.pipe(Effect.flatMap((session) => session.snapshot)),
70
+ Prompt: (request) => resource.get.pipe(
71
+ Effect.flatMap((session) => session.prompt(request.payload.text))
72
+ ),
73
+ Abort: () => resource.get.pipe(Effect.flatMap((session) => session.abort)),
74
+ Jsonl: () => resource.get.pipe(Effect.flatMap((session) => session.jsonl)),
75
+ Events: () => Stream.unwrap(resource.get.pipe(
76
+ Effect.map((session) => session.events)
77
+ ))
78
+ })
79
+ })
80
+
81
+ return Protocol.entity.toLayer(handlers, {
82
+ concurrency: "unbounded",
83
+ mailboxCapacity: options.mailboxCapacity ?? 128,
84
+ maxIdleTime: options.entityMaxIdleTime ?? "20 minutes"
85
+ })
86
+ }
87
+
88
+ /** Registers the server-side cluster entity handlers. */
89
+ export const runnerLayer = (options: Options) => runnerLayerWith(options, Pi.make)
90
+
91
+ /** Resolve runner options using the active ConfigProvider at layer build time. */
92
+ export const runnerLayerConfig = (options: Config.Wrap<Options>) =>
93
+ Layer.unwrap(Effect.map(Config.unwrap<Options>(options), runnerLayer))
94
+
95
+ /** Implements Sessions using location-transparent Effect Cluster clients. */
96
+ export const clientLayer: Layer.Layer<Sessions, never, Sharding.Sharding> = Layer.effect(
97
+ Sessions,
98
+ Protocol.entity.client.pipe(
99
+ Effect.map((makeClient) => Sessions.of({
100
+ open: (id) => Effect.succeed((() => {
101
+ const client = makeClient(id)
102
+ return {
103
+ id,
104
+ snapshot: client.Snapshot().pipe(
105
+ Effect.mapError((cause) => transportError(id, "snapshot", cause))
106
+ ),
107
+ prompt: (text) => Schema.decodeUnknownEffect(Schema.NonEmptyString)(text).pipe(
108
+ Effect.flatMap((text) => client.Prompt({ text })),
109
+ Effect.mapError((cause) => transportError(id, "prompt", cause))
110
+ ),
111
+ abort: client.Abort().pipe(
112
+ Effect.mapError((cause) => transportError(id, "abort", cause))
113
+ ),
114
+ events: client.Events().pipe(
115
+ Stream.mapError((cause) => transportError(id, "events", cause))
116
+ ),
117
+ jsonl: client.Jsonl().pipe(
118
+ Effect.mapError((cause) => transportError(id, "jsonl", cause))
119
+ )
120
+ } satisfies Session.Session
121
+ })())
122
+ }))
123
+ )
124
+ )
125
+
126
+ export const entity = Protocol.entity
@@ -0,0 +1,45 @@
1
+ import * as Config from "effect/Config"
2
+ import type * as Duration from "effect/Duration"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Layer from "effect/Layer"
5
+ import * as RcMap from "effect/RcMap"
6
+ import type * as Scope from "effect/Scope"
7
+
8
+ import { Sessions } from "../Sessions.js"
9
+ import * as Pi from "./Pi.js"
10
+ import type * as Session from "./Session.js"
11
+
12
+ export interface Options extends Session.Config {
13
+ /** How long an unreferenced live Pi session remains resident. */
14
+ readonly idleTimeToLive?: Duration.Input
15
+ }
16
+
17
+ type MakeSession<R> = (
18
+ options: Session.MakeOptions
19
+ ) => Effect.Effect<Session.Session, Session.Error, Scope.Scope | R>
20
+
21
+ /** Internal constructor seam used to verify pool behavior without model calls. */
22
+ export const layerWith = <R>(
23
+ options: Options,
24
+ makeSession: MakeSession<R>
25
+ ): Layer.Layer<Sessions, never, R> => Layer.effect(Sessions, Effect.gen(function*() {
26
+ const sessions = yield* RcMap.make({
27
+ lookup: (id: Session.Id) => makeSession({
28
+ id,
29
+ cwd: options.cwd,
30
+ ...(options.keyPrefix === undefined ? {} : { keyPrefix: options.keyPrefix }),
31
+ ...(options.configure === undefined ? {} : { configure: options.configure })
32
+ }),
33
+ idleTimeToLive: options.idleTimeToLive ?? "15 minutes"
34
+ })
35
+
36
+ return Sessions.of({
37
+ open: (id) => RcMap.get(sessions, id)
38
+ })
39
+ }))
40
+
41
+ export const layer = (options: Options) => layerWith(options, Pi.make)
42
+
43
+ /** Resolve pool options using the active ConfigProvider at layer build time. */
44
+ export const layerConfig = (options: Config.Wrap<Options>) =>
45
+ Layer.unwrap(Effect.map(Config.unwrap<Options>(options), layer))
@@ -0,0 +1,105 @@
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 Redacted from "effect/Redacted"
7
+ import * as Schema from "effect/Schema"
8
+ import type * as Scope from "effect/Scope"
9
+
10
+ import * as ResourceLoader from "./ResourceLoader.js"
11
+
12
+ export interface Options extends Omit<Pi.CreateModelRuntimeOptions, "signal"> {
13
+ /** Omit to let Pi restore/select the model using the session and settings. */
14
+ readonly model?: { readonly provider: string; readonly id: string }
15
+
16
+ /** Runtime-only overrides. Keys are never persisted to auth.json. */
17
+ readonly apiKeys?: Readonly<Record<string, Redacted.Redacted<string>>>
18
+ }
19
+
20
+ export class Error extends Schema.TaggedError<Error>()("ModelRuntimeError", {
21
+ operation: Schema.Literals(["create", "providers", "credentials", "model"]),
22
+ message: Schema.String
23
+ }) {}
24
+
25
+ /** SDK interoperability values; never share one binding between live sessions. */
26
+ export interface Binding extends ResourceLoader.Loaded {
27
+ readonly modelRuntime: Pi.ModelRuntime
28
+ readonly model?: NonNullable<Pi.CreateAgentSessionOptions["model"]>
29
+ }
30
+
31
+ export interface Operations {
32
+ readonly sessionOptions: (
33
+ cwd: string
34
+ ) => Effect.Effect<Binding, Error | ResourceLoader.Error, Scope.Scope>
35
+ }
36
+
37
+ export class ModelRuntime extends Context.Service<ModelRuntime, Operations>()(
38
+ "@jpowersdev/effect-pi/ModelRuntime"
39
+ ) {}
40
+
41
+ /** Capture the loading policy; SDK objects are owned by each live session's scope. */
42
+ export const make = Effect.fn("ModelRuntime.make")(function* (options: Options = {}) {
43
+ const resources = yield* ResourceLoader.ResourceLoader
44
+
45
+ const { model: selection, apiKeys, ...runtimeOptions } = options
46
+
47
+ const sessionOptions: Operations["sessionOptions"] = Effect.fn("ModelRuntime.sessionOptions")(function* (cwd) {
48
+ const loaded = yield* resources.load(cwd)
49
+
50
+ const modelRuntime = yield* Effect.tryPromise({
51
+ try: (signal) => Pi.ModelRuntime.create({ ...runtimeOptions, signal }),
52
+ // SDK credential errors can contain raw keys. Do not retain or stringify them.
53
+ catch: () => new Error({ operation: "create", message: "Unable to create Pi model runtime" })
54
+ })
55
+
56
+ // Extensions can define the selected model. Register their providers before
57
+ // lookup; leave Pi's pending queues intact for its own subsequent binding.
58
+ yield* Effect.try({
59
+ try: () => {
60
+ const runtime = loaded.resourceLoader.getExtensions().runtime
61
+
62
+ for (const { name, config } of runtime.pendingProviderRegistrations) {
63
+ modelRuntime.registerProvider(name, config)
64
+ }
65
+
66
+ for (const { provider } of runtime.pendingNativeProviderRegistrations) {
67
+ modelRuntime.registerNativeProvider(provider)
68
+ }
69
+ },
70
+ catch: () => new Error({ operation: "providers", message: "Unable to register resource providers" })
71
+ })
72
+
73
+ for (const [provider, key] of Object.entries(apiKeys ?? {})) {
74
+ yield* Effect.tryPromise({
75
+ try: (signal) => modelRuntime.setRuntimeApiKey(provider, Redacted.value(key), { signal }),
76
+ catch: () => new Error({ operation: "credentials", message: "Unable to configure runtime credentials" })
77
+ })
78
+ }
79
+
80
+ if (selection === undefined) return { ...loaded, modelRuntime }
81
+
82
+ const model = yield* Effect.try({
83
+ try: () => modelRuntime.getModel(selection.provider, selection.id),
84
+ catch: () => new Error({ operation: "model", message: "Unable to resolve the configured model" })
85
+ })
86
+
87
+ if (model === undefined) {
88
+ return yield* new Error({
89
+ operation: "model",
90
+ message: `Unknown model ${selection.provider}/${selection.id}`
91
+ })
92
+ }
93
+
94
+ return { ...loaded, modelRuntime, model }
95
+ })
96
+
97
+ return ModelRuntime.of({ sessionOptions })
98
+ })
99
+
100
+ export const layer = (options: Options = {}): Layer.Layer<ModelRuntime, never, ResourceLoader.ResourceLoader> =>
101
+ Layer.effect(ModelRuntime, make(options))
102
+
103
+ /** Resolve configuration when the layer is built, using the active ConfigProvider. */
104
+ export const layerConfig = (options: Config.Wrap<Options>) =>
105
+ Layer.unwrap(Effect.map(Config.unwrap<Options>(options), layer))
@@ -0,0 +1,40 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
3
+
4
+ import * as Session from "./Session.js"
5
+
6
+ const message = (cause: unknown): string => {
7
+ if (typeof cause === "object" && cause !== null && "message" in cause) {
8
+ return String(cause.message)
9
+ }
10
+ return String(cause)
11
+ }
12
+
13
+ export interface Store {
14
+ readonly load: Effect.Effect<string | undefined, Session.Error>
15
+ readonly save: (jsonl: string) => Effect.Effect<void, Session.Error>
16
+ }
17
+
18
+ export const make = Effect.fnUntraced(function* (
19
+ id: Session.Id,
20
+ keyPrefix = "effect-pi/sessions/"
21
+ ) {
22
+ const backing = yield* KeyValueStore.KeyValueStore
23
+ const store = KeyValueStore.prefix(backing, keyPrefix)
24
+ return {
25
+ load: store.get(id).pipe(
26
+ Effect.mapError((cause) => new Session.Error({
27
+ sessionId: id,
28
+ operation: "load",
29
+ message: message(cause)
30
+ }))
31
+ ),
32
+ save: (jsonl) => store.set(id, jsonl).pipe(
33
+ Effect.mapError((cause) => new Session.Error({
34
+ sessionId: id,
35
+ operation: "save",
36
+ message: message(cause)
37
+ }))
38
+ )
39
+ } satisfies Store
40
+ })