@llm4ts/runner 3.3.0 → 4.0.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 (130) hide show
  1. package/README.md +1 -1
  2. package/dist/AgentTree.d.ts +2 -3
  3. package/dist/AgentTree.d.ts.map +1 -1
  4. package/dist/AgentTree.js +8 -20
  5. package/dist/AgentTree.js.map +1 -1
  6. package/dist/AgentTreeSurface.d.ts +1 -1
  7. package/dist/AgentTreeSurface.d.ts.map +1 -1
  8. package/dist/AgentTreeSurface.js +2 -5
  9. package/dist/AgentTreeSurface.js.map +1 -1
  10. package/dist/Cli.d.ts.map +1 -1
  11. package/dist/Cli.js +2 -1
  12. package/dist/Cli.js.map +1 -1
  13. package/dist/Connectors.d.ts +27 -2
  14. package/dist/Connectors.d.ts.map +1 -1
  15. package/dist/Connectors.js +76 -89
  16. package/dist/Connectors.js.map +1 -1
  17. package/dist/Doctor.d.ts.map +1 -1
  18. package/dist/Doctor.js +8 -11
  19. package/dist/Doctor.js.map +1 -1
  20. package/dist/EventPresentation.d.ts +35 -0
  21. package/dist/EventPresentation.d.ts.map +1 -0
  22. package/dist/EventPresentation.js +132 -0
  23. package/dist/EventPresentation.js.map +1 -0
  24. package/dist/ExecutorRoster.d.ts.map +1 -1
  25. package/dist/ExecutorRoster.js +4 -6
  26. package/dist/ExecutorRoster.js.map +1 -1
  27. package/dist/FlowArgs.d.ts +1 -1
  28. package/dist/FlowArgs.d.ts.map +1 -1
  29. package/dist/FlowArgs.js +4 -2
  30. package/dist/FlowArgs.js.map +1 -1
  31. package/dist/FlowRunner.d.ts +13 -4
  32. package/dist/FlowRunner.d.ts.map +1 -1
  33. package/dist/FlowRunner.js +50 -39
  34. package/dist/FlowRunner.js.map +1 -1
  35. package/dist/NodeGeminiCliExecutor.d.ts.map +1 -1
  36. package/dist/NodeGeminiCliExecutor.js +7 -4
  37. package/dist/NodeGeminiCliExecutor.js.map +1 -1
  38. package/dist/NodeGeminiModelBridge.d.ts.map +1 -1
  39. package/dist/NodeGeminiModelBridge.js +4 -1
  40. package/dist/NodeGeminiModelBridge.js.map +1 -1
  41. package/dist/NodeHttpClient.d.ts.map +1 -1
  42. package/dist/NodeHttpClient.js +1 -0
  43. package/dist/NodeHttpClient.js.map +1 -1
  44. package/dist/Otel.d.ts.map +1 -1
  45. package/dist/Otel.js +5 -5
  46. package/dist/Otel.js.map +1 -1
  47. package/dist/Package.d.ts.map +1 -1
  48. package/dist/Package.js +1 -0
  49. package/dist/Package.js.map +1 -1
  50. package/dist/Packs.d.ts.map +1 -1
  51. package/dist/Packs.js +2 -1
  52. package/dist/Packs.js.map +1 -1
  53. package/dist/PiModels.d.ts.map +1 -1
  54. package/dist/PiModels.js +6 -9
  55. package/dist/PiModels.js.map +1 -1
  56. package/dist/Terminal.d.ts +13 -11
  57. package/dist/Terminal.d.ts.map +1 -1
  58. package/dist/Terminal.js +24 -80
  59. package/dist/Terminal.js.map +1 -1
  60. package/dist/Tools.d.ts.map +1 -1
  61. package/dist/Tools.js +2 -1
  62. package/dist/Tools.js.map +1 -1
  63. package/dist/Transcripts.d.ts +1 -1
  64. package/dist/Transcripts.d.ts.map +1 -1
  65. package/dist/Transcripts.js +3 -2
  66. package/dist/Transcripts.js.map +1 -1
  67. package/dist/WindowsCommand.d.ts.map +1 -1
  68. package/dist/WindowsCommand.js +1 -0
  69. package/dist/WindowsCommand.js.map +1 -1
  70. package/dist/index.d.ts +1 -2
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +1 -2
  73. package/dist/index.js.map +1 -1
  74. package/package.json +9 -21
  75. package/src/AgentTree.ts +16 -25
  76. package/src/AgentTreeSurface.ts +6 -11
  77. package/src/Cli.ts +2 -1
  78. package/src/Connectors.ts +131 -107
  79. package/src/Doctor.ts +10 -12
  80. package/src/EventPresentation.ts +167 -0
  81. package/src/ExecutorRoster.ts +4 -6
  82. package/src/FlowArgs.ts +7 -2
  83. package/src/FlowRunner.ts +66 -60
  84. package/src/NodeGeminiCliExecutor.ts +8 -4
  85. package/src/NodeGeminiModelBridge.ts +5 -1
  86. package/src/NodeHttpClient.ts +1 -0
  87. package/src/Otel.ts +5 -6
  88. package/src/Package.ts +1 -0
  89. package/src/Packs.ts +2 -1
  90. package/src/PiModels.ts +6 -10
  91. package/src/Terminal.ts +104 -165
  92. package/src/Tools.ts +2 -1
  93. package/src/Transcripts.ts +3 -2
  94. package/src/WindowsCommand.ts +1 -0
  95. package/src/index.ts +3 -2
  96. package/dist/Costs.d.ts +0 -52
  97. package/dist/Costs.d.ts.map +0 -1
  98. package/dist/Costs.js +0 -95
  99. package/dist/Costs.js.map +0 -1
  100. package/dist/McpStdio.d.ts +0 -7
  101. package/dist/McpStdio.d.ts.map +0 -1
  102. package/dist/McpStdio.js +0 -19
  103. package/dist/McpStdio.js.map +0 -1
  104. package/dist/Profile.d.ts +0 -309
  105. package/dist/Profile.d.ts.map +0 -1
  106. package/dist/Profile.js +0 -967
  107. package/dist/Profile.js.map +0 -1
  108. package/dist/ReviewFingerprint.d.ts +0 -2
  109. package/dist/ReviewFingerprint.d.ts.map +0 -1
  110. package/dist/ReviewFingerprint.js +0 -13
  111. package/dist/ReviewFingerprint.js.map +0 -1
  112. package/dist/RunReaders.d.ts +0 -69
  113. package/dist/RunReaders.d.ts.map +0 -1
  114. package/dist/RunReaders.js +0 -283
  115. package/dist/RunReaders.js.map +0 -1
  116. package/dist/StoryReaders.d.ts +0 -16
  117. package/dist/StoryReaders.d.ts.map +0 -1
  118. package/dist/StoryReaders.js +0 -107
  119. package/dist/StoryReaders.js.map +0 -1
  120. package/dist/Watch.d.ts +0 -68
  121. package/dist/Watch.d.ts.map +0 -1
  122. package/dist/Watch.js +0 -261
  123. package/dist/Watch.js.map +0 -1
  124. package/src/Costs.ts +0 -148
  125. package/src/McpStdio.ts +0 -41
  126. package/src/Profile.ts +0 -1213
  127. package/src/ReviewFingerprint.ts +0 -13
  128. package/src/RunReaders.ts +0 -419
  129. package/src/StoryReaders.ts +0 -157
  130. package/src/Watch.ts +0 -446
package/src/Connectors.ts CHANGED
@@ -4,10 +4,22 @@ import * as Redacted from "effect/Redacted"
4
4
  import {
5
5
  ApiConnectorConfig,
6
6
  CliConnectorConfig,
7
- type ConnectorConfig
7
+ type ConnectorConfig,
8
+ type ConnectorConfigOf
8
9
  } from "@llm4ts/core/ConnectorConfig"
9
- import { ConnectorIds, connectorDefaultBaseUrl } from "@llm4ts/core/Models"
10
+ import {
11
+ ConnectorIds,
12
+ apiProviderConnector,
13
+ coderConnector,
14
+ coderNames,
15
+ connectorDefaultBaseUrl,
16
+ connectorRow,
17
+ connectorTable,
18
+ offeredApiProviderNames,
19
+ type ConnectorName
20
+ } from "@llm4ts/core/Models"
10
21
  import { runStateDeniedPaths } from "@llm4ts/flow/RunState"
22
+ import { settingText, settingValue } from "@llm4ts/flow/Settings"
11
23
  import { ScriptUsage } from "./FlowArgs.ts"
12
24
 
13
25
  export const claude = CliConnectorConfig.make({
@@ -53,12 +65,12 @@ export const opencode = CliConnectorConfig.make({
53
65
 
54
66
  export const lmStudio = ApiConnectorConfig.make({
55
67
  connectorId: ConnectorIds.LmStudio,
56
- baseUrl: "http://localhost:1234/v1"
68
+ baseUrl: connectorTable.LmStudio.baseUrl
57
69
  })
58
70
 
59
71
  export const mlxLm = ApiConnectorConfig.make({
60
72
  connectorId: ConnectorIds.MlxLm,
61
- baseUrl: "http://localhost:8080"
73
+ baseUrl: connectorTable.MlxLm.baseUrl
62
74
  })
63
75
 
64
76
  export const openAI = ApiConnectorConfig.make({
@@ -82,6 +94,26 @@ export const mock = ApiConnectorConfig.make({
82
94
  model: "mock"
83
95
  })
84
96
 
97
+ // A connector without a preset here does not compile.
98
+ const presets: { readonly [K in ConnectorName]: ConnectorConfigOf<K> } = {
99
+ OpenAI: openAI,
100
+ Anthropic: anthropic,
101
+ GeminiApi: geminiApi,
102
+ LmStudio: lmStudio,
103
+ Ollama: ollama,
104
+ MlxLm: mlxLm,
105
+ ClaudeCli: claude,
106
+ GeminiCli: gemini,
107
+ OpenCode: opencode,
108
+ Codex: codex,
109
+ Copilot: CliConnectorConfig.make({ connectorId: ConnectorIds.Copilot }),
110
+ Pi: pi,
111
+ AntigravityCli: antigravity,
112
+ Grok: grok,
113
+ Cursor: cursor,
114
+ Mock: mock
115
+ }
116
+
85
117
  export function withModel(config: CliConnectorConfig, model: string): CliConnectorConfig
86
118
  export function withModel(config: ApiConnectorConfig, model: string): ApiConnectorConfig
87
119
  export function withModel(
@@ -134,16 +166,12 @@ const environmentApiKey = (
134
166
  config: ApiConnectorConfig,
135
167
  environment: Readonly<Record<string, string | undefined>>
136
168
  ): string | undefined => {
137
- switch (config.connectorId.value) {
138
- case "openai":
139
- return environment.OPENAI_API_KEY
140
- case "anthropic":
141
- return environment.ANTHROPIC_API_KEY
142
- case "gemini-api":
143
- return environment.GEMINI_API_KEY ?? environment.GOOGLE_API_KEY
144
- default:
145
- return undefined
146
- }
169
+ const row = connectorRow(config.connectorId)
170
+ return row?.kind === "Api"
171
+ ? row.credentialVariables
172
+ .map((variable) => environment[variable])
173
+ .find((value) => value !== undefined)
174
+ : undefined
147
175
  }
148
176
 
149
177
  export const enrichApiConnector = (
@@ -163,9 +191,6 @@ export const enrichApiConnector = (
163
191
  })
164
192
  }
165
193
 
166
- const switchedOn = (value: string | undefined): boolean =>
167
- value !== undefined && ["1", "true", "on", "yes"].includes(value.trim().toLowerCase())
168
-
169
194
  export const prepareConnector = (
170
195
  config: ConnectorConfig,
171
196
  workDir: string,
@@ -179,7 +204,7 @@ export const prepareConnector = (
179
204
  workingDir: workDir,
180
205
  // `LLM4TS_ISOLATED=1` asks every CLI seat of the run to leave the
181
206
  // repository's own harness material unloaded (ADR 0029).
182
- ...(switchedOn(environment.LLM4TS_ISOLATED) ? { isolated: true } : {}),
207
+ ...(settingValue("LLM4TS_ISOLATED", environment) ? { isolated: true } : {}),
183
208
  // Every CLI seat keeps out of the run's own state unless the seat
184
209
  // (or the run) says otherwise; the connector maps it to a deny
185
210
  // rule where its harness has one (`capabilities.pathDeny`).
@@ -188,110 +213,114 @@ export const prepareConnector = (
188
213
 
189
214
  /** The API provider a name stands for (`LLM4TS_PROVIDER`, a roster executor's `harness`). */
190
215
  export const apiPresetFor = (name: string): ApiConnectorConfig | undefined => {
191
- switch (name.trim().toLowerCase()) {
192
- case "mock":
193
- return mock
194
- case "openai":
195
- return openAI
196
- case "anthropic":
197
- return anthropic
198
- case "gemini":
199
- case "gemini-api":
200
- return geminiApi
201
- case "lm-studio":
202
- case "lmstudio":
203
- return lmStudio
204
- case "ollama":
205
- return ollama
206
- case "mlx-lm":
207
- case "mlxlm":
208
- case "mlx":
209
- return mlxLm
210
- default:
211
- return undefined
212
- }
216
+ const connector = apiProviderConnector(name.trim().toLowerCase())
217
+ return connector === undefined ? undefined : presets[connector]
213
218
  }
214
219
 
215
- export const apiConnectorFromEnvironment = Effect.fn(
216
- "@llm4ts/runner/Connectors.apiConnectorFromEnvironment"
217
- )(function* (
218
- environment: Readonly<Record<string, string | undefined>> = process.env
220
+ const apiConnectorNamed = Effect.fnUntraced(function* (
221
+ named: string,
222
+ model: string | undefined
219
223
  ): Effect.fn.Return<ApiConnectorConfig, ScriptUsage> {
220
- const provider = environment.LLM4TS_PROVIDER?.trim().toLowerCase() ?? "mock"
224
+ const provider = named.toLowerCase()
221
225
  const preset = apiPresetFor(provider)
222
226
  if (preset === undefined) {
223
227
  return yield* ScriptUsage.make({
224
- message:
225
- `unknown LLM4TS_PROVIDER '${provider}'; expected ` +
226
- "mock|openai|anthropic|gemini|lm-studio|ollama|mlx-lm"
228
+ message: `unknown LLM4TS_PROVIDER '${provider}'; expected ${offeredApiProviderNames.join("|")}`
227
229
  })
228
230
  }
229
- const model = environment.LLM4TS_MODEL?.trim()
230
- if (provider !== "mock" && (model === undefined || model.length === 0)) {
231
+ if (provider !== "mock" && model === undefined) {
231
232
  return yield* ScriptUsage.make({
232
233
  message: `LLM4TS_MODEL is required for provider '${provider}' (e.g. LLM4TS_MODEL=gpt-4o-mini)`
233
234
  })
234
235
  }
235
- return model === undefined || model.length === 0 ? preset : withModel(preset, model)
236
+ return model === undefined ? preset : withModel(preset, model)
236
237
  })
237
238
 
238
- // Every coding CLI, with the names an operator may write for it. The
239
- // connector's own id is always one of them, derived rather than repeated:
240
- // `gemini-cli` is the string llm4ts prints in events, traces and errors,
241
- // so it is the name an operator has actually seen — it must not resolve to
242
- // a different vendor's CLI.
243
- const coderAliases: ReadonlyArray<readonly [CliConnectorConfig, ReadonlyArray<string>]> = [
244
- [claude, ["claude"]],
245
- [codex, ["codex"]],
246
- [gemini, ["gemini"]],
247
- [pi, ["pi"]],
248
- [antigravity, ["agy", "antigravity"]],
249
- [grok, ["grok"]],
250
- [cursor, ["cursor"]],
251
- [opencode, ["opencode"]]
252
- ]
253
-
254
- const coders = new Map<string, CliConnectorConfig>(
255
- coderAliases.flatMap(([preset, aliases]) =>
256
- [...aliases, preset.connectorId.value].map((name) => [name, preset] as const)
239
+ export const apiConnectorFromEnvironment = Effect.fn(
240
+ "@llm4ts/runner/Connectors.apiConnectorFromEnvironment"
241
+ )(function* (
242
+ environment: Readonly<Record<string, string | undefined>> = process.env
243
+ ): Effect.fn.Return<ApiConnectorConfig, ScriptUsage> {
244
+ return yield* apiConnectorNamed(
245
+ settingText("LLM4TS_PROVIDER", environment) ?? "mock",
246
+ settingValue("LLM4TS_MODEL", environment)
257
247
  )
258
- )
248
+ })
259
249
 
260
- export const coderIds: ReadonlyArray<string> = Object.freeze([...coders.keys()].sort())
250
+ export const coderIds: ReadonlyArray<string> = Object.freeze([...coderNames].sort())
261
251
 
262
- export const coderFor = (name: string): CliConnectorConfig | undefined =>
263
- coders.get(name.trim().toLowerCase())
252
+ export const coderFor = (name: string): CliConnectorConfig | undefined => {
253
+ const connector = coderConnector(name.trim().toLowerCase())
254
+ return connector === undefined ? undefined : presets[connector]
255
+ }
264
256
 
265
- // Unchecked: an unrecognized name falls back to claude, which silently
266
- // runs a CLI the operator did not ask for. Prefer coderFromEnvironment,
267
- // which refuses and names the coders it knows.
268
- export const coderFromEnv = (
269
- env: Readonly<Record<string, string | undefined>> = process.env
270
- ): CliConnectorConfig => {
271
- const requested = (env.LLM4TS_CODER ?? "").trim()
272
- return (requested.length === 0 ? claude : coderFor(requested)) ?? claude
257
+ export interface SeatDefaults {
258
+ /** The coder when `LLM4TS_CODER` is unset; claude when omitted. */
259
+ readonly coder?: CliConnectorConfig
260
+ /** The reasoner when `LLM4TS_REASONER` is unset; the coder seat when omitted. */
261
+ readonly reasoner?: CliConnectorConfig
273
262
  }
274
263
 
275
- // The checked reading, matching apiConnectorFromEnvironment above: an
276
- // unset variable is the claude default, and an unknown one is a usage
277
- // error rather than a different coder than the one asked for.
278
- export const coderFromEnvironment = Effect.fn("@llm4ts/runner/Connectors.coderFromEnvironment")(
279
- function* (
280
- environment: Readonly<Record<string, string | undefined>> = process.env
281
- ): Effect.fn.Return<CliConnectorConfig, ScriptUsage> {
282
- const requested = (environment.LLM4TS_CODER ?? "").trim()
283
- if (requested.length === 0) {
284
- return claude
285
- }
286
- const preset = coderFor(requested)
287
- return preset === undefined
288
- ? yield* ScriptUsage.make({
289
- message: `unknown LLM4TS_CODER '${requested}'; expected ${coderIds.join("|")}`
264
+ export interface Seats {
265
+ readonly coder: CliConnectorConfig
266
+ readonly reasoning: CliConnectorConfig
267
+ }
268
+
269
+ type Environment = Readonly<Record<string, string | undefined>>
270
+
271
+ const namedSeat = (
272
+ variable: "LLM4TS_CODER" | "LLM4TS_REASONER",
273
+ environment: Environment
274
+ ): Effect.Effect<CliConnectorConfig | undefined, ScriptUsage> => {
275
+ const requested = settingText(variable, environment)
276
+ if (requested === undefined) {
277
+ return Effect.succeed(undefined)
278
+ }
279
+ const preset = coderFor(requested)
280
+ return preset === undefined
281
+ ? Effect.fail(
282
+ ScriptUsage.make({
283
+ message: `unknown ${variable} '${requested}'; expected ${coderIds.join("|")}`
290
284
  })
291
- : preset
285
+ )
286
+ : Effect.succeed(preset)
287
+ }
288
+
289
+ /**
290
+ * The coder and reasoning seats of a flow that runs both, from `LLM4TS_CODER`
291
+ * and `LLM4TS_REASONER`. An unknown name is a usage error naming the accepted
292
+ * names, never another agent. A flow with one seat reads that one alone
293
+ * (`coderFromEnvironment`, `reasonerFromEnvironment`).
294
+ */
295
+ export const seatsFromEnvironment = Effect.fn("@llm4ts/runner/Connectors.seatsFromEnvironment")(
296
+ function* (
297
+ defaults: SeatDefaults = {},
298
+ environment: Environment = process.env
299
+ ): Effect.fn.Return<Seats, ScriptUsage> {
300
+ const coder = (yield* namedSeat("LLM4TS_CODER", environment)) ?? defaults.coder ?? claude
301
+ const reasoner = yield* namedSeat("LLM4TS_REASONER", environment)
302
+ return { coder, reasoning: reasoner ?? defaults.reasoner ?? coder }
292
303
  }
293
304
  )
294
305
 
306
+ /**
307
+ * `LLM4TS_CODER` alone, for a flow that runs no separate reasoning seat. A
308
+ * flow answers only for the seats it uses: a bad `LLM4TS_REASONER` is not
309
+ * this flow's to refuse.
310
+ */
311
+ export const coderFromEnvironment = (
312
+ environment: Environment = process.env,
313
+ fallback: CliConnectorConfig = claude
314
+ ): Effect.Effect<CliConnectorConfig, ScriptUsage> =>
315
+ Effect.map(namedSeat("LLM4TS_CODER", environment), (coder) => coder ?? fallback)
316
+
317
+ /** `LLM4TS_REASONER` alone, for a flow that edits nothing. */
318
+ export const reasonerFromEnvironment = (
319
+ environment: Environment = process.env,
320
+ fallback: CliConnectorConfig = claude
321
+ ): Effect.Effect<CliConnectorConfig, ScriptUsage> =>
322
+ Effect.map(namedSeat("LLM4TS_REASONER", environment), (reasoner) => reasoner ?? fallback)
323
+
295
324
  /**
296
325
  * The judgment seat from the environment (ADR 0017): `LLM4TS_JUDGMENT_PROVIDER`
297
326
  * names an API provider the way `LLM4TS_PROVIDER` does, `LLM4TS_JUDGMENT_MODEL`
@@ -302,13 +331,8 @@ export const judgmentConnectorFromEnvironment = Effect.fn(
302
331
  )(function* (
303
332
  environment: Readonly<Record<string, string | undefined>> = process.env
304
333
  ): Effect.fn.Return<ApiConnectorConfig | undefined, ScriptUsage> {
305
- const provider = environment.LLM4TS_JUDGMENT_PROVIDER?.trim()
306
- if (provider === undefined || provider.length === 0) {
307
- return undefined
308
- }
309
- return yield* apiConnectorFromEnvironment({
310
- ...environment,
311
- LLM4TS_PROVIDER: provider,
312
- LLM4TS_MODEL: environment.LLM4TS_JUDGMENT_MODEL
313
- })
334
+ const provider = settingText("LLM4TS_JUDGMENT_PROVIDER", environment)
335
+ return provider === undefined
336
+ ? undefined
337
+ : yield* apiConnectorNamed(provider, settingValue("LLM4TS_JUDGMENT_MODEL", environment))
314
338
  })
package/src/Doctor.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import type { ConnectorKind, HealthStatus } from "@llm4ts/core/Models"
3
- import { ConnectorIds } from "@llm4ts/core/Models"
3
+ import { ConnectorIds, credentialVariables } from "@llm4ts/core/Models"
4
4
  import type { ConnectorRegistryShape } from "@llm4ts/core/ConnectorRegistry"
5
5
  import { nodePreflightReport, type NodePreflightReport } from "@llm4ts/flow/NodePreflight"
6
+ import { settingText, settingValue } from "@llm4ts/flow/Settings"
6
7
  import { nodeFlowRunnerDependencies } from "./FlowRunner.ts"
7
8
  import { nodePlainFileStore } from "./NodePlainFileStore.ts"
8
9
  import { nodeProcessExecutor } from "./NodeProcessExecutor.ts"
@@ -15,13 +16,6 @@ import {
15
16
  import { systemAdminPolicies, systemPolicyProblem } from "./NodeGeminiModelBridge.ts"
16
17
  import { otelConfig, otelDoctorLine, probeOtelEndpoint, type OtelConfig } from "./Otel.ts"
17
18
 
18
- const credentialKeys = [
19
- "OPENAI_API_KEY",
20
- "ANTHROPIC_API_KEY",
21
- "GEMINI_API_KEY",
22
- "GOOGLE_API_KEY"
23
- ] as const
24
-
25
19
  /**
26
20
  * How far a connector's health was verified: `"auth"` means the CLI's own
27
21
  * login check answered (`authStatus` is its word); `"version"` means only
@@ -133,7 +127,7 @@ const wrap = (text: string, width: number, indent: string): ReadonlyArray<string
133
127
  }
134
128
 
135
129
  const selectedCoder = (environment: Readonly<Record<string, string | undefined>>): string =>
136
- environment.LLM4TS_CODER ?? "claude (default)"
130
+ settingText("LLM4TS_CODER", environment) ?? "claude (default)"
137
131
 
138
132
  // Re-exported so `@llm4ts/runner/Doctor` stays the one place callers look
139
133
  // for pi-config questions, while the parsing itself has no dependency on
@@ -161,7 +155,7 @@ export const geminiBridgePrerequisites = (
161
155
  environment: Readonly<Record<string, string | undefined>>,
162
156
  modelsJson: string | undefined
163
157
  ): PrerequisiteReport | undefined => {
164
- if (!truthy(environment.LLM4TS_GEMINI_BRIDGE)) {
158
+ if (!settingValue("LLM4TS_GEMINI_BRIDGE", environment)) {
165
159
  return undefined
166
160
  }
167
161
  const port = bridgePort(environment)
@@ -254,7 +248,11 @@ export const geminiModelModePrerequisites = (
254
248
  systemPolicies: ReadonlyArray<string>
255
249
  ): PrerequisiteReport | undefined => {
256
250
  const mode = geminiBridgeMode(environment)
257
- if (!truthy(environment.LLM4TS_GEMINI_BRIDGE) || mode._tag !== "Mode" || mode.mode !== "model") {
251
+ if (
252
+ !settingValue("LLM4TS_GEMINI_BRIDGE", environment) ||
253
+ mode._tag !== "Mode" ||
254
+ mode.mode !== "model"
255
+ ) {
258
256
  return undefined
259
257
  }
260
258
  const detail = [
@@ -324,7 +322,7 @@ export const collectDoctorReport = (
324
322
  const lines: Array<string> = []
325
323
  lines.push("")
326
324
  lines.push("credentials:")
327
- for (const key of credentialKeys) {
325
+ for (const key of credentialVariables) {
328
326
  const value = environment[key]
329
327
  lines.push(` ${value === undefined || value.length === 0 ? "✖" : "✔"} ${key}`)
330
328
  }
@@ -0,0 +1,167 @@
1
+ import * as Schema from "effect/Schema"
2
+ import type { FlowEvent, FlowEventByTag, FlowEventTag } from "@llm4ts/flow/FlowEvents"
3
+
4
+ export const Verbosity = Schema.Literals(["Quiet", "Normal", "Verbose", "Debug"])
5
+ export type Verbosity = typeof Verbosity.Type
6
+
7
+ const rank: Readonly<Record<Verbosity, number>> = { Quiet: 0, Normal: 1, Verbose: 2, Debug: 3 }
8
+
9
+ /** Whether `verbosity` is `least` or louder. */
10
+ export const atLeast = (verbosity: Verbosity, least: Verbosity): boolean =>
11
+ rank[verbosity] >= rank[least]
12
+
13
+ /** A line of `llm4ts watch` and of the MCP run digests, and the lane it is filed under. */
14
+ export interface DigestLine {
15
+ readonly text: string
16
+ readonly lane?: string
17
+ }
18
+
19
+ /** How one event shows: on the terminal, in a profile's gaps, in a digest. */
20
+ export interface Presentation<E> {
21
+ /**
22
+ * Whether the terminal prints it as a line of its own: `never`, from a
23
+ * verbosity up (`Quiet`: always), or by a rule of its own.
24
+ */
25
+ readonly line: "never" | Verbosity | ((event: E, verbosity: Verbosity) => boolean)
26
+ /** The event by kind, never its content: how `llm4ts profile` names it around a gap. */
27
+ readonly kind: string | ((event: E) => string)
28
+ /** Its line in a digest. Absent: digests leave the event out. */
29
+ readonly digest?: (event: E) => DigestLine
30
+ }
31
+
32
+ const laned = (lane: string | undefined): { readonly lane?: string } =>
33
+ lane === undefined ? {} : { lane }
34
+
35
+ const capability = "a capability check"
36
+ const roster = "a roster change"
37
+ const other = "an event"
38
+
39
+ /**
40
+ * Screen and digest policy for every event. Exhaustive by type: adding an
41
+ * event class without an entry here fails `pnpm typecheck`.
42
+ */
43
+ export const eventPresentation: {
44
+ readonly [K in FlowEventTag]: Presentation<FlowEventByTag[K]>
45
+ } = {
46
+ // Advise mode publishes a separate Info event for the operator.
47
+ JudgmentObserved: { line: "never", kind: other },
48
+ // For the trace file.
49
+ BlackboardRun: { line: "never", kind: other },
50
+ StageStarted: { line: "Quiet", kind: "a stage start" },
51
+ StageCompleted: { line: "Quiet", kind: "a stage end" },
52
+ StageFailed: {
53
+ line: "Quiet",
54
+ kind: "a stage failure",
55
+ digest: (event) => ({ text: `${event.stage} failed: ${event.message}`, ...laned(event.lane) })
56
+ },
57
+ Aborted: {
58
+ line: "Quiet",
59
+ kind: other,
60
+ digest: (event) => ({ text: `aborted: ${event.message}` })
61
+ },
62
+ Info: { line: "Normal", kind: "a log note" },
63
+ ToolUse: { line: "Normal", kind: "a coder tool call" },
64
+ AssistantMessage: { line: "Normal", kind: "an assistant message" },
65
+ TokensUsed: { line: "Verbose", kind: "a token report" },
66
+ // Progress feeds the status rows.
67
+ UsageProgress: { line: "never", kind: other },
68
+ ReviewFindings: { line: "Normal", kind: "a review round" },
69
+ // Every git read and gate command is a capability event: useful in a
70
+ // trace, noise on screen once several stories run.
71
+ CapabilityUsed: { line: "Verbose", kind: capability },
72
+ CapabilityDenied: { line: "Quiet", kind: capability },
73
+ CapabilityUnenforceable: {
74
+ line: "Quiet",
75
+ kind: capability,
76
+ digest: (event) => ({ text: `capability unenforceable: ${event.detail}` })
77
+ },
78
+ EvidenceChecked: { line: "Quiet", kind: "an evidence check" },
79
+ ReviewFindingDemoted: { line: "Quiet", kind: "a review finding demoted" },
80
+ ExecutorLeased: { line: "Normal", kind: roster },
81
+ // A lease ending is routine.
82
+ ExecutorReleased: { line: "never", kind: roster },
83
+ ExecutorExcluded: {
84
+ line: "Normal",
85
+ kind: roster,
86
+ digest: (event) => ({ text: `${event.executor} excluded: ${event.reason}` })
87
+ },
88
+ ExecutorResumed: { line: "Normal", kind: roster },
89
+ ExecutorHandedOver: {
90
+ line: "Normal",
91
+ kind: roster,
92
+ digest: (event) => ({
93
+ text: `${event.from} handed over ${event.role} (${event.scope}): ${event.reason}`,
94
+ ...laned(event.label)
95
+ })
96
+ },
97
+ // The story's outcome already says whether the judge cleared it.
98
+ StoryJudged: {
99
+ line: "never",
100
+ kind: "a judge verdict",
101
+ digest: (event) => ({
102
+ text: `${event.lane} judged round ${event.round}: ${event.cleared ? "cleared" : `${event.issues} issues`}`,
103
+ lane: event.lane
104
+ })
105
+ },
106
+ RoundtableTurn: { line: "never", kind: "a roundtable turn" },
107
+ BriefReported: { line: "never", kind: "a brief report" },
108
+ Timed: {
109
+ line: "never",
110
+ kind: (event) => {
111
+ switch (event.kind) {
112
+ case "model":
113
+ return `the end of a ${event.label} call`
114
+ case "tool":
115
+ return `the end of tool ${event.label}`
116
+ case "gate":
117
+ return `the end of gate ${event.label}`
118
+ case "wait":
119
+ return `the end of a wait for the ${event.label}`
120
+ default:
121
+ return `the end of ${event.label}`
122
+ }
123
+ }
124
+ },
125
+ Began: {
126
+ // A gate or a setup may run for many minutes: its start is a line, so a
127
+ // long one reads as running, not stuck. Other work starts too often.
128
+ line: (event, verbosity) => event.kind === "gate" && verbosity !== "Quiet",
129
+ kind: (event) =>
130
+ event.kind === "model"
131
+ ? `the start of a ${event.label} call`
132
+ : `the start of ${event.kind} ${event.label}`,
133
+ digest: (event) => ({ text: `began ${event.kind} ${event.label}`, ...laned(event.lane) })
134
+ },
135
+ TasksPlanned: { line: "never", kind: other },
136
+ TaskStarted: { line: "never", kind: other },
137
+ TaskCompleted: { line: "never", kind: other },
138
+ StoryStatusChanged: {
139
+ line: "never",
140
+ kind: other,
141
+ digest: (event) => ({ text: `story ${event.id} ${event.status}`, lane: event.id })
142
+ },
143
+ CriterionChecked: {
144
+ line: "Normal",
145
+ kind: other,
146
+ digest: (event) => ({ text: `criterion ${event.tag} ${event.status}`, lane: event.story })
147
+ },
148
+ Parked: {
149
+ line: "Normal",
150
+ kind: other,
151
+ digest: (event) => ({ text: `parked ${event.item}: ${event.reason}`, lane: event.story })
152
+ }
153
+ }
154
+
155
+ // A tag and its event are one value's, so the entry found by the tag takes
156
+ // the event. TypeScript does not correlate the two across the union; reading
157
+ // the entry through a generic key is the step that lets it.
158
+ const entryOf = <K extends FlowEventTag>(tag: K): Presentation<FlowEventByTag[K]> =>
159
+ eventPresentation[tag]
160
+
161
+ /** The entry of an event, to apply to that event. */
162
+ export const presentationOf = (event: FlowEvent): Presentation<FlowEvent> => entryOf(event._tag)
163
+
164
+ /** The tags a digest shows: the entries with a `digest`. */
165
+ export const digestedTags: ReadonlyArray<string> = Object.entries(eventPresentation)
166
+ .filter(([, entry]) => entry.digest !== undefined)
167
+ .map(([tag]) => tag)
@@ -33,6 +33,7 @@ import {
33
33
  effortOf,
34
34
  type Role
35
35
  } from "@llm4ts/flow/Roster"
36
+ import { settingValue } from "@llm4ts/flow/Settings"
36
37
  import { apiPresetFor, asReadOnly, coderFor, withEnvironment, withModel } from "./Connectors.ts"
37
38
 
38
39
  type Environment = Readonly<Record<string, string | undefined>>
@@ -188,11 +189,11 @@ export interface RosterSource {
188
189
  export const loadRosterDocument = Effect.fn("@llm4ts/runner/ExecutorRoster.load")(function* (
189
190
  source: RosterSource
190
191
  ): Effect.fn.Return<RosterDocument | undefined, FlowError> {
191
- const chosen = source.environment.LLM4TS_ROSTER?.trim()
192
+ const chosen = settingValue("LLM4TS_ROSTER", source.environment)
192
193
  if (chosen === "none") {
193
194
  return undefined
194
195
  }
195
- const loaded = yield* chosen !== undefined && chosen.length > 0
196
+ const loaded = yield* chosen !== undefined
196
197
  ? Effect.flatMap(readRoster(source.files, chosen), (document) =>
197
198
  document === undefined
198
199
  ? Effect.fail(RosterInvalid.make({ path: chosen, violations: ["no roster file there"] }))
@@ -218,10 +219,7 @@ export const loadRosterDocument = Effect.fn("@llm4ts/runner/ExecutorRoster.load"
218
219
  return undefined
219
220
  }
220
221
  const { document, where } = loaded
221
- const ids = (source.environment.LLM4TS_EXECUTORS ?? "")
222
- .split(",")
223
- .map((id) => id.trim())
224
- .filter((id) => id.length > 0)
222
+ const ids = settingValue("LLM4TS_EXECUTORS", source.environment) ?? []
225
223
  const unknown = ids.filter((id) => !document.executors.some((spec) => spec.id === id))
226
224
  const narrowed = narrowRoster(document, ids)
227
225
  const violations = [
package/src/FlowArgs.ts CHANGED
@@ -2,6 +2,7 @@ import { readFile, stat } from "node:fs/promises"
2
2
  import { resolve } from "node:path"
3
3
  import * as Effect from "effect/Effect"
4
4
  import * as Schema from "effect/Schema"
5
+ import { connectorTable, offeredCoderNames } from "@llm4ts/core/Models"
5
6
 
6
7
  export interface FlowArgs {
7
8
  readonly promptText?: string
@@ -11,6 +12,10 @@ export interface FlowArgs {
11
12
  readonly version?: boolean
12
13
  }
13
14
 
15
+ const firstCredentialVariables = Object.values(connectorTable).flatMap((row) =>
16
+ row.kind === "Api" ? row.credentialVariables.slice(0, 1) : []
17
+ )
18
+
14
19
  export const FlowUsage =
15
20
  'usage: llm4ts ["<prompt>" | --prompt-file <path> | @<path>] [--repo <path>] | llm4ts doctor'
16
21
 
@@ -30,13 +35,13 @@ commands:
30
35
  doctor check connector availability and credentials
31
36
 
32
37
  environment:
33
- LLM4TS_CODER coding agent: claude|codex|gemini|pi|agy|grok|cursor|opencode (default: claude)
38
+ LLM4TS_CODER coding agent: ${offeredCoderNames.join("|")} (default: claude)
34
39
  LLM4TS_VERBOSITY terminal verbosity: quiet|normal|verbose
35
40
  LLM4TS_UI tree: the full-screen agent tree (ADR 0022; q returns to the classic view)
36
41
  LLM4TS_TRANSCRIPT on: record each seat's input and output under .llm4ts/transcripts/
37
42
  LLM4TS_OTEL on: export spans over OTLP (a local Phoenix at localhost:6006 unless OTEL_* says otherwise)
38
43
  LLM4TS_OTEL_CONTENT on|full: prompts, replies and tool I/O travel too (off by default)
39
- OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY
44
+ ${firstCredentialVariables.join(", ")}
40
45
  credentials for API providers
41
46
 
42
47
  A trace of each run is written under .llm4ts/ in the target repository.