@llm4ts/flow 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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/dist/AzureDevOpsTool.d.ts +60 -0
  4. package/dist/AzureDevOpsTool.d.ts.map +1 -0
  5. package/dist/AzureDevOpsTool.js +163 -0
  6. package/dist/AzureDevOpsTool.js.map +1 -0
  7. package/dist/Bench.d.ts +133 -0
  8. package/dist/Bench.d.ts.map +1 -0
  9. package/dist/Bench.js +272 -0
  10. package/dist/Bench.js.map +1 -0
  11. package/dist/BenchReport.d.ts +9 -0
  12. package/dist/BenchReport.d.ts.map +1 -0
  13. package/dist/BenchReport.js +360 -0
  14. package/dist/BenchReport.js.map +1 -0
  15. package/dist/CapabilityGuard.d.ts +6 -0
  16. package/dist/CapabilityGuard.d.ts.map +1 -0
  17. package/dist/CapabilityGuard.js +25 -0
  18. package/dist/CapabilityGuard.js.map +1 -0
  19. package/dist/Chat.d.ts +15 -0
  20. package/dist/Chat.d.ts.map +1 -0
  21. package/dist/Chat.js +36 -0
  22. package/dist/Chat.js.map +1 -0
  23. package/dist/Classified.d.ts +14 -0
  24. package/dist/Classified.d.ts.map +1 -0
  25. package/dist/Classified.js +48 -0
  26. package/dist/Classified.js.map +1 -0
  27. package/dist/CostLedger.d.ts +55 -0
  28. package/dist/CostLedger.d.ts.map +1 -0
  29. package/dist/CostLedger.js +126 -0
  30. package/dist/CostLedger.js.map +1 -0
  31. package/dist/CostTracker.d.ts +13 -0
  32. package/dist/CostTracker.d.ts.map +1 -0
  33. package/dist/CostTracker.js +116 -0
  34. package/dist/CostTracker.js.map +1 -0
  35. package/dist/Equiv.d.ts +163 -0
  36. package/dist/Equiv.d.ts.map +1 -0
  37. package/dist/Equiv.js +256 -0
  38. package/dist/Equiv.js.map +1 -0
  39. package/dist/EquivReport.d.ts +16 -0
  40. package/dist/EquivReport.d.ts.map +1 -0
  41. package/dist/EquivReport.js +77 -0
  42. package/dist/EquivReport.js.map +1 -0
  43. package/dist/Flow.d.ts +25 -0
  44. package/dist/Flow.d.ts.map +1 -0
  45. package/dist/Flow.js +39 -0
  46. package/dist/Flow.js.map +1 -0
  47. package/dist/FlowContext.d.ts +23 -0
  48. package/dist/FlowContext.d.ts.map +1 -0
  49. package/dist/FlowContext.js +4 -0
  50. package/dist/FlowContext.js.map +1 -0
  51. package/dist/FlowError.d.ts +80 -0
  52. package/dist/FlowError.d.ts.map +1 -0
  53. package/dist/FlowError.js +92 -0
  54. package/dist/FlowError.js.map +1 -0
  55. package/dist/FlowEvents.d.ts +101 -0
  56. package/dist/FlowEvents.d.ts.map +1 -0
  57. package/dist/FlowEvents.js +103 -0
  58. package/dist/FlowEvents.js.map +1 -0
  59. package/dist/FlowRecorder.d.ts +26 -0
  60. package/dist/FlowRecorder.d.ts.map +1 -0
  61. package/dist/FlowRecorder.js +74 -0
  62. package/dist/FlowRecorder.js.map +1 -0
  63. package/dist/GitHubTool.d.ts +53 -0
  64. package/dist/GitHubTool.d.ts.map +1 -0
  65. package/dist/GitHubTool.js +191 -0
  66. package/dist/GitHubTool.js.map +1 -0
  67. package/dist/GitTool.d.ts +46 -0
  68. package/dist/GitTool.d.ts.map +1 -0
  69. package/dist/GitTool.js +97 -0
  70. package/dist/GitTool.js.map +1 -0
  71. package/dist/Interaction.d.ts +21 -0
  72. package/dist/Interaction.d.ts.map +1 -0
  73. package/dist/Interaction.js +9 -0
  74. package/dist/Interaction.js.map +1 -0
  75. package/dist/McpServer.d.ts +18 -0
  76. package/dist/McpServer.d.ts.map +1 -0
  77. package/dist/McpServer.js +122 -0
  78. package/dist/McpServer.js.map +1 -0
  79. package/dist/Mermaid.d.ts +7 -0
  80. package/dist/Mermaid.d.ts.map +1 -0
  81. package/dist/Mermaid.js +98 -0
  82. package/dist/Mermaid.js.map +1 -0
  83. package/dist/Pack.d.ts +40 -0
  84. package/dist/Pack.d.ts.map +1 -0
  85. package/dist/Pack.js +141 -0
  86. package/dist/Pack.js.map +1 -0
  87. package/dist/Package.d.ts +2 -0
  88. package/dist/Package.d.ts.map +1 -0
  89. package/dist/Package.js +2 -0
  90. package/dist/Package.js.map +1 -0
  91. package/dist/Persistence.d.ts +32 -0
  92. package/dist/Persistence.d.ts.map +1 -0
  93. package/dist/Persistence.js +85 -0
  94. package/dist/Persistence.js.map +1 -0
  95. package/dist/Plan.d.ts +26 -0
  96. package/dist/Plan.d.ts.map +1 -0
  97. package/dist/Plan.js +101 -0
  98. package/dist/Plan.js.map +1 -0
  99. package/dist/PlanExecution.d.ts +7 -0
  100. package/dist/PlanExecution.d.ts.map +1 -0
  101. package/dist/PlanExecution.js +19 -0
  102. package/dist/PlanExecution.js.map +1 -0
  103. package/dist/Planner.d.ts +32 -0
  104. package/dist/Planner.d.ts.map +1 -0
  105. package/dist/Planner.js +97 -0
  106. package/dist/Planner.js.map +1 -0
  107. package/dist/PrSummary.d.ts +15 -0
  108. package/dist/PrSummary.d.ts.map +1 -0
  109. package/dist/PrSummary.js +29 -0
  110. package/dist/PrSummary.js.map +1 -0
  111. package/dist/PriceList.d.ts +11 -0
  112. package/dist/PriceList.d.ts.map +1 -0
  113. package/dist/PriceList.js +79 -0
  114. package/dist/PriceList.js.map +1 -0
  115. package/dist/Provenance.d.ts +27 -0
  116. package/dist/Provenance.d.ts.map +1 -0
  117. package/dist/Provenance.js +38 -0
  118. package/dist/Provenance.js.map +1 -0
  119. package/dist/Replay.d.ts +29 -0
  120. package/dist/Replay.d.ts.map +1 -0
  121. package/dist/Replay.js +181 -0
  122. package/dist/Replay.js.map +1 -0
  123. package/dist/Review.d.ts +71 -0
  124. package/dist/Review.d.ts.map +1 -0
  125. package/dist/Review.js +204 -0
  126. package/dist/Review.js.map +1 -0
  127. package/dist/ReviewCache.d.ts +6 -0
  128. package/dist/ReviewCache.d.ts.map +1 -0
  129. package/dist/ReviewCache.js +28 -0
  130. package/dist/ReviewCache.js.map +1 -0
  131. package/dist/Reviewer.d.ts +12 -0
  132. package/dist/Reviewer.d.ts.map +1 -0
  133. package/dist/Reviewer.js +30 -0
  134. package/dist/Reviewer.js.map +1 -0
  135. package/dist/SpecChecks.d.ts +17 -0
  136. package/dist/SpecChecks.d.ts.map +1 -0
  137. package/dist/SpecChecks.js +107 -0
  138. package/dist/SpecChecks.js.map +1 -0
  139. package/dist/Survey.d.ts +32 -0
  140. package/dist/Survey.d.ts.map +1 -0
  141. package/dist/Survey.js +119 -0
  142. package/dist/Survey.js.map +1 -0
  143. package/dist/TransientRetry.d.ts +16 -0
  144. package/dist/TransientRetry.d.ts.map +1 -0
  145. package/dist/TransientRetry.js +102 -0
  146. package/dist/TransientRetry.js.map +1 -0
  147. package/dist/Workspace.d.ts +39 -0
  148. package/dist/Workspace.d.ts.map +1 -0
  149. package/dist/Workspace.js +192 -0
  150. package/dist/Workspace.js.map +1 -0
  151. package/dist/WorkspaceLayout.d.ts +3 -0
  152. package/dist/WorkspaceLayout.d.ts.map +1 -0
  153. package/dist/WorkspaceLayout.js +19 -0
  154. package/dist/WorkspaceLayout.js.map +1 -0
  155. package/dist/WorkspaceTools.d.ts +4 -0
  156. package/dist/WorkspaceTools.d.ts.map +1 -0
  157. package/dist/WorkspaceTools.js +130 -0
  158. package/dist/WorkspaceTools.js.map +1 -0
  159. package/package.json +91 -0
  160. package/src/AzureDevOpsTool.ts +291 -0
  161. package/src/Bench.ts +340 -0
  162. package/src/BenchReport.ts +447 -0
  163. package/src/CapabilityGuard.ts +43 -0
  164. package/src/Chat.ts +60 -0
  165. package/src/Classified.ts +67 -0
  166. package/src/CostLedger.ts +171 -0
  167. package/src/CostTracker.ts +170 -0
  168. package/src/Equiv.ts +378 -0
  169. package/src/EquivReport.ts +92 -0
  170. package/src/Flow.ts +72 -0
  171. package/src/FlowContext.ts +23 -0
  172. package/src/FlowError.ts +112 -0
  173. package/src/FlowEvents.ts +147 -0
  174. package/src/FlowRecorder.ts +116 -0
  175. package/src/GitHubTool.ts +315 -0
  176. package/src/GitTool.ts +214 -0
  177. package/src/Interaction.ts +23 -0
  178. package/src/McpServer.ts +168 -0
  179. package/src/Mermaid.ts +112 -0
  180. package/src/Pack.ts +213 -0
  181. package/src/Package.ts +1 -0
  182. package/src/Persistence.ts +198 -0
  183. package/src/Plan.ts +119 -0
  184. package/src/PlanExecution.ts +46 -0
  185. package/src/Planner.ts +134 -0
  186. package/src/PrSummary.ts +37 -0
  187. package/src/PriceList.ts +91 -0
  188. package/src/Provenance.ts +85 -0
  189. package/src/Replay.ts +265 -0
  190. package/src/Review.ts +337 -0
  191. package/src/ReviewCache.ts +45 -0
  192. package/src/Reviewer.ts +31 -0
  193. package/src/SpecChecks.ts +143 -0
  194. package/src/Survey.ts +144 -0
  195. package/src/TransientRetry.ts +238 -0
  196. package/src/Workspace.ts +255 -0
  197. package/src/WorkspaceLayout.ts +22 -0
  198. package/src/WorkspaceTools.ts +188 -0
@@ -0,0 +1,447 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Schema from "effect/Schema"
3
+ import { BenchRecord, CurrentBenchSchema, type BenchPhase } from "./Bench.ts"
4
+ import { PlanParseError, UnsupportedSchemaVersion, type FlowError } from "./FlowError.ts"
5
+ import type { PlainFileStoreShape } from "./Persistence.ts"
6
+ import { estimateCostUsd } from "./PriceList.ts"
7
+ import { TokenUsage } from "@llm4ts/core/Models"
8
+
9
+ export const median = (values: ReadonlyArray<number>): number => {
10
+ if (values.length === 0) {
11
+ return 0
12
+ }
13
+ const sorted = [...values].sort((left, right) => left - right)
14
+ const middle = Math.floor(sorted.length / 2)
15
+ return sorted.length % 2 === 1
16
+ ? (sorted[middle] ?? 0)
17
+ : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
18
+ }
19
+
20
+ type Direction = "lower" | "higher" | "neutral"
21
+
22
+ interface Metric {
23
+ readonly label: string
24
+ readonly direction: Direction
25
+ readonly value: (record: BenchRecord) => number | undefined
26
+ readonly format: (value: number) => string
27
+ readonly gap?: (value: number) => string
28
+ }
29
+
30
+ const formatDuration = (milliseconds: number): string => {
31
+ if (milliseconds >= 3_600_000) {
32
+ return `${(milliseconds / 3_600_000).toFixed(1)}h`
33
+ }
34
+ if (milliseconds >= 60_000) {
35
+ return `${(milliseconds / 60_000).toFixed(1)}m`
36
+ }
37
+ return `${(milliseconds / 1_000).toFixed(1)}s`
38
+ }
39
+
40
+ const formatTokens = (tokens: number): string => {
41
+ if (Math.abs(tokens) >= 1_000_000) {
42
+ return `${(tokens / 1_000_000).toFixed(1)}M`
43
+ }
44
+ if (Math.abs(tokens) >= 1_000) {
45
+ return `${(tokens / 1_000).toFixed(1)}k`
46
+ }
47
+ return Math.round(tokens).toString()
48
+ }
49
+
50
+ const formatInteger = (value: number): string => Math.round(value).toString()
51
+ const formatPercent = (value: number): string => `${value.toFixed(1)}%`
52
+ const formatBoolean = (value: number): string => (value >= 0.5 ? "yes" : "no")
53
+
54
+ const spread = (records: ReadonlyArray<BenchRecord>, metric: Metric): string => {
55
+ const values = records.flatMap((record) => {
56
+ const value = metric.value(record)
57
+ return value === undefined ? [] : [value]
58
+ })
59
+ if (values.length === 0) {
60
+ return "–"
61
+ }
62
+ const center = median(values)
63
+ const base = metric.format(center)
64
+ return values.length > 1 && Math.min(...values) !== Math.max(...values)
65
+ ? `${base} (${metric.format(Math.min(...values))}–${metric.format(Math.max(...values))})`
66
+ : base
67
+ }
68
+
69
+ const judgePercent = (record: BenchRecord): number | undefined => {
70
+ const scores = record.quality?.judgeScores ?? []
71
+ const maximum = scores.reduce((sum, score) => sum + score.max, 0)
72
+ return maximum <= 0
73
+ ? undefined
74
+ : (scores.reduce((sum, score) => sum + score.score, 0) / maximum) * 100
75
+ }
76
+
77
+ const totalOutputTokens = (record: BenchRecord): number =>
78
+ record.phases.reduce((sum, phase) => sum + phase.tokens.completion, 0)
79
+
80
+ const totalCachedTokens = (record: BenchRecord): number =>
81
+ record.phases.reduce((sum, phase) => sum + phase.tokens.cached, 0)
82
+
83
+ const headlineMetrics = (judgesComparable: boolean): ReadonlyArray<Metric> => [
84
+ {
85
+ label: "Total time",
86
+ direction: "lower",
87
+ value: (record) => record.totalMs,
88
+ format: formatDuration
89
+ },
90
+ {
91
+ label: "Total tokens",
92
+ direction: "lower",
93
+ value: (record) => record.totalTokens,
94
+ format: formatTokens
95
+ },
96
+ {
97
+ label: "Output tokens",
98
+ direction: "lower",
99
+ value: totalOutputTokens,
100
+ format: formatTokens
101
+ },
102
+ {
103
+ label: "Cached reads",
104
+ direction: "neutral",
105
+ value: totalCachedTokens,
106
+ format: formatTokens
107
+ },
108
+ {
109
+ label: "Self-healing actions",
110
+ direction: "lower",
111
+ value: (record) => record.selfHealing,
112
+ format: formatInteger
113
+ },
114
+ {
115
+ label: "Gate cleared",
116
+ direction: "higher",
117
+ value: (record) =>
118
+ record.quality?.gateCleared === undefined ? undefined : record.quality.gateCleared ? 1 : 0,
119
+ format: formatBoolean,
120
+ gap: formatInteger
121
+ },
122
+ {
123
+ label: "Build passed",
124
+ direction: "higher",
125
+ value: (record) =>
126
+ record.quality?.buildPassed === undefined ? undefined : record.quality.buildPassed ? 1 : 0,
127
+ format: formatBoolean,
128
+ gap: formatInteger
129
+ },
130
+ {
131
+ label: "Coverage %",
132
+ direction: "higher",
133
+ value: (record) => record.quality?.coveragePct,
134
+ format: formatPercent
135
+ },
136
+ {
137
+ label: "Judge score",
138
+ direction: judgesComparable ? "higher" : "neutral",
139
+ value: judgePercent,
140
+ format: formatPercent
141
+ }
142
+ ]
143
+
144
+ const gapCell = (metric: Metric, values: ReadonlyArray<number>): string => {
145
+ if (values.length < 2) {
146
+ return ""
147
+ }
148
+ const low = Math.min(...values)
149
+ const high = Math.max(...values)
150
+ const difference = high - low
151
+ const percent = low === 0 ? 100 : (difference / Math.abs(low)) * 100
152
+ return `+${(metric.gap ?? metric.format)(difference)} (+${percent.toFixed(1)}%)`
153
+ }
154
+
155
+ const metricRow = (
156
+ metric: Metric,
157
+ providers: ReadonlyArray<string>,
158
+ byProvider: ReadonlyMap<string, ReadonlyArray<BenchRecord>>
159
+ ): string => {
160
+ const centers = providers.map((provider) => {
161
+ const values = (byProvider.get(provider) ?? []).flatMap((record) => {
162
+ const value = metric.value(record)
163
+ return value === undefined ? [] : [value]
164
+ })
165
+ return values.length === 0 ? undefined : median(values)
166
+ })
167
+ const comparable = centers.filter((value): value is number => value !== undefined)
168
+ const best =
169
+ metric.direction === "neutral" || comparable.length < 2
170
+ ? undefined
171
+ : metric.direction === "lower"
172
+ ? Math.min(...comparable)
173
+ : Math.max(...comparable)
174
+ const worst =
175
+ metric.direction === "neutral" || comparable.length < 2
176
+ ? undefined
177
+ : metric.direction === "lower"
178
+ ? Math.max(...comparable)
179
+ : Math.min(...comparable)
180
+ const cells = providers.map((provider, index) => {
181
+ const value = centers[index]
182
+ const display = spread(byProvider.get(provider) ?? [], metric)
183
+ if (value === undefined || best === undefined || best === worst) {
184
+ return display
185
+ }
186
+ return value === best ? `**${display} ✅**` : value === worst ? `${display} ⚠️` : display
187
+ })
188
+ return `| ${metric.label} | ${cells.join(" | ")} | ${gapCell(metric, comparable)} |`
189
+ }
190
+
191
+ const costForPhase = (
192
+ record: BenchRecord,
193
+ phase: BenchPhase
194
+ ): readonly [cost: number, estimatedAtReportTime: boolean] | undefined => {
195
+ if (phase.costUsd !== undefined) {
196
+ return [phase.costUsd, false]
197
+ }
198
+ if (phase.tokens.prompt === 0 && phase.tokens.completion === 0 && phase.tokens.cached === 0) {
199
+ return undefined
200
+ }
201
+ const cost = estimateCostUsd(
202
+ record.modelRequested,
203
+ TokenUsage.make({
204
+ prompt: phase.tokens.prompt,
205
+ completion: phase.tokens.completion,
206
+ total: phase.tokens.total,
207
+ ...(phase.tokens.cached <= 0 ? {} : { cached: phase.tokens.cached })
208
+ })
209
+ )
210
+ return cost === undefined ? undefined : [cost, true]
211
+ }
212
+
213
+ const costForRecord = (
214
+ record: BenchRecord
215
+ ): readonly [cost: number, estimatedAtReportTime: boolean] | undefined => {
216
+ const values = record.phases.flatMap((phase) => {
217
+ const cost = costForPhase(record, phase)
218
+ return cost === undefined ? [] : [cost]
219
+ })
220
+ return values.length === 0
221
+ ? undefined
222
+ : [values.reduce((sum, [cost]) => sum + cost, 0), values.some(([, estimated]) => estimated)]
223
+ }
224
+
225
+ const costRow = (
226
+ providers: ReadonlyArray<string>,
227
+ byProvider: ReadonlyMap<string, ReadonlyArray<BenchRecord>>,
228
+ select: (record: BenchRecord) => readonly [number, boolean] | undefined
229
+ ): string => {
230
+ const cells = providers.map((provider) => {
231
+ const values = (byProvider.get(provider) ?? []).flatMap((record) => {
232
+ const value = select(record)
233
+ return value === undefined ? [] : [value]
234
+ })
235
+ if (values.length === 0) {
236
+ return "–"
237
+ }
238
+ const suffix = values.some(([, estimated]) => estimated) ? "*" : ""
239
+ const costs = values.map(([cost]) => cost)
240
+ return `$${median(costs).toFixed(2)}${suffix}`
241
+ })
242
+ return `| Est. cost (USD) | ${cells.join(" | ")} | |`
243
+ }
244
+
245
+ const projection = (
246
+ programs: number,
247
+ providers: ReadonlyArray<string>,
248
+ byProvider: ReadonlyMap<string, ReadonlyArray<BenchRecord>>
249
+ ): string => {
250
+ const rows = providers.map((provider) => {
251
+ const records = byProvider.get(provider) ?? []
252
+ const perProgramTokens = records.flatMap((record) => {
253
+ const count = record.quality?.programs ?? 0
254
+ return count <= 0 ? [] : [record.totalTokens / count]
255
+ })
256
+ const perProgramMs = records.flatMap((record) => {
257
+ const count = record.quality?.programs ?? 0
258
+ return count <= 0 ? [] : [record.totalMs / count]
259
+ })
260
+ if (perProgramTokens.length === 0 || perProgramMs.length === 0) {
261
+ return `| ${provider} | – | – | – |`
262
+ }
263
+ const tokens = median(perProgramTokens)
264
+ const milliseconds = median(perProgramMs)
265
+ return `| ${provider} | ${formatTokens(tokens)} | ${formatTokens(
266
+ tokens * programs
267
+ )} | ${formatDuration(milliseconds * programs)} |`
268
+ })
269
+ return [
270
+ `## Projection @ ${programs} programs`,
271
+ "",
272
+ "| Provider | Tokens/program | Projected tokens | Projected time |",
273
+ "| -------- | -------------- | ---------------- | -------------- |",
274
+ ...rows,
275
+ "",
276
+ "_Linear extrapolation from recorded medians; not a delivery forecast._"
277
+ ].join("\n")
278
+ }
279
+
280
+ const renderSection = (
281
+ records: ReadonlyArray<BenchRecord>,
282
+ projectPrograms: number | undefined
283
+ ): string => {
284
+ const providers = [...new Set(records.map((record) => record.provider))].sort()
285
+ const byProvider = new Map<string, ReadonlyArray<BenchRecord>>(
286
+ providers.map((provider) => [
287
+ provider,
288
+ records.filter((record) => record.provider === provider)
289
+ ])
290
+ )
291
+ const first = records[0]
292
+ if (first === undefined) {
293
+ return ""
294
+ }
295
+ const judgesComparable =
296
+ new Set(records.map((record) => `${record.judge.provider}\0${record.judge.model}`)).size === 1
297
+ const judgeLine = judgesComparable
298
+ ? `Judge: ${first.judge.provider}/${first.judge.model} — same examiner for all runs`
299
+ : "Judges: self-graded — scores NOT cross-comparable"
300
+ const header = `| Metric | ${providers.join(" | ")} | Gap |`
301
+ const separator = `| ------ | ${providers.map(() => "------").join(" | ")} | --- |`
302
+ const outcomes = `| Outcome | ${providers
303
+ .map((provider) => {
304
+ const group = byProvider.get(provider) ?? []
305
+ return group.some((record) => record.resumed === true)
306
+ ? `${group[0]?.outcome ?? "unknown"} ♻`
307
+ : (group[0]?.outcome ?? "unknown")
308
+ })
309
+ .join(" | ")} | |`
310
+ const phaseNames = [
311
+ ...new Set(records.flatMap((record) => record.phases.map((phase) => phase.name)))
312
+ ]
313
+ const phaseSections = phaseNames.map((phaseName) => {
314
+ const duration: Metric = {
315
+ label: "Duration",
316
+ direction: "lower",
317
+ value: (record) => record.phases.find((phase) => phase.name === phaseName)?.ms,
318
+ format: formatDuration
319
+ }
320
+ const tokens: Metric = {
321
+ label: "Tokens",
322
+ direction: "lower",
323
+ value: (record) => {
324
+ const phase = record.phases.find((item) => item.name === phaseName)
325
+ return phase === undefined ? undefined : phase.tokens.total + phase.tokens.cached
326
+ },
327
+ format: formatTokens
328
+ }
329
+ return [
330
+ `### ${phaseName}`,
331
+ "",
332
+ header,
333
+ separator,
334
+ metricRow(duration, providers, byProvider),
335
+ metricRow(tokens, providers, byProvider),
336
+ costRow(providers, byProvider, (record) => {
337
+ const phase = record.phases.find((item) => item.name === phaseName)
338
+ return phase === undefined ? undefined : costForPhase(record, phase)
339
+ })
340
+ ].join("\n")
341
+ })
342
+ const failures = records.filter((record) => !record.completed)
343
+ const estimated = records.some((record) => costForRecord(record)?.[1] === true)
344
+ const extras = [
345
+ ...(failures.length === 0
346
+ ? []
347
+ : [
348
+ "## Failures",
349
+ ...failures.map((record) => `- ${record.provider} ${record.runId}: ${record.outcome}`)
350
+ ]),
351
+ ...(projectPrograms === undefined ? [] : [projection(projectPrograms, providers, byProvider)]),
352
+ ...(records.some((record) => record.resumed === true)
353
+ ? [
354
+ "_♻ resumed run: duration and tokens include only work after resume and are not fresh-run comparable._"
355
+ ]
356
+ : []),
357
+ ...(estimated
358
+ ? ["_\\* Cost estimated at report time from recorded tokens × the requested model._"]
359
+ : []),
360
+ `_Machines: ${[...new Set(records.map((record) => record.machine.hostname))].sort().join(", ")}._`
361
+ ]
362
+ return [
363
+ `# Benchmark — ${first.pack} @ ${first.fixtureFingerprint.slice(0, 8)}`,
364
+ "",
365
+ `_${judgeLine}_`,
366
+ "",
367
+ "## Headline",
368
+ "",
369
+ header,
370
+ separator,
371
+ outcomes,
372
+ ...headlineMetrics(judgesComparable).map((metric) => metricRow(metric, providers, byProvider)),
373
+ costRow(providers, byProvider, costForRecord),
374
+ "",
375
+ "## Phases",
376
+ "",
377
+ ...phaseSections,
378
+ ...extras
379
+ ].join("\n")
380
+ }
381
+
382
+ export const renderBenchReport = (
383
+ records: ReadonlyArray<BenchRecord>,
384
+ projectPrograms?: number
385
+ ): string => {
386
+ const groups = new Map<string, Array<BenchRecord>>()
387
+ for (const record of records) {
388
+ const key = `${record.pack}\0${record.fixtureFingerprint}`
389
+ groups.set(key, [...(groups.get(key) ?? []), record])
390
+ }
391
+ return [...groups.entries()]
392
+ .sort(([left], [right]) => left.localeCompare(right))
393
+ .map(([, group]) => renderSection(group, projectPrograms))
394
+ .join("\n\n")
395
+ }
396
+
397
+ const benchCodec = Schema.fromJsonString(BenchRecord)
398
+
399
+ export const appendBenchRecord = Effect.fn("@llm4ts/flow/BenchReport.append")(function* (
400
+ files: PlainFileStoreShape,
401
+ path: string,
402
+ record: BenchRecord
403
+ ): Effect.fn.Return<void, FlowError> {
404
+ const encoded = yield* Schema.encodeEffect(benchCodec)(record).pipe(
405
+ Effect.mapError((error) =>
406
+ PlanParseError.make({
407
+ message: `failed to encode benchmark record: ${String(error)}`
408
+ })
409
+ )
410
+ )
411
+ yield* files.append(path, `${encoded}\n`)
412
+ })
413
+
414
+ export const loadBenchRecords = Effect.fn("@llm4ts/flow/BenchReport.load")(function* (
415
+ files: PlainFileStoreShape,
416
+ path: string
417
+ ): Effect.fn.Return<ReadonlyArray<BenchRecord>, FlowError> {
418
+ const contents = yield* files.read(path)
419
+ if (contents === undefined) {
420
+ return []
421
+ }
422
+ const records = yield* Effect.forEach(
423
+ contents
424
+ .split(/\r?\n/u)
425
+ .map((line) => line.trim())
426
+ .filter((line) => line.length > 0),
427
+ (line, index) =>
428
+ Schema.decodeUnknownEffect(benchCodec)(line).pipe(
429
+ Effect.mapError((error) =>
430
+ PlanParseError.make({
431
+ message: `malformed benchmark record at ${path}:${index + 1}: ${String(error)}`
432
+ })
433
+ )
434
+ ),
435
+ { concurrency: 1 }
436
+ )
437
+ for (const record of records) {
438
+ if (record.schemaVersion !== CurrentBenchSchema) {
439
+ return yield* UnsupportedSchemaVersion.make({
440
+ path,
441
+ expected: CurrentBenchSchema,
442
+ actual: record.schemaVersion
443
+ })
444
+ }
445
+ }
446
+ return records
447
+ })
@@ -0,0 +1,43 @@
1
+ import * as Effect from "effect/Effect"
2
+ import { allows, currentGrants, type Capability } from "@llm4ts/core/Capability"
3
+ import { FlowCapabilityDenied } from "./FlowError.ts"
4
+ import { CapabilityDeniedEvent, CapabilityUsedEvent, type FlowEventsShape } from "./FlowEvents.ts"
5
+
6
+ const capabilityName = (capability: Capability): string =>
7
+ capability._tag === "Exec" ? `Exec(${capability.command})` : capability._tag
8
+
9
+ export const guarded = <A, E, R>(
10
+ capability: Capability,
11
+ operation: string,
12
+ events: FlowEventsShape,
13
+ effect: Effect.Effect<A, E, R>
14
+ ): Effect.Effect<A, E | FlowCapabilityDenied, R> =>
15
+ Effect.flatMap(currentGrants, (grants): Effect.Effect<A, E | FlowCapabilityDenied, R> => {
16
+ if (!allows(grants, capability)) {
17
+ return events
18
+ .publish(
19
+ CapabilityDeniedEvent.make({
20
+ capability: capabilityName(capability),
21
+ operation
22
+ })
23
+ )
24
+ .pipe(
25
+ Effect.andThen(
26
+ Effect.fail(
27
+ FlowCapabilityDenied.make({
28
+ capability,
29
+ operation
30
+ })
31
+ )
32
+ )
33
+ )
34
+ }
35
+ return events
36
+ .publish(
37
+ CapabilityUsedEvent.make({
38
+ capability: capabilityName(capability),
39
+ operation
40
+ })
41
+ )
42
+ .pipe(Effect.andThen(effect))
43
+ })
package/src/Chat.ts ADDED
@@ -0,0 +1,60 @@
1
+ import * as Effect from "effect/Effect"
2
+ import * as Ref from "effect/Ref"
3
+ import * as Semaphore from "effect/Semaphore"
4
+ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
5
+ import { Message } from "@llm4ts/core/Models"
6
+ import { collect } from "@llm4ts/core/Streaming"
7
+ import { FlowLlmError } from "./FlowError.ts"
8
+
9
+ export const gitOwnershipInstruction =
10
+ "The flow runtime owns git operations. Do not create or switch branches, commit, push, or open pull requests."
11
+
12
+ export interface ChatOptions {
13
+ readonly system?: string
14
+ readonly manageGit?: boolean
15
+ }
16
+
17
+ export interface Chat {
18
+ readonly ask: (prompt: string) => Effect.Effect<string, FlowLlmError>
19
+ readonly messages: Effect.Effect<ReadonlyArray<Message>>
20
+ }
21
+
22
+ const initialHistory = (options: ChatOptions): ReadonlyArray<Message> => {
23
+ const system =
24
+ options.manageGit === true
25
+ ? options.system
26
+ : [gitOwnershipInstruction, options.system]
27
+ .filter((part) => part !== undefined && part.length > 0)
28
+ .join("\n\n")
29
+ return system === undefined || system.length === 0
30
+ ? []
31
+ : [Message.make({ role: "System", content: system })]
32
+ }
33
+
34
+ export const makeChat = Effect.fn("@llm4ts/flow/Chat.make")(function* (
35
+ service: LlmServiceShape,
36
+ options: ChatOptions = {}
37
+ ): Effect.fn.Return<Chat> {
38
+ const history = yield* Ref.make(initialHistory(options))
39
+ const gate = yield* Semaphore.make(1)
40
+
41
+ const askRound = Effect.fn("@llm4ts/flow/Chat.ask")(function* (
42
+ prompt: string
43
+ ): Effect.fn.Return<string, FlowLlmError> {
44
+ const userTurn = Message.make({ role: "User", content: prompt })
45
+ const messages = [...(yield* Ref.get(history)), userTurn]
46
+ const response = yield* collect(service.executeStreamWithHistory(messages)).pipe(
47
+ Effect.mapError(FlowLlmError.from)
48
+ )
49
+ yield* Ref.set(history, [
50
+ ...messages,
51
+ Message.make({ role: "Assistant", content: response.content })
52
+ ])
53
+ return response.content
54
+ })
55
+
56
+ return {
57
+ ask: (prompt) => gate.withPermit(askRound(prompt)),
58
+ messages: Ref.get(history)
59
+ }
60
+ })
@@ -0,0 +1,67 @@
1
+ import * as Effect from "effect/Effect"
2
+ import { Capabilities, type Capability, allows, currentGrants } from "@llm4ts/core/Capability"
3
+ import { FlowCapabilityDenied } from "./FlowError.ts"
4
+ import { FlowEvents, FlowEventsValues, type FlowEventsShape } from "./FlowEvents.ts"
5
+
6
+ const capabilityName = (capability: Capability): string =>
7
+ capability._tag === "Exec" ? `Exec(${capability.command})` : capability._tag
8
+
9
+ const shouldAuditUse = (capability: Capability): boolean => {
10
+ switch (capability._tag) {
11
+ case "GitPush":
12
+ case "GhWrite":
13
+ case "AdoWrite":
14
+ case "Exec":
15
+ return true
16
+ default:
17
+ return false
18
+ }
19
+ }
20
+
21
+ export const guarded = <A, E, R>(
22
+ capability: Capability,
23
+ operation: string,
24
+ body: Effect.Effect<A, E, R>
25
+ ): Effect.Effect<A, E | FlowCapabilityDenied, R | FlowEvents> =>
26
+ Effect.gen(function* () {
27
+ const events: FlowEventsShape = yield* FlowEvents
28
+ const grants = yield* currentGrants
29
+ const name = capabilityName(capability)
30
+
31
+ if (!allows(grants, capability)) {
32
+ yield* events.publish(FlowEventsValues.CapabilityDenied(name, operation))
33
+ return yield* new FlowCapabilityDenied({ capability, operation })
34
+ }
35
+
36
+ if (shouldAuditUse(capability)) {
37
+ yield* events.publish(FlowEventsValues.CapabilityUsed(name, operation))
38
+ }
39
+
40
+ return yield* body
41
+ })
42
+
43
+ export class Classified<A> {
44
+ private constructor(private readonly value: A) {}
45
+
46
+ static of<A>(value: A): Classified<A> {
47
+ return new Classified(value)
48
+ }
49
+
50
+ map<B>(transform: (value: A) => B): Classified<B> {
51
+ return new Classified(transform(this.value))
52
+ }
53
+
54
+ declassify(label: string): Effect.Effect<A, FlowCapabilityDenied, FlowEvents> {
55
+ return guarded(
56
+ Capabilities.Declassify,
57
+ `declassify ${label}`,
58
+ Effect.flatMap(FlowEvents, (events) =>
59
+ Effect.as(events.publish(FlowEventsValues.Declassified(label)), this.value)
60
+ )
61
+ )
62
+ }
63
+
64
+ toString(): string {
65
+ return "Classified(…)"
66
+ }
67
+ }