| 1 | /** |
| 2 | * PiTextGeneration — commit messages, PR content, branch names, and thread |
| 3 | * titles generated through an ephemeral `pi --mode rpc --no-session` process. |
| 4 | * No session file is written; the user's Pi configuration (default model, |
| 5 | * auth, custom providers) still applies. |
| 6 | */ |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Queue from "effect/Queue"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | |
| 13 | import { TextGenerationError } from "@t3tools/contracts"; |
| 14 | import type { PiSettings } from "../settings.ts"; |
| 15 | |
| 16 | import { makePiRpcConnection, parsePiModelSlug } from "./rpc.ts"; |
| 17 | import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; |
| 18 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; |
| 19 | |
| 20 | const PI_TIMEOUT_MS = 180_000; |
| 21 | |
| 22 | const isTextGenerationError = Schema.is(TextGenerationError); |
| 23 | |
| 24 | export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ( |
| 25 | piSettings: PiSettings, |
| 26 | environment: NodeJS.ProcessEnv = process.env, |
| 27 | ) { |
| 28 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 29 | |
| 30 | const runPiJson: TextGenerationOperations.Runner = (request) => { |
| 31 | const { operation, cwd, prompt, modelSelection } = request; |
| 32 | return Effect.gen(function* () { |
| 33 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); |
| 34 | if (!resolvedLaunchArgs.ok) { |
| 35 | return yield* new TextGenerationError({ |
| 36 | operation, |
| 37 | detail: resolvedLaunchArgs.message, |
| 38 | }); |
| 39 | } |
| 40 | const launch = buildPiRpcLaunch({ |
| 41 | launchArgs: resolvedLaunchArgs.args, |
| 42 | environment, |
| 43 | mcpSession: undefined, |
| 44 | extensionPath: undefined, |
| 45 | ephemeral: true, |
| 46 | // No user is present to answer a text-generation extension dialog. |
| 47 | disableExtensions: true, |
| 48 | // Background naming/content helpers must never mutate the workspace. |
| 49 | disableTools: true, |
| 50 | }); |
| 51 | const connection = yield* makePiRpcConnection({ |
| 52 | command: piSettings.binaryPath || "pi", |
| 53 | // Extensions and tools are disabled because no user is present to |
| 54 | // answer a dialog and background text generation is read-only. User |
| 55 | // model config and auth still apply. |
| 56 | args: launch.args, |
| 57 | cwd, |
| 58 | env: launch.env, |
| 59 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); |
| 60 | |
| 61 | if (modelSelection.model !== "default") { |
| 62 | // `customModels` accepts arbitrary strings, so an unusable slug is |
| 63 | // rejected rather than skipped: running Pi's default model here would |
| 64 | // report success for a model the caller never asked for. |
| 65 | const parsed = parsePiModelSlug(modelSelection.model); |
| 66 | if (parsed === null) { |
| 67 | return yield* new TextGenerationError({ |
| 68 | operation, |
| 69 | detail: `Pi model '${modelSelection.model}' must use provider/model format.`, |
| 70 | }); |
| 71 | } |
| 72 | yield* connection.request({ |
| 73 | type: "set_model", |
| 74 | provider: parsed.provider, |
| 75 | modelId: parsed.modelId, |
| 76 | }); |
| 77 | } |
| 78 | |
| 79 | yield* connection.request({ type: "prompt", message: prompt }); |
| 80 | yield* Effect.gen(function* () { |
| 81 | while (true) { |
| 82 | const event = yield* Queue.take(connection.events); |
| 83 | if (event["type"] === "agent_settled") return; |
| 84 | } |
| 85 | }); |
| 86 | const data = yield* connection.request({ type: "get_last_assistant_text" }); |
| 87 | const text = |
| 88 | typeof data === "object" && |
| 89 | data !== null && |
| 90 | typeof (data as { text?: unknown }).text === "string" |
| 91 | ? (data as { text: string }).text.trim() |
| 92 | : ""; |
| 93 | if (!text) { |
| 94 | return yield* new TextGenerationError({ |
| 95 | operation, |
| 96 | detail: "Pi returned empty output.", |
| 97 | }); |
| 98 | } |
| 99 | return yield* TextGenerationOperations.decodeJsonReply(request, "Pi", text); |
| 100 | }).pipe( |
| 101 | Effect.timeoutOption(PI_TIMEOUT_MS), |
| 102 | Effect.flatMap( |
| 103 | Option.match({ |
| 104 | onNone: () => |
| 105 | Effect.fail(new TextGenerationError({ operation, detail: "Pi request timed out." })), |
| 106 | onSome: (value) => Effect.succeed(value), |
| 107 | }), |
| 108 | ), |
| 109 | Effect.mapError((cause) => |
| 110 | isTextGenerationError(cause) |
| 111 | ? cause |
| 112 | : new TextGenerationError({ |
| 113 | operation, |
| 114 | detail: "Pi text generation failed.", |
| 115 | cause, |
| 116 | }), |
| 117 | ), |
| 118 | Effect.scoped, |
| 119 | ); |
| 120 | }; |
| 121 | |
| 122 | return TextGenerationOperations.fromRunner( |
| 123 | "PiTextGeneration", |
| 124 | TextGenerationOperations.withoutInvestigation("Pi", runPiJson), |
| 125 | ); |
| 126 | }); |