@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.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/dist/AzureDevOpsTool.d.ts +60 -0
- package/dist/AzureDevOpsTool.d.ts.map +1 -0
- package/dist/AzureDevOpsTool.js +163 -0
- package/dist/AzureDevOpsTool.js.map +1 -0
- package/dist/Bench.d.ts +133 -0
- package/dist/Bench.d.ts.map +1 -0
- package/dist/Bench.js +272 -0
- package/dist/Bench.js.map +1 -0
- package/dist/BenchReport.d.ts +9 -0
- package/dist/BenchReport.d.ts.map +1 -0
- package/dist/BenchReport.js +360 -0
- package/dist/BenchReport.js.map +1 -0
- package/dist/CapabilityGuard.d.ts +6 -0
- package/dist/CapabilityGuard.d.ts.map +1 -0
- package/dist/CapabilityGuard.js +25 -0
- package/dist/CapabilityGuard.js.map +1 -0
- package/dist/Chat.d.ts +15 -0
- package/dist/Chat.d.ts.map +1 -0
- package/dist/Chat.js +36 -0
- package/dist/Chat.js.map +1 -0
- package/dist/Classified.d.ts +14 -0
- package/dist/Classified.d.ts.map +1 -0
- package/dist/Classified.js +48 -0
- package/dist/Classified.js.map +1 -0
- package/dist/CostLedger.d.ts +55 -0
- package/dist/CostLedger.d.ts.map +1 -0
- package/dist/CostLedger.js +126 -0
- package/dist/CostLedger.js.map +1 -0
- package/dist/CostTracker.d.ts +13 -0
- package/dist/CostTracker.d.ts.map +1 -0
- package/dist/CostTracker.js +116 -0
- package/dist/CostTracker.js.map +1 -0
- package/dist/Equiv.d.ts +163 -0
- package/dist/Equiv.d.ts.map +1 -0
- package/dist/Equiv.js +256 -0
- package/dist/Equiv.js.map +1 -0
- package/dist/EquivReport.d.ts +16 -0
- package/dist/EquivReport.d.ts.map +1 -0
- package/dist/EquivReport.js +77 -0
- package/dist/EquivReport.js.map +1 -0
- package/dist/Flow.d.ts +25 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +39 -0
- package/dist/Flow.js.map +1 -0
- package/dist/FlowContext.d.ts +23 -0
- package/dist/FlowContext.d.ts.map +1 -0
- package/dist/FlowContext.js +4 -0
- package/dist/FlowContext.js.map +1 -0
- package/dist/FlowError.d.ts +80 -0
- package/dist/FlowError.d.ts.map +1 -0
- package/dist/FlowError.js +92 -0
- package/dist/FlowError.js.map +1 -0
- package/dist/FlowEvents.d.ts +101 -0
- package/dist/FlowEvents.d.ts.map +1 -0
- package/dist/FlowEvents.js +103 -0
- package/dist/FlowEvents.js.map +1 -0
- package/dist/FlowRecorder.d.ts +26 -0
- package/dist/FlowRecorder.d.ts.map +1 -0
- package/dist/FlowRecorder.js +74 -0
- package/dist/FlowRecorder.js.map +1 -0
- package/dist/GitHubTool.d.ts +53 -0
- package/dist/GitHubTool.d.ts.map +1 -0
- package/dist/GitHubTool.js +191 -0
- package/dist/GitHubTool.js.map +1 -0
- package/dist/GitTool.d.ts +46 -0
- package/dist/GitTool.d.ts.map +1 -0
- package/dist/GitTool.js +97 -0
- package/dist/GitTool.js.map +1 -0
- package/dist/Interaction.d.ts +21 -0
- package/dist/Interaction.d.ts.map +1 -0
- package/dist/Interaction.js +9 -0
- package/dist/Interaction.js.map +1 -0
- package/dist/McpServer.d.ts +18 -0
- package/dist/McpServer.d.ts.map +1 -0
- package/dist/McpServer.js +122 -0
- package/dist/McpServer.js.map +1 -0
- package/dist/Mermaid.d.ts +7 -0
- package/dist/Mermaid.d.ts.map +1 -0
- package/dist/Mermaid.js +98 -0
- package/dist/Mermaid.js.map +1 -0
- package/dist/Pack.d.ts +40 -0
- package/dist/Pack.d.ts.map +1 -0
- package/dist/Pack.js +141 -0
- package/dist/Pack.js.map +1 -0
- package/dist/Package.d.ts +2 -0
- package/dist/Package.d.ts.map +1 -0
- package/dist/Package.js +2 -0
- package/dist/Package.js.map +1 -0
- package/dist/Persistence.d.ts +32 -0
- package/dist/Persistence.d.ts.map +1 -0
- package/dist/Persistence.js +85 -0
- package/dist/Persistence.js.map +1 -0
- package/dist/Plan.d.ts +26 -0
- package/dist/Plan.d.ts.map +1 -0
- package/dist/Plan.js +101 -0
- package/dist/Plan.js.map +1 -0
- package/dist/PlanExecution.d.ts +7 -0
- package/dist/PlanExecution.d.ts.map +1 -0
- package/dist/PlanExecution.js +19 -0
- package/dist/PlanExecution.js.map +1 -0
- package/dist/Planner.d.ts +32 -0
- package/dist/Planner.d.ts.map +1 -0
- package/dist/Planner.js +97 -0
- package/dist/Planner.js.map +1 -0
- package/dist/PrSummary.d.ts +15 -0
- package/dist/PrSummary.d.ts.map +1 -0
- package/dist/PrSummary.js +29 -0
- package/dist/PrSummary.js.map +1 -0
- package/dist/PriceList.d.ts +11 -0
- package/dist/PriceList.d.ts.map +1 -0
- package/dist/PriceList.js +79 -0
- package/dist/PriceList.js.map +1 -0
- package/dist/Provenance.d.ts +27 -0
- package/dist/Provenance.d.ts.map +1 -0
- package/dist/Provenance.js +38 -0
- package/dist/Provenance.js.map +1 -0
- package/dist/Replay.d.ts +29 -0
- package/dist/Replay.d.ts.map +1 -0
- package/dist/Replay.js +181 -0
- package/dist/Replay.js.map +1 -0
- package/dist/Review.d.ts +71 -0
- package/dist/Review.d.ts.map +1 -0
- package/dist/Review.js +204 -0
- package/dist/Review.js.map +1 -0
- package/dist/ReviewCache.d.ts +6 -0
- package/dist/ReviewCache.d.ts.map +1 -0
- package/dist/ReviewCache.js +28 -0
- package/dist/ReviewCache.js.map +1 -0
- package/dist/Reviewer.d.ts +12 -0
- package/dist/Reviewer.d.ts.map +1 -0
- package/dist/Reviewer.js +30 -0
- package/dist/Reviewer.js.map +1 -0
- package/dist/SpecChecks.d.ts +17 -0
- package/dist/SpecChecks.d.ts.map +1 -0
- package/dist/SpecChecks.js +107 -0
- package/dist/SpecChecks.js.map +1 -0
- package/dist/Survey.d.ts +32 -0
- package/dist/Survey.d.ts.map +1 -0
- package/dist/Survey.js +119 -0
- package/dist/Survey.js.map +1 -0
- package/dist/TransientRetry.d.ts +16 -0
- package/dist/TransientRetry.d.ts.map +1 -0
- package/dist/TransientRetry.js +102 -0
- package/dist/TransientRetry.js.map +1 -0
- package/dist/Workspace.d.ts +39 -0
- package/dist/Workspace.d.ts.map +1 -0
- package/dist/Workspace.js +192 -0
- package/dist/Workspace.js.map +1 -0
- package/dist/WorkspaceLayout.d.ts +3 -0
- package/dist/WorkspaceLayout.d.ts.map +1 -0
- package/dist/WorkspaceLayout.js +19 -0
- package/dist/WorkspaceLayout.js.map +1 -0
- package/dist/WorkspaceTools.d.ts +4 -0
- package/dist/WorkspaceTools.d.ts.map +1 -0
- package/dist/WorkspaceTools.js +130 -0
- package/dist/WorkspaceTools.js.map +1 -0
- package/package.json +91 -0
- package/src/AzureDevOpsTool.ts +291 -0
- package/src/Bench.ts +340 -0
- package/src/BenchReport.ts +447 -0
- package/src/CapabilityGuard.ts +43 -0
- package/src/Chat.ts +60 -0
- package/src/Classified.ts +67 -0
- package/src/CostLedger.ts +171 -0
- package/src/CostTracker.ts +170 -0
- package/src/Equiv.ts +378 -0
- package/src/EquivReport.ts +92 -0
- package/src/Flow.ts +72 -0
- package/src/FlowContext.ts +23 -0
- package/src/FlowError.ts +112 -0
- package/src/FlowEvents.ts +147 -0
- package/src/FlowRecorder.ts +116 -0
- package/src/GitHubTool.ts +315 -0
- package/src/GitTool.ts +214 -0
- package/src/Interaction.ts +23 -0
- package/src/McpServer.ts +168 -0
- package/src/Mermaid.ts +112 -0
- package/src/Pack.ts +213 -0
- package/src/Package.ts +1 -0
- package/src/Persistence.ts +198 -0
- package/src/Plan.ts +119 -0
- package/src/PlanExecution.ts +46 -0
- package/src/Planner.ts +134 -0
- package/src/PrSummary.ts +37 -0
- package/src/PriceList.ts +91 -0
- package/src/Provenance.ts +85 -0
- package/src/Replay.ts +265 -0
- package/src/Review.ts +337 -0
- package/src/ReviewCache.ts +45 -0
- package/src/Reviewer.ts +31 -0
- package/src/SpecChecks.ts +143 -0
- package/src/Survey.ts +144 -0
- package/src/TransientRetry.ts +238 -0
- package/src/Workspace.ts +255 -0
- package/src/WorkspaceLayout.ts +22 -0
- 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
|
+
}
|