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 */
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import * as Queue from "effect/Queue";
10import * as Schema from "effect/Schema";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import { TextGenerationError } from "@t3tools/contracts";
14import type { PiSettings } from "../settings.ts";
15
16import { makePiRpcConnection, parsePiModelSlug } from "./rpc.ts";
17import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts";
18import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
19
20const PI_TIMEOUT_MS = 180_000;
21
22const isTextGenerationError = Schema.is(TextGenerationError);
23
24export 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});