@llm4ts/runner 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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/Cli.d.ts +5 -0
  4. package/dist/Cli.d.ts.map +1 -0
  5. package/dist/Cli.js +36 -0
  6. package/dist/Cli.js.map +1 -0
  7. package/dist/CoderPolicy.d.ts +12 -0
  8. package/dist/CoderPolicy.d.ts.map +1 -0
  9. package/dist/CoderPolicy.js +59 -0
  10. package/dist/CoderPolicy.js.map +1 -0
  11. package/dist/Connectors.d.ts +28 -0
  12. package/dist/Connectors.d.ts.map +1 -0
  13. package/dist/Connectors.js +165 -0
  14. package/dist/Connectors.js.map +1 -0
  15. package/dist/Doctor.d.ts +4 -0
  16. package/dist/Doctor.d.ts.map +1 -0
  17. package/dist/Doctor.js +33 -0
  18. package/dist/Doctor.js.map +1 -0
  19. package/dist/FlowArgs.d.ts +34 -0
  20. package/dist/FlowArgs.d.ts.map +1 -0
  21. package/dist/FlowArgs.js +136 -0
  22. package/dist/FlowArgs.js.map +1 -0
  23. package/dist/FlowRunner.d.ts +48 -0
  24. package/dist/FlowRunner.d.ts.map +1 -0
  25. package/dist/FlowRunner.js +97 -0
  26. package/dist/FlowRunner.js.map +1 -0
  27. package/dist/McpStdio.d.ts +7 -0
  28. package/dist/McpStdio.d.ts.map +1 -0
  29. package/dist/McpStdio.js +19 -0
  30. package/dist/McpStdio.js.map +1 -0
  31. package/dist/NodeGeminiCliExecutor.d.ts +7 -0
  32. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -0
  33. package/dist/NodeGeminiCliExecutor.js +26 -0
  34. package/dist/NodeGeminiCliExecutor.js.map +1 -0
  35. package/dist/NodeHttpClient.d.ts +5 -0
  36. package/dist/NodeHttpClient.d.ts.map +1 -0
  37. package/dist/NodeHttpClient.js +109 -0
  38. package/dist/NodeHttpClient.js.map +1 -0
  39. package/dist/NodePlainFileStore.d.ts +5 -0
  40. package/dist/NodePlainFileStore.d.ts.map +1 -0
  41. package/dist/NodePlainFileStore.js +64 -0
  42. package/dist/NodePlainFileStore.js.map +1 -0
  43. package/dist/NodeProcessExecutor.d.ts +5 -0
  44. package/dist/NodeProcessExecutor.d.ts.map +1 -0
  45. package/dist/NodeProcessExecutor.js +212 -0
  46. package/dist/NodeProcessExecutor.js.map +1 -0
  47. package/dist/NodeRuntime.d.ts +4 -0
  48. package/dist/NodeRuntime.d.ts.map +1 -0
  49. package/dist/NodeRuntime.js +11 -0
  50. package/dist/NodeRuntime.js.map +1 -0
  51. package/dist/NodeTemporaryFiles.d.ts +5 -0
  52. package/dist/NodeTemporaryFiles.d.ts.map +1 -0
  53. package/dist/NodeTemporaryFiles.js +30 -0
  54. package/dist/NodeTemporaryFiles.js.map +1 -0
  55. package/dist/NodeWorkspace.d.ts +6 -0
  56. package/dist/NodeWorkspace.d.ts.map +1 -0
  57. package/dist/NodeWorkspace.js +231 -0
  58. package/dist/NodeWorkspace.js.map +1 -0
  59. package/dist/Package.d.ts +2 -0
  60. package/dist/Package.d.ts.map +1 -0
  61. package/dist/Package.js +2 -0
  62. package/dist/Package.js.map +1 -0
  63. package/dist/ReviewFingerprint.d.ts +2 -0
  64. package/dist/ReviewFingerprint.d.ts.map +1 -0
  65. package/dist/ReviewFingerprint.js +13 -0
  66. package/dist/ReviewFingerprint.js.map +1 -0
  67. package/dist/Terminal.d.ts +46 -0
  68. package/dist/Terminal.d.ts.map +1 -0
  69. package/dist/Terminal.js +226 -0
  70. package/dist/Terminal.js.map +1 -0
  71. package/dist/TerminalInteraction.d.ts +4 -0
  72. package/dist/TerminalInteraction.d.ts.map +1 -0
  73. package/dist/TerminalInteraction.js +25 -0
  74. package/dist/TerminalInteraction.js.map +1 -0
  75. package/dist/cli-main.d.ts +3 -0
  76. package/dist/cli-main.d.ts.map +1 -0
  77. package/dist/cli-main.js +39 -0
  78. package/dist/cli-main.js.map +1 -0
  79. package/package.json +74 -0
  80. package/src/Cli.ts +51 -0
  81. package/src/CoderPolicy.ts +82 -0
  82. package/src/Connectors.ts +225 -0
  83. package/src/Doctor.ts +44 -0
  84. package/src/FlowArgs.ts +167 -0
  85. package/src/FlowRunner.ts +220 -0
  86. package/src/McpStdio.ts +41 -0
  87. package/src/NodeGeminiCliExecutor.ts +56 -0
  88. package/src/NodeHttpClient.ts +224 -0
  89. package/src/NodePlainFileStore.ts +75 -0
  90. package/src/NodeProcessExecutor.ts +331 -0
  91. package/src/NodeRuntime.ts +19 -0
  92. package/src/NodeTemporaryFiles.ts +46 -0
  93. package/src/NodeWorkspace.ts +282 -0
  94. package/src/Package.ts +1 -0
  95. package/src/ReviewFingerprint.ts +13 -0
  96. package/src/Terminal.ts +329 -0
  97. package/src/TerminalInteraction.ts +43 -0
  98. package/src/cli-main.ts +53 -0
@@ -0,0 +1,225 @@
1
+ import * as Duration from "effect/Duration"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Redacted from "effect/Redacted"
4
+ import {
5
+ ApiConnectorConfig,
6
+ CliConnectorConfig,
7
+ type ConnectorConfig
8
+ } from "@llm4ts/core/ConnectorConfig"
9
+ import { ConnectorIds, connectorDefaultBaseUrl } from "@llm4ts/core/Models"
10
+ import { ScriptUsage } from "./FlowArgs.ts"
11
+
12
+ export const claude = CliConnectorConfig.make({
13
+ connectorId: ConnectorIds.ClaudeCli,
14
+ flags: {
15
+ "permission-mode": "acceptEdits"
16
+ }
17
+ })
18
+
19
+ export const codex = CliConnectorConfig.make({
20
+ connectorId: ConnectorIds.Codex,
21
+ flags: {
22
+ sandbox: "workspace-write"
23
+ }
24
+ })
25
+
26
+ export const gemini = CliConnectorConfig.make({
27
+ connectorId: ConnectorIds.GeminiCli
28
+ })
29
+
30
+ export const pi = CliConnectorConfig.make({
31
+ connectorId: ConnectorIds.Pi
32
+ })
33
+
34
+ export const antigravity = CliConnectorConfig.make({
35
+ connectorId: ConnectorIds.AntigravityCli,
36
+ flags: {
37
+ mode: "accept-edits"
38
+ }
39
+ })
40
+
41
+ export const grok = CliConnectorConfig.make({
42
+ connectorId: ConnectorIds.Grok
43
+ })
44
+
45
+ export const cursor = CliConnectorConfig.make({
46
+ connectorId: ConnectorIds.Cursor
47
+ })
48
+
49
+ export const opencode = CliConnectorConfig.make({
50
+ connectorId: ConnectorIds.OpenCode
51
+ })
52
+
53
+ export const lmStudio = ApiConnectorConfig.make({
54
+ connectorId: ConnectorIds.LmStudio,
55
+ baseUrl: "http://localhost:1234/v1"
56
+ })
57
+
58
+ export const openAI = ApiConnectorConfig.make({
59
+ connectorId: ConnectorIds.OpenAI
60
+ })
61
+
62
+ export const anthropic = ApiConnectorConfig.make({
63
+ connectorId: ConnectorIds.Anthropic
64
+ })
65
+
66
+ export const geminiApi = ApiConnectorConfig.make({
67
+ connectorId: ConnectorIds.GeminiApi
68
+ })
69
+
70
+ export const ollama = ApiConnectorConfig.make({
71
+ connectorId: ConnectorIds.Ollama
72
+ })
73
+
74
+ export const mock = ApiConnectorConfig.make({
75
+ connectorId: ConnectorIds.Mock,
76
+ model: "mock"
77
+ })
78
+
79
+ export function withModel(config: CliConnectorConfig, model: string): CliConnectorConfig
80
+ export function withModel(config: ApiConnectorConfig, model: string): ApiConnectorConfig
81
+ export function withModel(
82
+ config: CliConnectorConfig | ApiConnectorConfig,
83
+ model: string
84
+ ): CliConnectorConfig | ApiConnectorConfig {
85
+ return config instanceof CliConnectorConfig
86
+ ? CliConnectorConfig.make({ ...config, model })
87
+ : ApiConnectorConfig.make({ ...config, model })
88
+ }
89
+
90
+ export const withTurnLimit = (config: CliConnectorConfig, turnLimit: number): CliConnectorConfig =>
91
+ CliConnectorConfig.make({
92
+ ...config,
93
+ turnLimit
94
+ })
95
+
96
+ export const withEnvironment = (
97
+ config: CliConnectorConfig,
98
+ environment: Readonly<Record<string, string>>
99
+ ): CliConnectorConfig =>
100
+ CliConnectorConfig.make({
101
+ ...config,
102
+ envVars: {
103
+ ...config.envVars,
104
+ ...environment
105
+ }
106
+ })
107
+
108
+ export const asReadOnly = (config: CliConnectorConfig): CliConnectorConfig =>
109
+ CliConnectorConfig.make({ ...config, readOnly: true })
110
+
111
+ export const withTimeoutSeconds = (
112
+ config: ApiConnectorConfig,
113
+ seconds: number
114
+ ): ApiConnectorConfig =>
115
+ ApiConnectorConfig.make({
116
+ ...config,
117
+ timeout: Duration.seconds(seconds)
118
+ })
119
+
120
+ const environmentApiKey = (
121
+ config: ApiConnectorConfig,
122
+ environment: Readonly<Record<string, string | undefined>>
123
+ ): string | undefined => {
124
+ switch (config.connectorId.value) {
125
+ case "openai":
126
+ return environment.OPENAI_API_KEY
127
+ case "anthropic":
128
+ return environment.ANTHROPIC_API_KEY
129
+ case "gemini-api":
130
+ return environment.GEMINI_API_KEY ?? environment.GOOGLE_API_KEY
131
+ default:
132
+ return undefined
133
+ }
134
+ }
135
+
136
+ export const enrichApiConnector = (
137
+ config: ApiConnectorConfig,
138
+ environment: Readonly<Record<string, string | undefined>> = process.env
139
+ ): ApiConnectorConfig => {
140
+ const baseUrl = config.baseUrl ?? connectorDefaultBaseUrl(config.connectorId)
141
+ const environmentKey = environmentApiKey(config, environment)
142
+ return ApiConnectorConfig.make({
143
+ ...config,
144
+ ...(baseUrl === undefined ? {} : { baseUrl }),
145
+ ...(config.apiKey !== undefined
146
+ ? { apiKey: config.apiKey }
147
+ : environmentKey === undefined
148
+ ? {}
149
+ : { apiKey: Redacted.make(environmentKey) })
150
+ })
151
+ }
152
+
153
+ export const prepareConnector = (
154
+ config: ConnectorConfig,
155
+ workDir: string,
156
+ environment: Readonly<Record<string, string | undefined>> = process.env
157
+ ): ConnectorConfig =>
158
+ config instanceof ApiConnectorConfig
159
+ ? enrichApiConnector(config, environment)
160
+ : CliConnectorConfig.make({ ...config, workingDir: workDir })
161
+
162
+ export const apiConnectorFromEnvironment = Effect.fn(
163
+ "@llm4ts/runner/Connectors.apiConnectorFromEnvironment"
164
+ )(function* (
165
+ environment: Readonly<Record<string, string | undefined>> = process.env
166
+ ): Effect.fn.Return<ApiConnectorConfig, ScriptUsage> {
167
+ const provider = environment.LLM4TS_PROVIDER?.trim().toLowerCase() ?? "mock"
168
+ const preset = (() => {
169
+ switch (provider) {
170
+ case "mock":
171
+ return mock
172
+ case "openai":
173
+ return openAI
174
+ case "anthropic":
175
+ return anthropic
176
+ case "gemini":
177
+ case "gemini-api":
178
+ return geminiApi
179
+ case "lm-studio":
180
+ case "lmstudio":
181
+ return lmStudio
182
+ case "ollama":
183
+ return ollama
184
+ default:
185
+ return undefined
186
+ }
187
+ })()
188
+ if (preset === undefined) {
189
+ return yield* ScriptUsage.make({
190
+ message:
191
+ `unknown LLM4TS_PROVIDER '${provider}'; expected ` +
192
+ "mock|openai|anthropic|gemini|lm-studio|ollama"
193
+ })
194
+ }
195
+ const model = environment.LLM4TS_MODEL?.trim()
196
+ if (provider !== "mock" && (model === undefined || model.length === 0)) {
197
+ return yield* ScriptUsage.make({
198
+ message: `LLM4TS_MODEL is required for provider '${provider}' (e.g. LLM4TS_MODEL=gpt-4o-mini)`
199
+ })
200
+ }
201
+ return model === undefined || model.length === 0 ? preset : withModel(preset, model)
202
+ })
203
+
204
+ export const coderFromEnv = (
205
+ env: Readonly<Record<string, string | undefined>> = process.env
206
+ ): CliConnectorConfig => {
207
+ switch (env.LLM4TS_CODER ?? env.LLM4ZIO_CODER ?? "claude") {
208
+ case "codex":
209
+ return codex
210
+ case "gemini":
211
+ return gemini
212
+ case "pi":
213
+ return pi
214
+ case "agy":
215
+ return antigravity
216
+ case "grok":
217
+ return grok
218
+ case "cursor":
219
+ return cursor
220
+ case "opencode":
221
+ return opencode
222
+ default:
223
+ return claude
224
+ }
225
+ }
package/src/Doctor.ts ADDED
@@ -0,0 +1,44 @@
1
+ import * as Effect from "effect/Effect"
2
+ import type { HealthStatus } from "@llm4ts/core/Models"
3
+ import type { ConnectorRegistryShape } from "@llm4ts/core/ConnectorRegistry"
4
+ import { nodeFlowRunnerDependencies } from "./FlowRunner.ts"
5
+
6
+ const credentialKeys = [
7
+ "OPENAI_API_KEY",
8
+ "ANTHROPIC_API_KEY",
9
+ "GEMINI_API_KEY",
10
+ "GOOGLE_API_KEY"
11
+ ] as const
12
+
13
+ const statusLine = (id: string, status: HealthStatus): string => {
14
+ const mark =
15
+ status.availability === "Healthy" ? "✔" : status.availability === "Unknown" ? "?" : "✖"
16
+ const auth = status.authStatus === "Unknown" ? "" : ` auth: ${status.authStatus.toLowerCase()}`
17
+ return ` ${mark} ${id.padEnd(14)} ${status.availability.toLowerCase()}${auth}`
18
+ }
19
+
20
+ export const makeDoctorProgram = (
21
+ registry: ConnectorRegistryShape = nodeFlowRunnerDependencies().registry,
22
+ environment: Readonly<Record<string, string | undefined>> = process.env
23
+ ): Effect.Effect<string> =>
24
+ Effect.map(registry.healthCheckAll, (statuses) => {
25
+ const lines: Array<string> = []
26
+ lines.push("llm4ts doctor")
27
+ lines.push("")
28
+ lines.push("connectors:")
29
+ const sorted = Array.from(statuses.entries()).sort(([a], [b]) => a.value.localeCompare(b.value))
30
+ for (const [id, status] of sorted) {
31
+ lines.push(statusLine(id.value, status))
32
+ }
33
+ lines.push("")
34
+ lines.push("credentials:")
35
+ for (const key of credentialKeys) {
36
+ const value = environment[key]
37
+ lines.push(` ${value === undefined || value.length === 0 ? "✖" : "✔"} ${key}`)
38
+ }
39
+ lines.push("")
40
+ lines.push(
41
+ `coder: ${environment.LLM4TS_CODER ?? environment.LLM4ZIO_CODER ?? "claude (default)"}`
42
+ )
43
+ return `${lines.join("\n")}\n`
44
+ })
@@ -0,0 +1,167 @@
1
+ import { readFile, stat } from "node:fs/promises"
2
+ import { resolve } from "node:path"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Schema from "effect/Schema"
5
+
6
+ export interface FlowArgs {
7
+ readonly promptText?: string
8
+ readonly promptFile?: string
9
+ readonly repo?: string
10
+ readonly help?: boolean
11
+ readonly version?: boolean
12
+ }
13
+
14
+ export const FlowUsage =
15
+ 'usage: llm4ts ["<prompt>" | --prompt-file <path> | @<path>] [--repo <path>] | llm4ts doctor'
16
+
17
+ export const FlowHelp = `${FlowUsage}
18
+
19
+ Stream one prompt to your configured coding agent and print the response.
20
+
21
+ arguments:
22
+ "<prompt>" prompt text
23
+ @<path> read the prompt from a file
24
+ --prompt-file <path> read the prompt from a file
25
+ --repo, -C <path> run against this repository (default: current directory)
26
+ -h, --help show this help
27
+ --version print the version
28
+
29
+ commands:
30
+ doctor check connector availability and credentials
31
+
32
+ environment:
33
+ LLM4TS_CODER coding agent: claude|codex|gemini|pi|agy|grok|cursor|opencode (default: claude)
34
+ LLM4TS_VERBOSITY terminal verbosity: quiet|normal|verbose
35
+ OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY
36
+ credentials for API providers
37
+
38
+ A trace of each run is written under .llm4ts/ in the target repository.
39
+ `
40
+
41
+ export class ScriptUsage extends Schema.TaggedErrorClass<ScriptUsage>()("ScriptUsage", {
42
+ message: Schema.String
43
+ }) {}
44
+
45
+ export type FlowArgsParseResult =
46
+ | { readonly ok: true; readonly value: FlowArgs }
47
+ | { readonly ok: false; readonly error: ScriptUsage }
48
+
49
+ export const parseFlowArgs = (args: ReadonlyArray<string>): FlowArgsParseResult => {
50
+ let parsed: FlowArgs = {}
51
+ for (let index = 0; index < args.length; index += 1) {
52
+ const argument = args[index] ?? ""
53
+ if (argument === "--") {
54
+ continue
55
+ } else if (argument === "--help" || argument === "-h") {
56
+ parsed = { ...parsed, help: true }
57
+ } else if (argument === "--version") {
58
+ parsed = { ...parsed, version: true }
59
+ } else if (argument === "--prompt-file") {
60
+ const value = args[index + 1]
61
+ if (value === undefined) {
62
+ return {
63
+ ok: false,
64
+ error: ScriptUsage.make({
65
+ message: `--prompt-file requires a path\n${FlowUsage}`
66
+ })
67
+ }
68
+ }
69
+ parsed = { ...parsed, promptFile: value }
70
+ index += 1
71
+ } else if (argument.startsWith("--prompt-file=")) {
72
+ parsed = {
73
+ ...parsed,
74
+ promptFile: argument.slice("--prompt-file=".length)
75
+ }
76
+ } else if (argument === "--repo" || argument === "-C") {
77
+ const value = args[index + 1]
78
+ if (value === undefined) {
79
+ return {
80
+ ok: false,
81
+ error: ScriptUsage.make({
82
+ message: `${argument} requires a path\n${FlowUsage}`
83
+ })
84
+ }
85
+ }
86
+ parsed = { ...parsed, repo: value }
87
+ index += 1
88
+ } else if (argument.startsWith("--repo=")) {
89
+ parsed = { ...parsed, repo: argument.slice("--repo=".length) }
90
+ } else if (argument.startsWith("--")) {
91
+ return {
92
+ ok: false,
93
+ error: ScriptUsage.make({
94
+ message: `unknown flag: ${argument}\n${FlowUsage}`
95
+ })
96
+ }
97
+ } else if (argument.startsWith("@") && argument.length > 1 && parsed.promptFile === undefined) {
98
+ parsed = { ...parsed, promptFile: argument.slice(1) }
99
+ } else if (parsed.promptText === undefined && argument.trim().length > 0) {
100
+ parsed = { ...parsed, promptText: argument.trim() }
101
+ }
102
+ }
103
+ return { ok: true, value: parsed }
104
+ }
105
+
106
+ export const readFlowPrompt = (
107
+ args: FlowArgs,
108
+ defaultPrompt?: string
109
+ ): Effect.Effect<string, ScriptUsage> => {
110
+ if (args.promptFile !== undefined) {
111
+ return Effect.tryPromise({
112
+ try: () => readFile(args.promptFile ?? "", "utf8"),
113
+ catch: (error) =>
114
+ ScriptUsage.make({
115
+ message: `could not read prompt file ${args.promptFile}: ${
116
+ error instanceof Error ? error.message : String(error)
117
+ }`
118
+ })
119
+ })
120
+ }
121
+ const prompt = args.promptText ?? defaultPrompt
122
+ return prompt === undefined
123
+ ? Effect.fail(ScriptUsage.make({ message: FlowUsage }))
124
+ : Effect.succeed(prompt)
125
+ }
126
+
127
+ export const resolveFlowRepo = (
128
+ args: FlowArgs,
129
+ workspace: string
130
+ ): Effect.Effect<string, ScriptUsage> => {
131
+ const path = resolve(workspace, args.repo ?? ".")
132
+ return Effect.tryPromise({
133
+ try: async () => {
134
+ const information = await stat(path)
135
+ if (!information.isDirectory()) {
136
+ throw new Error("not a directory")
137
+ }
138
+ return path
139
+ },
140
+ catch: (error) =>
141
+ ScriptUsage.make({
142
+ message: `--repo is not a directory: ${path}${
143
+ error instanceof Error ? ` (${error.message})` : ""
144
+ }`
145
+ })
146
+ })
147
+ }
148
+
149
+ export interface FlowInput {
150
+ readonly prompt: string
151
+ readonly workDir: string
152
+ readonly workspace: string
153
+ }
154
+
155
+ export const resolveFlowInput = Effect.fn("@llm4ts/runner/FlowArgs.resolveFlowInput")(function* (
156
+ defaultPrompt?: string,
157
+ argv: ReadonlyArray<string> = process.argv.slice(2),
158
+ workspace = process.cwd()
159
+ ): Effect.fn.Return<FlowInput, ScriptUsage> {
160
+ const parsed = parseFlowArgs(argv)
161
+ if (!parsed.ok) {
162
+ return yield* parsed.error
163
+ }
164
+ const prompt = yield* readFlowPrompt(parsed.value, defaultPrompt)
165
+ const workDir = yield* resolveFlowRepo(parsed.value, workspace)
166
+ return { prompt, workDir, workspace }
167
+ })
@@ -0,0 +1,220 @@
1
+ import * as Clock from "effect/Clock"
2
+ import * as Effect from "effect/Effect"
3
+ import type * as Scope from "effect/Scope"
4
+ import type { ConnectorConfig } from "@llm4ts/core/ConnectorConfig"
5
+ import { defaultReasoningConfig } from "@llm4ts/core/ConnectorConfig"
6
+ import type { ConnectorRegistryShape } from "@llm4ts/core/ConnectorRegistry"
7
+ import type { HttpClientShape } from "@llm4ts/core/HttpClient"
8
+ import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
9
+ import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
10
+ import type { GeminiCliExecutorShape } from "@llm4ts/core/providers/GeminiCliProvider"
11
+ import { createConnectorRegistry } from "@llm4ts/core/providers/ConnectorFactories"
12
+ import { makeCostTracker } from "@llm4ts/flow/CostTracker"
13
+ import { FlowLlmError, type FlowError } from "@llm4ts/flow/FlowError"
14
+ import { makeFlowEventHub, type FlowEventHub } from "@llm4ts/flow/FlowEvents"
15
+ import { FlowContext, type FlowContextShape } from "@llm4ts/flow/FlowContext"
16
+ import { makeFlowRecorder } from "@llm4ts/flow/FlowRecorder"
17
+ import { makeGitHubTool } from "@llm4ts/flow/GitHubTool"
18
+ import { makeGitTool } from "@llm4ts/flow/GitTool"
19
+ import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
20
+ import { nodeHttpClient } from "./NodeHttpClient.ts"
21
+ import { nodeGeminiCliExecutor } from "./NodeGeminiCliExecutor.ts"
22
+ import { nodePlainFileStore } from "./NodePlainFileStore.ts"
23
+ import { nodeProcessExecutor } from "./NodeProcessExecutor.ts"
24
+ import { nodeTemporaryFiles } from "./NodeTemporaryFiles.ts"
25
+ import { prepareConnector } from "./Connectors.ts"
26
+ import {
27
+ consumeTerminalEvents,
28
+ makeTerminalSurface,
29
+ type TerminalSurface,
30
+ type Verbosity
31
+ } from "./Terminal.ts"
32
+
33
+ export interface FlowRunnerDependencies {
34
+ readonly registry: ConnectorRegistryShape
35
+ readonly process: ProcessExecutorShape
36
+ readonly files: PlainFileStoreShape
37
+ }
38
+
39
+ export interface NodeConnectorDependencies {
40
+ readonly http: HttpClientShape
41
+ readonly process: ProcessExecutorShape
42
+ readonly temporaryFiles: TemporaryFilesShape
43
+ readonly geminiCli: GeminiCliExecutorShape
44
+ }
45
+
46
+ export const nodeFlowRunnerDependencies = (): FlowRunnerDependencies => ({
47
+ process: nodeProcessExecutor,
48
+ files: nodePlainFileStore,
49
+ registry: createConnectorRegistry({
50
+ http: nodeHttpClient,
51
+ process: nodeProcessExecutor,
52
+ temporaryFiles: nodeTemporaryFiles,
53
+ geminiCli: nodeGeminiCliExecutor
54
+ })
55
+ })
56
+
57
+ export interface FlowRunnerOptions {
58
+ readonly workDir: string
59
+ readonly workspace: string
60
+ readonly userPrompt: string
61
+ readonly coder: ConnectorConfig
62
+ readonly reasoning?: ConnectorConfig
63
+ readonly reviewers?: ReadonlyArray<ConnectorConfig>
64
+ readonly tracePath?: string
65
+ readonly runId?: string
66
+ readonly verbosity?: Verbosity
67
+ readonly surface?: TerminalSurface
68
+ readonly environment?: Readonly<Record<string, string | undefined>>
69
+ }
70
+
71
+ export interface FlowRunnerBundle {
72
+ readonly context: FlowContextShape
73
+ readonly events: FlowEventHub
74
+ }
75
+
76
+ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeContext")(function* (
77
+ options: FlowRunnerOptions,
78
+ dependencies: FlowRunnerDependencies
79
+ ): Effect.fn.Return<FlowRunnerBundle, FlowError> {
80
+ const events = yield* makeFlowEventHub()
81
+ const coderConfig = prepareConnector(
82
+ options.coder,
83
+ options.workDir,
84
+ options.environment ?? process.env
85
+ )
86
+ const reasoning = defaultReasoningConfig(options.coder, options.reasoning)
87
+ const reasoningPrepared = prepareConnector(
88
+ reasoning,
89
+ options.workDir,
90
+ options.environment ?? process.env
91
+ )
92
+ const coder = yield* dependencies.registry
93
+ .resolve(coderConfig)
94
+ .pipe(Effect.mapError(FlowLlmError.from))
95
+ const reasoningService = yield* dependencies.registry
96
+ .resolve(reasoningPrepared)
97
+ .pipe(Effect.mapError(FlowLlmError.from))
98
+ const reviewers = yield* Effect.forEach(options.reviewers ?? [], (configuration) =>
99
+ dependencies.registry
100
+ .resolve(prepareConnector(configuration, options.workDir, options.environment ?? process.env))
101
+ .pipe(Effect.mapError(FlowLlmError.from))
102
+ )
103
+ return {
104
+ events,
105
+ context: FlowContext.of({
106
+ reasoning: reasoningService,
107
+ coder,
108
+ git: makeGitTool(dependencies.process, options.workDir, events),
109
+ hosting: makeGitHubTool(dependencies.process, options.workDir, events),
110
+ events,
111
+ reviewers,
112
+ coderCapabilities: coder.capabilities,
113
+ userPrompt: options.userPrompt,
114
+ workDir: options.workDir,
115
+ workspace: options.workspace
116
+ })
117
+ }
118
+ })
119
+
120
+ export const runWithBundle = Effect.fn("@llm4ts/runner/FlowRunner.runWithBundle")(function* <
121
+ A,
122
+ E,
123
+ R
124
+ >(
125
+ bundle: FlowRunnerBundle,
126
+ options: FlowRunnerOptions,
127
+ body: (context: FlowContextShape) => Effect.Effect<A, E, R>,
128
+ dependencies: FlowRunnerDependencies = nodeFlowRunnerDependencies()
129
+ ): Effect.fn.Return<A, E | FlowError, R | Scope.Scope> {
130
+ const tracker = yield* makeCostTracker()
131
+ const surface =
132
+ options.surface ?? (yield* makeTerminalSurface(options.environment ?? process.env))
133
+ const palette = surface.palette
134
+ const terminal = yield* consumeTerminalEvents(
135
+ bundle.events,
136
+ surface,
137
+ options.verbosity ?? "Normal"
138
+ )
139
+ yield* tracker.consume(bundle.events)
140
+ const recorder =
141
+ options.tracePath === undefined
142
+ ? undefined
143
+ : yield* makeFlowRecorder(
144
+ dependencies.files,
145
+ options.tracePath,
146
+ options.runId ?? `run-${(yield* Clock.currentTimeMillis).toString()}`
147
+ )
148
+ if (recorder !== undefined) {
149
+ yield* recorder.consume(bundle.events)
150
+ }
151
+ yield* surface.setStatus("preparing flow")
152
+ return yield* body(bundle.context).pipe(
153
+ Effect.provideService(FlowContext, bundle.context),
154
+ Effect.ensuring(
155
+ Effect.all(
156
+ [
157
+ terminal.awaitDrained(),
158
+ tracker.awaitDrained(bundle.events),
159
+ recorder === undefined ? Effect.void : recorder.awaitDrained(bundle.events)
160
+ ],
161
+ { concurrency: "unbounded" }
162
+ ).pipe(
163
+ Effect.andThen(surface.setStatus(undefined)),
164
+ Effect.andThen(tracker.summary),
165
+ Effect.flatMap((summary) => surface.log(`\n${summary}`))
166
+ )
167
+ ),
168
+ Effect.tapError((error) =>
169
+ surface
170
+ .setStatus(undefined)
171
+ .pipe(
172
+ Effect.andThen(
173
+ surface.log(
174
+ `\n${palette.fail(
175
+ `flow failed: ${error instanceof Error ? error.message : String(error)}`
176
+ )}`
177
+ )
178
+ )
179
+ )
180
+ ),
181
+ Effect.tap(() =>
182
+ surface
183
+ .setStatus(undefined)
184
+ .pipe(Effect.andThen(surface.log(`\n${palette.stageDone("flow completed")}`)))
185
+ )
186
+ )
187
+ })
188
+
189
+ export const runEmbedded = Effect.fn("@llm4ts/runner/FlowRunner.runEmbedded")(function* <A, E, R>(
190
+ options: FlowRunnerOptions,
191
+ body: (context: FlowContextShape) => Effect.Effect<A, E, R>,
192
+ dependencies: FlowRunnerDependencies = nodeFlowRunnerDependencies()
193
+ ): Effect.fn.Return<A, E | FlowError, R | Scope.Scope> {
194
+ const bundle = yield* makeFlowRunnerContext(options, dependencies)
195
+ return yield* runWithBundle(bundle, options, body, dependencies)
196
+ })
197
+
198
+ export const runNode = <A, E, R>(
199
+ options: FlowRunnerOptions,
200
+ body: (context: FlowContextShape) => Effect.Effect<A, E, R>
201
+ ): Effect.Effect<A, E | FlowError, R> => Effect.scoped(runEmbedded(options, body))
202
+
203
+ const mainErrorMessage = (error: unknown): string =>
204
+ error instanceof Error && error.message.length > 0 ? error.message : String(error)
205
+
206
+ export const runFlowMain = <E>(program: Effect.Effect<void, E>): void => {
207
+ Effect.runFork(
208
+ program.pipe(
209
+ Effect.match({
210
+ onFailure: (error) =>
211
+ Effect.sync(() => {
212
+ process.stderr.write(`${mainErrorMessage(error)}\n`)
213
+ process.exitCode = 1
214
+ }),
215
+ onSuccess: () => Effect.void
216
+ }),
217
+ Effect.flatten
218
+ )
219
+ )
220
+ }
@@ -0,0 +1,41 @@
1
+ import { createInterface } from "node:readline"
2
+ import { stdin, stdout } from "node:process"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Stream from "effect/Stream"
5
+ import { ProcessError, type FlowError } from "@llm4ts/flow/FlowError"
6
+ import { handleMcpLine, type McpTool } from "@llm4ts/flow/McpServer"
7
+
8
+ export const mcpStdioResponses = <R>(
9
+ lines: Stream.Stream<string, FlowError, R>,
10
+ tools: ReadonlyArray<McpTool>
11
+ ): Stream.Stream<string, FlowError, R> =>
12
+ lines.pipe(
13
+ Stream.mapEffect((line) => handleMcpLine(line, tools)),
14
+ Stream.filter((response): response is string => response !== undefined)
15
+ )
16
+
17
+ export const runMcpStdio = (
18
+ tools: ReadonlyArray<McpTool>,
19
+ write: (line: string) => void = (line) => stdout.write(`${line}\n`)
20
+ ): Effect.Effect<void, FlowError> =>
21
+ Effect.acquireUseRelease(
22
+ Effect.sync(() =>
23
+ createInterface({
24
+ input: stdin,
25
+ crlfDelay: Infinity,
26
+ terminal: false
27
+ })
28
+ ),
29
+ (input) => {
30
+ const lines = Stream.fromAsyncIterable(input, (error) =>
31
+ ProcessError.make({
32
+ message: "MCP stdio input",
33
+ detail: error instanceof Error ? error.message : String(error)
34
+ })
35
+ )
36
+ return mcpStdioResponses(lines, tools).pipe(
37
+ Stream.runForEach((line) => Effect.sync(() => write(line)))
38
+ )
39
+ },
40
+ (input) => Effect.sync(() => input.close())
41
+ )