@llm4ts/flow 2.5.0 → 2.7.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 (82) hide show
  1. package/dist/Classified.d.ts +2 -0
  2. package/dist/Classified.d.ts.map +1 -1
  3. package/dist/Classified.js +4 -0
  4. package/dist/Classified.js.map +1 -1
  5. package/dist/CostReport.d.ts +110 -0
  6. package/dist/CostReport.d.ts.map +1 -0
  7. package/dist/CostReport.js +313 -0
  8. package/dist/CostReport.js.map +1 -0
  9. package/dist/EstimatedUsage.d.ts.map +1 -1
  10. package/dist/EstimatedUsage.js +33 -1
  11. package/dist/EstimatedUsage.js.map +1 -1
  12. package/dist/Flow.d.ts +14 -0
  13. package/dist/Flow.d.ts.map +1 -1
  14. package/dist/Flow.js +45 -2
  15. package/dist/Flow.js.map +1 -1
  16. package/dist/FlowContext.d.ts +7 -0
  17. package/dist/FlowContext.d.ts.map +1 -1
  18. package/dist/FlowContext.js.map +1 -1
  19. package/dist/FlowEvents.d.ts +44 -1
  20. package/dist/FlowEvents.d.ts.map +1 -1
  21. package/dist/FlowEvents.js +35 -0
  22. package/dist/FlowEvents.js.map +1 -1
  23. package/dist/GitTool.d.ts +7 -0
  24. package/dist/GitTool.d.ts.map +1 -1
  25. package/dist/GitTool.js +15 -1
  26. package/dist/GitTool.js.map +1 -1
  27. package/dist/Judgment.d.ts +88 -0
  28. package/dist/Judgment.d.ts.map +1 -0
  29. package/dist/Judgment.js +281 -0
  30. package/dist/Judgment.js.map +1 -0
  31. package/dist/JudgmentDataset.d.ts +79 -0
  32. package/dist/JudgmentDataset.d.ts.map +1 -0
  33. package/dist/JudgmentDataset.js +164 -0
  34. package/dist/JudgmentDataset.js.map +1 -0
  35. package/dist/JudgmentEval.d.ts +143 -0
  36. package/dist/JudgmentEval.d.ts.map +1 -0
  37. package/dist/JudgmentEval.js +242 -0
  38. package/dist/JudgmentEval.js.map +1 -0
  39. package/dist/JudgmentLog.d.ts +46 -0
  40. package/dist/JudgmentLog.d.ts.map +1 -0
  41. package/dist/JudgmentLog.js +62 -0
  42. package/dist/JudgmentLog.js.map +1 -0
  43. package/dist/JudgmentTypes.d.ts +13 -0
  44. package/dist/JudgmentTypes.d.ts.map +1 -0
  45. package/dist/JudgmentTypes.js +11 -0
  46. package/dist/JudgmentTypes.js.map +1 -0
  47. package/dist/ProgramJudge.d.ts +15 -2
  48. package/dist/ProgramJudge.d.ts.map +1 -1
  49. package/dist/ProgramJudge.js +58 -3
  50. package/dist/ProgramJudge.js.map +1 -1
  51. package/dist/Replay.d.ts.map +1 -1
  52. package/dist/Replay.js +9 -4
  53. package/dist/Replay.js.map +1 -1
  54. package/dist/Review.d.ts +35 -0
  55. package/dist/Review.d.ts.map +1 -1
  56. package/dist/Review.js +92 -13
  57. package/dist/Review.js.map +1 -1
  58. package/dist/Reviewer.d.ts +8 -0
  59. package/dist/Reviewer.d.ts.map +1 -1
  60. package/dist/Reviewer.js +12 -1
  61. package/dist/Reviewer.js.map +1 -1
  62. package/dist/TransientRetry.d.ts.map +1 -1
  63. package/dist/TransientRetry.js +1 -0
  64. package/dist/TransientRetry.js.map +1 -1
  65. package/package.json +8 -3
  66. package/src/Classified.ts +5 -0
  67. package/src/CostReport.ts +402 -0
  68. package/src/EstimatedUsage.ts +81 -10
  69. package/src/Flow.ts +77 -2
  70. package/src/FlowContext.ts +7 -0
  71. package/src/FlowEvents.ts +42 -0
  72. package/src/GitTool.ts +19 -1
  73. package/src/Judgment.ts +418 -0
  74. package/src/JudgmentDataset.ts +254 -0
  75. package/src/JudgmentEval.ts +306 -0
  76. package/src/JudgmentLog.ts +96 -0
  77. package/src/JudgmentTypes.ts +15 -0
  78. package/src/ProgramJudge.ts +89 -4
  79. package/src/Replay.ts +23 -17
  80. package/src/Review.ts +163 -13
  81. package/src/Reviewer.ts +15 -1
  82. package/src/TransientRetry.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/flow",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "Effect-native LLM workflow, persistence, review, and repository automation",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  "./Classified": "./dist/Classified.js",
28
28
  "./Context": "./dist/Context.js",
29
29
  "./CostLedger": "./dist/CostLedger.js",
30
+ "./CostReport": "./dist/CostReport.js",
30
31
  "./CostTracker": "./dist/CostTracker.js",
31
32
  "./Decisions": "./dist/Decisions.js",
32
33
  "./Domains": "./dist/Domains.js",
@@ -47,6 +48,8 @@
47
48
  "./Package": "./dist/Package.js",
48
49
  "./PageSpec": "./dist/PageSpec.js",
49
50
  "./Patterns": "./dist/Patterns.js",
51
+ "./Judgment": "./dist/Judgment.js",
52
+ "./JudgmentLog": "./dist/JudgmentLog.js",
50
53
  "./Perimeter": "./dist/Perimeter.js",
51
54
  "./Persistence": "./dist/Persistence.js",
52
55
  "./Plan": "./dist/Plan.js",
@@ -69,7 +72,9 @@
69
72
  "./Wall": "./dist/Wall.js",
70
73
  "./Workspace": "./dist/Workspace.js",
71
74
  "./WorkspaceLayout": "./dist/WorkspaceLayout.js",
72
- "./WorkspaceTools": "./dist/WorkspaceTools.js"
75
+ "./WorkspaceTools": "./dist/WorkspaceTools.js",
76
+ "./JudgmentDataset": "./dist/JudgmentDataset.js",
77
+ "./JudgmentEval": "./dist/JudgmentEval.js"
73
78
  },
74
79
  "publishConfig": {
75
80
  "access": "public",
@@ -93,7 +98,7 @@
93
98
  "typescript"
94
99
  ],
95
100
  "dependencies": {
96
- "@llm4ts/core": "2.5.0"
101
+ "@llm4ts/core": "2.7.0"
97
102
  },
98
103
  "peerDependencies": {
99
104
  "effect": "4.0.0-rc.115"
package/src/Classified.ts CHANGED
@@ -61,6 +61,11 @@ export class Classified<A> {
61
61
  )
62
62
  }
63
63
 
64
+ /** JSON boundaries must use the same sealed representation as text boundaries. */
65
+ toJSON(): string {
66
+ return this.toString()
67
+ }
68
+
64
69
  toString(): string {
65
70
  return "Classified(…)"
66
71
  }
@@ -0,0 +1,402 @@
1
+ import * as DateTime from "effect/DateTime"
2
+ import * as Option from "effect/Option"
3
+ import * as Schema from "effect/Schema"
4
+ import type { TokenUsage } from "@llm4ts/core/Models"
5
+ import { isEstimatedModel } from "./EstimatedUsage.ts"
6
+ import { TokensUsed } from "./FlowEvents.ts"
7
+ import type { TraceLine } from "./FlowRecorder.ts"
8
+ import { estimateCostUsd, PricesAsOf } from "./PriceList.ts"
9
+
10
+ /**
11
+ * Cross-run usage report for budgeting.
12
+ *
13
+ * A run's cost summary (`CostTracker`) answers "what did this run cost"; this
14
+ * module answers "what do my runs cost per day, per hour, and per run" from
15
+ * the `TokensUsed` lines every trace already records. Measured usage (a
16
+ * backend's own token counts) and estimated usage (`EstimatedUsage`'s
17
+ * character-count fallback, labelled `estimated:<model>`) are kept in separate
18
+ * columns so a budget never mistakes one for the other.
19
+ */
20
+
21
+ export const UnknownModel = "(unknown)"
22
+
23
+ export class UsageSample extends Schema.Class<UsageSample>("UsageSample")({
24
+ /** Epoch milliseconds of the token report. */
25
+ at: Schema.Number,
26
+ runId: Schema.String,
27
+ agent: Schema.String,
28
+ model: Schema.String,
29
+ prompt: Schema.Int,
30
+ completion: Schema.Int,
31
+ total: Schema.Int,
32
+ cached: Schema.optionalKey(Schema.Int),
33
+ costUsd: Schema.optionalKey(Schema.Number),
34
+ estimated: Schema.Boolean
35
+ }) {}
36
+
37
+ export class UsageTotals extends Schema.Class<UsageTotals>("UsageTotals")({
38
+ requests: Schema.Int,
39
+ prompt: Schema.Int,
40
+ completion: Schema.Int,
41
+ total: Schema.Int,
42
+ cached: Schema.optionalKey(Schema.Int),
43
+ costUsd: Schema.optionalKey(Schema.Number)
44
+ }) {}
45
+
46
+ export class UsageBucket extends Schema.Class<UsageBucket>("UsageBucket")({
47
+ /** `YYYY-MM-DD` for a day, `YYYY-MM-DD HH:00` for an hour, in the report's zone. */
48
+ key: Schema.String,
49
+ runs: Schema.Int,
50
+ measured: UsageTotals,
51
+ estimated: UsageTotals
52
+ }) {}
53
+
54
+ export class ModelUsage extends Schema.Class<ModelUsage>("ModelUsage")({
55
+ model: Schema.String,
56
+ estimated: Schema.Boolean,
57
+ totals: UsageTotals
58
+ }) {}
59
+
60
+ export class UsageAverages extends Schema.Class<UsageAverages>("UsageAverages")({
61
+ activeDays: Schema.Int,
62
+ calendarDays: Schema.Int,
63
+ activeHours: Schema.Int,
64
+ tokensPerActiveDay: Schema.Int,
65
+ tokensPerCalendarDay: Schema.Int,
66
+ tokensPerActiveHour: Schema.Int,
67
+ tokensPerRun: Schema.Int,
68
+ costUsdPerActiveDay: Schema.optionalKey(Schema.Number),
69
+ costUsdPerCalendarDay: Schema.optionalKey(Schema.Number),
70
+ costUsdPerActiveHour: Schema.optionalKey(Schema.Number),
71
+ costUsdPerRun: Schema.optionalKey(Schema.Number),
72
+ peakDay: Schema.optionalKey(Schema.String),
73
+ peakHour: Schema.optionalKey(Schema.String)
74
+ }) {}
75
+
76
+ export class UsageProjection extends Schema.Class<UsageProjection>("UsageProjection")({
77
+ runsPerDay: Schema.Number,
78
+ tokensPerDay: Schema.Int,
79
+ costUsdPerDay: Schema.optionalKey(Schema.Number)
80
+ }) {}
81
+
82
+ export class CostReport extends Schema.Class<CostReport>("CostReport")({
83
+ schemaVersion: Schema.Int,
84
+ timeZone: Schema.String,
85
+ pricesAsOf: Schema.String,
86
+ from: Schema.optionalKey(Schema.String),
87
+ to: Schema.optionalKey(Schema.String),
88
+ runs: Schema.Int,
89
+ requests: Schema.Int,
90
+ measured: UsageTotals,
91
+ estimated: UsageTotals,
92
+ averages: UsageAverages,
93
+ projection: Schema.optionalKey(UsageProjection),
94
+ byDay: Schema.Array(UsageBucket),
95
+ byHour: Schema.Array(UsageBucket),
96
+ byModel: Schema.Array(ModelUsage)
97
+ }) {}
98
+
99
+ export const CurrentCostReportSchema = 1
100
+
101
+ const tokensUsedCodec = Schema.fromJsonString(TokensUsed)
102
+ const decodeTokensUsed = Schema.decodeUnknownOption(tokensUsedCodec)
103
+
104
+ // Backend-reported cost is authoritative; the pricing table only fills in
105
+ // when the backend said nothing (the same rule as `CostTracker`).
106
+ const costOf = (model: string, usage: TokenUsage): number | undefined =>
107
+ usage.costUsd ?? (model === UnknownModel ? undefined : estimateCostUsd(model, usage))
108
+
109
+ /** The token reports of one trace, in trace order; malformed lines are skipped. */
110
+ export const usageSamplesFromTrace = (
111
+ lines: ReadonlyArray<TraceLine>
112
+ ): ReadonlyArray<UsageSample> =>
113
+ lines.flatMap((line) => {
114
+ if (line.kind !== "TokensUsed") {
115
+ return []
116
+ }
117
+ const raw = line.fields.event
118
+ if (typeof raw !== "string") {
119
+ return []
120
+ }
121
+ const decoded = decodeTokensUsed(raw)
122
+ if (Option.isNone(decoded)) {
123
+ return []
124
+ }
125
+ const event = decoded.value
126
+ const model = event.model ?? UnknownModel
127
+ const costUsd = costOf(model, event.usage)
128
+ return [
129
+ UsageSample.make({
130
+ at: line.timestamp,
131
+ runId: line.runId,
132
+ agent: event.agent,
133
+ model,
134
+ prompt: event.usage.prompt,
135
+ completion: event.usage.completion,
136
+ total: event.usage.total,
137
+ ...(event.usage.cached === undefined ? {} : { cached: event.usage.cached }),
138
+ ...(costUsd === undefined ? {} : { costUsd }),
139
+ estimated: isEstimatedModel(model)
140
+ })
141
+ ]
142
+ })
143
+
144
+ const sumOptional = (values: ReadonlyArray<number | undefined>): number | undefined => {
145
+ const present = values.flatMap((value) => (value === undefined ? [] : [value]))
146
+ return present.length === 0 ? undefined : present.reduce((sum, value) => sum + value, 0)
147
+ }
148
+
149
+ const totalsOf = (samples: ReadonlyArray<UsageSample>): UsageTotals => {
150
+ const cached = sumOptional(samples.map((sample) => sample.cached))
151
+ const costUsd = sumOptional(samples.map((sample) => sample.costUsd))
152
+ return UsageTotals.make({
153
+ requests: samples.length,
154
+ prompt: samples.reduce((sum, sample) => sum + sample.prompt, 0),
155
+ completion: samples.reduce((sum, sample) => sum + sample.completion, 0),
156
+ total: samples.reduce((sum, sample) => sum + sample.total, 0),
157
+ ...(cached === undefined ? {} : { cached }),
158
+ ...(costUsd === undefined ? {} : { costUsd })
159
+ })
160
+ }
161
+
162
+ const distinctRuns = (samples: ReadonlyArray<UsageSample>): number =>
163
+ new Set(samples.map((sample) => sample.runId)).size
164
+
165
+ const pad = (value: number): string => value.toString().padStart(2, "0")
166
+
167
+ interface ZonedParts {
168
+ readonly year: number
169
+ readonly month: number
170
+ readonly day: number
171
+ readonly hour: number
172
+ }
173
+
174
+ const partsIn = (timeZone: DateTime.TimeZone, at: number): ZonedParts => {
175
+ const parts = DateTime.toParts(DateTime.makeZonedUnsafe(at, { timeZone }))
176
+ return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour }
177
+ }
178
+
179
+ const dayKey = (parts: ZonedParts): string => `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`
180
+
181
+ const hourKey = (parts: ZonedParts): string => `${dayKey(parts)} ${pad(parts.hour)}:00`
182
+
183
+ const zoneLabel = (timeZone: DateTime.TimeZone): string =>
184
+ timeZone._tag === "Named"
185
+ ? timeZone.id
186
+ : timeZone.offset === 0
187
+ ? "UTC"
188
+ : DateTime.zoneToString(timeZone)
189
+
190
+ const bucketsBy = (
191
+ samples: ReadonlyArray<UsageSample>,
192
+ keyOf: (sample: UsageSample) => string
193
+ ): ReadonlyArray<UsageBucket> => {
194
+ const groups = new Map<string, Array<UsageSample>>()
195
+ for (const sample of samples) {
196
+ const key = keyOf(sample)
197
+ groups.set(key, [...(groups.get(key) ?? []), sample])
198
+ }
199
+ return [...groups.entries()]
200
+ .sort(([left], [right]) => left.localeCompare(right))
201
+ .map(([key, members]) =>
202
+ UsageBucket.make({
203
+ key,
204
+ runs: distinctRuns(members),
205
+ measured: totalsOf(members.filter((sample) => !sample.estimated)),
206
+ estimated: totalsOf(members.filter((sample) => sample.estimated))
207
+ })
208
+ )
209
+ }
210
+
211
+ const bucketTokens = (bucket: UsageBucket): number => bucket.measured.total + bucket.estimated.total
212
+
213
+ const bucketCost = (bucket: UsageBucket): number | undefined =>
214
+ sumOptional([bucket.measured.costUsd, bucket.estimated.costUsd])
215
+
216
+ /** The first bucket carrying the most tokens, in chronological order. */
217
+ const peakOf = (buckets: ReadonlyArray<UsageBucket>): UsageBucket | undefined =>
218
+ buckets.reduce<UsageBucket | undefined>(
219
+ (best, bucket) =>
220
+ best === undefined || bucketTokens(bucket) > bucketTokens(best) ? bucket : best,
221
+ undefined
222
+ )
223
+
224
+ const calendarDaysBetween = (first: ZonedParts, last: ZonedParts): number =>
225
+ Math.round(
226
+ (Date.UTC(last.year, last.month - 1, last.day) -
227
+ Date.UTC(first.year, first.month - 1, first.day)) /
228
+ 86_400_000
229
+ ) + 1
230
+
231
+ const ratio = (value: number, count: number): number => (count === 0 ? 0 : value / count)
232
+
233
+ const costRatio = (cost: number | undefined, count: number): number | undefined =>
234
+ cost === undefined || count === 0 ? undefined : cost / count
235
+
236
+ export interface CostReportOptions {
237
+ /** Zone the day and hour buckets are cut in. Default UTC. */
238
+ readonly timeZone?: DateTime.TimeZone
239
+ /** Assumed daily run count for the projection line; none when absent. */
240
+ readonly runsPerDay?: number
241
+ }
242
+
243
+ export const buildCostReport = (
244
+ samples: ReadonlyArray<UsageSample>,
245
+ options: CostReportOptions = {}
246
+ ): CostReport => {
247
+ const timeZone = options.timeZone ?? DateTime.zoneMakeOffset(0)
248
+ const ordered = [...samples].sort((left, right) => left.at - right.at)
249
+ const byDay = bucketsBy(ordered, (sample) => dayKey(partsIn(timeZone, sample.at)))
250
+ const byHour = bucketsBy(ordered, (sample) => hourKey(partsIn(timeZone, sample.at)))
251
+ const measured = totalsOf(ordered.filter((sample) => !sample.estimated))
252
+ const estimated = totalsOf(ordered.filter((sample) => sample.estimated))
253
+ const tokens = measured.total + estimated.total
254
+ const cost = sumOptional([measured.costUsd, estimated.costUsd])
255
+ const runs = distinctRuns(ordered)
256
+ const first = ordered[0]
257
+ const last = ordered[ordered.length - 1]
258
+ const calendarDays =
259
+ first === undefined || last === undefined
260
+ ? 0
261
+ : calendarDaysBetween(partsIn(timeZone, first.at), partsIn(timeZone, last.at))
262
+ const tokensPerRun = ratio(tokens, runs)
263
+ const costUsdPerRun = costRatio(cost, runs)
264
+ const costUsdPerActiveDay = costRatio(cost, byDay.length)
265
+ const costUsdPerCalendarDay = costRatio(cost, calendarDays)
266
+ const costUsdPerActiveHour = costRatio(cost, byHour.length)
267
+ const peakDay = peakOf(byDay)
268
+ const peakHour = peakOf(byHour)
269
+ const projection =
270
+ options.runsPerDay === undefined
271
+ ? undefined
272
+ : UsageProjection.make({
273
+ runsPerDay: options.runsPerDay,
274
+ tokensPerDay: Math.round(tokensPerRun * options.runsPerDay),
275
+ ...(costUsdPerRun === undefined
276
+ ? {}
277
+ : { costUsdPerDay: costUsdPerRun * options.runsPerDay })
278
+ })
279
+
280
+ const byModelGroups = new Map<string, Array<UsageSample>>()
281
+ for (const sample of ordered) {
282
+ byModelGroups.set(sample.model, [...(byModelGroups.get(sample.model) ?? []), sample])
283
+ }
284
+ const byModel = [...byModelGroups.entries()]
285
+ .map(([model, members]) =>
286
+ ModelUsage.make({ model, estimated: isEstimatedModel(model), totals: totalsOf(members) })
287
+ )
288
+ .sort((left, right) =>
289
+ left.estimated === right.estimated
290
+ ? left.model.localeCompare(right.model)
291
+ : left.estimated
292
+ ? 1
293
+ : -1
294
+ )
295
+
296
+ return CostReport.make({
297
+ schemaVersion: CurrentCostReportSchema,
298
+ timeZone: zoneLabel(timeZone),
299
+ pricesAsOf: PricesAsOf,
300
+ ...(first === undefined ? {} : { from: new Date(first.at).toISOString() }),
301
+ ...(last === undefined ? {} : { to: new Date(last.at).toISOString() }),
302
+ runs,
303
+ requests: ordered.length,
304
+ measured,
305
+ estimated,
306
+ averages: UsageAverages.make({
307
+ activeDays: byDay.length,
308
+ calendarDays,
309
+ activeHours: byHour.length,
310
+ tokensPerActiveDay: Math.round(ratio(tokens, byDay.length)),
311
+ tokensPerCalendarDay: Math.round(ratio(tokens, calendarDays)),
312
+ tokensPerActiveHour: Math.round(ratio(tokens, byHour.length)),
313
+ tokensPerRun: Math.round(tokensPerRun),
314
+ ...(costUsdPerActiveDay === undefined ? {} : { costUsdPerActiveDay }),
315
+ ...(costUsdPerCalendarDay === undefined ? {} : { costUsdPerCalendarDay }),
316
+ ...(costUsdPerActiveHour === undefined ? {} : { costUsdPerActiveHour }),
317
+ ...(costUsdPerRun === undefined ? {} : { costUsdPerRun }),
318
+ ...(peakDay === undefined ? {} : { peakDay: peakDay.key }),
319
+ ...(peakHour === undefined ? {} : { peakHour: peakHour.key })
320
+ }),
321
+ ...(projection === undefined ? {} : { projection }),
322
+ byDay,
323
+ byHour,
324
+ byModel
325
+ })
326
+ }
327
+
328
+ const thousands = (value: number): string =>
329
+ Math.round(value)
330
+ .toString()
331
+ .replace(/\B(?=(\d{3})+(?!\d))/gu, ",")
332
+
333
+ const money = (cost: number | undefined): string =>
334
+ cost === undefined ? "" : ` ($${cost.toFixed(4)})`
335
+
336
+ const totalsLine = (label: string, totals: UsageTotals): string => {
337
+ const cached =
338
+ totals.cached === undefined || totals.cached <= 0 ? "" : ` (${thousands(totals.cached)} cached)`
339
+ return `${label}: ${thousands(totals.prompt)} in${cached}, ${thousands(totals.completion)} out, ${thousands(totals.total)} total${money(totals.costUsd)}`
340
+ }
341
+
342
+ const runsLabel = (runs: number): string => `${runs} run${runs === 1 ? "" : "s"}`
343
+
344
+ const bucketLine = (bucket: UsageBucket): string =>
345
+ ` ${bucket.key} ${runsLabel(bucket.runs)} ${thousands(bucket.measured.total)} measured ${thousands(bucket.estimated.total)} estimated${money(bucketCost(bucket))}`
346
+
347
+ export const renderCostReport = (report: CostReport): string => {
348
+ if (report.from === undefined || report.to === undefined) {
349
+ return "llm4ts costs\n\nno usage recorded — run a flow whose backend reports token counts, then try again"
350
+ }
351
+ const averages = report.averages
352
+ const peakDay = peakOf(report.byDay)
353
+ const peakHour = peakOf(report.byHour)
354
+ const peaks = [
355
+ ...(peakDay === undefined
356
+ ? []
357
+ : [`peak day ${peakDay.key} (${thousands(bucketTokens(peakDay))})`]),
358
+ ...(peakHour === undefined
359
+ ? []
360
+ : [`peak hour ${peakHour.key} (${thousands(bucketTokens(peakHour))})`])
361
+ ]
362
+ const sections: Array<string> = [
363
+ "llm4ts costs",
364
+ `window: ${report.from} → ${report.to} (${report.timeZone})`,
365
+ `runs: ${report.runs} · requests: ${report.requests}`,
366
+ totalsLine("measured", report.measured),
367
+ totalsLine("estimated", report.estimated),
368
+ "",
369
+ "averages (measured + estimated):",
370
+ ` per active day: ${thousands(averages.tokensPerActiveDay)} tokens${money(averages.costUsdPerActiveDay)} over ${averages.activeDays} day${averages.activeDays === 1 ? "" : "s"}`,
371
+ ` per calendar day: ${thousands(averages.tokensPerCalendarDay)} tokens${money(averages.costUsdPerCalendarDay)} over ${averages.calendarDays} day${averages.calendarDays === 1 ? "" : "s"}`,
372
+ ` per active hour: ${thousands(averages.tokensPerActiveHour)} tokens${money(averages.costUsdPerActiveHour)} over ${averages.activeHours} hour${averages.activeHours === 1 ? "" : "s"}`,
373
+ ` per run: ${thousands(averages.tokensPerRun)} tokens${money(averages.costUsdPerRun)}`,
374
+ ...(peaks.length === 0 ? [] : [` ${peaks.join(", ")}`])
375
+ ]
376
+ if (report.projection !== undefined) {
377
+ sections.push(
378
+ "projection:",
379
+ ` at ${report.projection.runsPerDay} runs/day: ${thousands(report.projection.tokensPerDay)} tokens${money(report.projection.costUsdPerDay)} per day`
380
+ )
381
+ }
382
+ sections.push("", "by day:", ...report.byDay.map(bucketLine))
383
+ sections.push("", "by hour:", ...report.byHour.map(bucketLine))
384
+ sections.push(
385
+ "",
386
+ "by model:",
387
+ ...report.byModel.map(
388
+ (entry) =>
389
+ ` ${entry.model} ${thousands(entry.totals.total)} tokens${money(entry.totals.costUsd)}`
390
+ )
391
+ )
392
+ const notes = [
393
+ `costs are backend-reported where available, otherwise estimated from the pricing table (rates as of ${report.pricesAsOf} — may be stale)`,
394
+ ...(report.estimated.requests === 0
395
+ ? []
396
+ : [
397
+ "estimated:<model> rows come from character counts (LLM4TS_ESTIMATE_MODEL), not backend token counts"
398
+ ])
399
+ ]
400
+ sections.push("", ...notes.map((note) => `* ${note}`))
401
+ return sections.join("\n")
402
+ }
@@ -1,19 +1,27 @@
1
1
  import * as Effect from "effect/Effect"
2
2
  import * as Ref from "effect/Ref"
3
+ import * as Result from "effect/Result"
3
4
  import * as Stream from "effect/Stream"
4
5
  import type { LlmError } from "@llm4ts/core/Errors"
5
- import type { LlmServiceShape } from "@llm4ts/core/LlmService"
6
- import { LlmChunk, TokenUsage, type Message } from "@llm4ts/core/Models"
6
+ import type { LabelSequence, LlmServiceShape } from "@llm4ts/core/LlmService"
7
+ import { LabelDistribution, LlmChunk, TokenUsage, type Message } from "@llm4ts/core/Models"
7
8
  import { estimateCostUsd } from "./PriceList.ts"
8
9
 
9
- // ESTIMATED token accounting for connectors that report none (every CLI
10
- // connector: `makeCliConnector` returns no usage at all). The decorator
11
- // counts characters on both sides of each request and, when the backend
12
- // reported nothing, appends a synthetic final chunk carrying the estimate —
13
- // so `collect`, `Chat`, `CostTracker`, and the cost summary all see it
14
- // through the normal event path, labelled `estimated:<model>` so no report
15
- // can mistake it for measured usage (ADR 0012; the PoC decision of
16
- // 2026-08-30 is estimates-only, no CLI usage parsing).
10
+ // ESTIMATED token accounting for connectors that report none (Antigravity,
11
+ // Copilot, and Cursor on the capability matrix; every backend whose
12
+ // `usageReporting` is false). The decorator counts characters on both sides
13
+ // of each request and, when the backend reported nothing, carries the
14
+ // estimate back on the value the caller already reads — a synthetic final
15
+ // chunk for streams, the returned usage for structured calls and label
16
+ // scoring — so `collect`, `Chat`, `LlmJudgment`'s usage hook, `CostTracker`,
17
+ // and the cost summary all see it through the normal event path, labelled
18
+ // `estimated:<model>` so no report can mistake it for measured usage
19
+ // (ADR 0012; the PoC decision of 2026-08-30 is estimates-only, no CLI usage
20
+ // parsing).
21
+ //
22
+ // The runner meters every seat with this by default, so a flow inherits it
23
+ // without wrapping anything; wrapping again is safe, because an estimate is
24
+ // only ever filled in where no usage was reported at all.
17
25
 
18
26
  export interface EstimatedUsageOptions {
19
27
  /** Pricing reference for the estimate — a `PriceList` model prefix. */
@@ -59,6 +67,14 @@ export const estimateUsage = (
59
67
  const messagesChars = (messages: ReadonlyArray<Message>): number =>
60
68
  messages.reduce((sum, message) => sum + message.content.length, 0)
61
69
 
70
+ /** Characters the backend produced for a batch: the readable answers only. */
71
+ const sequenceCompletionChars = (sequence: LabelSequence): number =>
72
+ sequence.entries.reduce(
73
+ (sum, entry) =>
74
+ sum + (Result.isSuccess(entry) ? JSON.stringify(entry.success.probabilities).length : 0),
75
+ 0
76
+ )
77
+
62
78
  const mergeTotals = (previous: TokenUsage | undefined, usage: TokenUsage): TokenUsage => {
63
79
  const cached = [previous?.cached, usage.cached].flatMap((value) =>
64
80
  value === undefined ? [] : [value]
@@ -138,6 +154,35 @@ export const makeEstimatedUsageMeter = Effect.fn("@llm4ts/flow/EstimatedUsage.ma
138
154
  const totals = yield* Ref.make<TokenUsage | undefined>(undefined)
139
155
  const record = (usage: TokenUsage): Effect.Effect<void> =>
140
156
  Ref.update(totals, (previous) => mergeTotals(previous, usage))
157
+ const estimatedLabel = estimatedModelLabel(options.referenceModel)
158
+ const withEstimate = <A>(usage: TokenUsage, carry: (usage: TokenUsage) => A): Effect.Effect<A> =>
159
+ record(usage).pipe(Effect.as(carry(usage)))
160
+
161
+ // `scoreLabelSequence` is optional on the shape and the judgment layer reads
162
+ // its absence as "this backend cannot batch" (falling back to a verbalized
163
+ // sequence). So it is forwarded only when the seat really has it: defining it
164
+ // unconditionally would advertise a capability the backend lacks, and leaving
165
+ // it out of the decorator would hide one it has.
166
+ const batched = service.scoreLabelSequence
167
+ const scoreLabelSequence =
168
+ batched === undefined
169
+ ? undefined
170
+ : (
171
+ prompt: string,
172
+ labelSets: ReadonlyArray<ReadonlyArray<string>>
173
+ ): Effect.Effect<LabelSequence, LlmError> =>
174
+ batched(prompt, labelSets).pipe(
175
+ Effect.flatMap((sequence) =>
176
+ sequence.usage === undefined
177
+ ? withEstimate(
178
+ // One call, so one estimate for the whole batch: the
179
+ // per-question entries of a sequence carry no usage.
180
+ estimateUsage(prompt.length, sequenceCompletionChars(sequence), options),
181
+ (usage) => ({ ...sequence, usage, model: estimatedLabel })
182
+ )
183
+ : record(sequence.usage).pipe(Effect.as(sequence))
184
+ )
185
+ )
141
186
 
142
187
  const decorated: LlmServiceShape = {
143
188
  executeStream: (prompt) =>
@@ -174,6 +219,32 @@ export const makeEstimatedUsageMeter = Effect.fn("@llm4ts/flow/EstimatedUsage.ma
174
219
  )
175
220
  })
176
221
  ),
222
+ // A connector builds its verbalized `scoreLabels` from its own raw
223
+ // `executeStructuredWithUsage`, so the substitution below is the only one
224
+ // that reaches label scoring. Relabelling the model is the point of it:
225
+ // published under the backend's real name, an estimate would land in a
226
+ // measured column. The judgment origin then records `estimated:<model>`
227
+ // rather than the backend's name for such a seat, which is the accepted
228
+ // cost of never mixing the two.
229
+ scoreLabels: (prompt, labels) =>
230
+ service
231
+ .scoreLabels(prompt, labels)
232
+ .pipe(
233
+ Effect.flatMap((distribution) =>
234
+ distribution.usage === undefined
235
+ ? withEstimate(
236
+ estimateUsage(
237
+ prompt.length,
238
+ JSON.stringify(distribution.probabilities).length,
239
+ options
240
+ ),
241
+ (usage) =>
242
+ LabelDistribution.make({ ...distribution, usage, model: estimatedLabel })
243
+ )
244
+ : record(distribution.usage).pipe(Effect.as(distribution))
245
+ )
246
+ ),
247
+ ...(scoreLabelSequence === undefined ? {} : { scoreLabelSequence }),
177
248
  isAvailable: service.isAvailable
178
249
  }
179
250
  return { service: decorated, totals: Ref.get(totals) }
package/src/Flow.ts CHANGED
@@ -5,10 +5,19 @@ import { withToolActivity } from "./Activity.ts"
5
5
  import { makeChat, type Chat } from "./Chat.ts"
6
6
  import type { FlowContextShape } from "./FlowContext.ts"
7
7
  import { FlowAborted, FlowLlmError, type FlowError } from "./FlowError.ts"
8
- import { AssistantMessage, Info, TokensUsed, type FlowEventsShape } from "./FlowEvents.ts"
8
+ import {
9
+ AssistantMessage,
10
+ Info,
11
+ JudgmentObserved,
12
+ publishJudgmentObserved,
13
+ TokensUsed,
14
+ type FlowEventsShape
15
+ } from "./FlowEvents.ts"
9
16
  import type { Plan, Task } from "./Plan.ts"
10
17
  import type { PlanStoreShape } from "./Persistence.ts"
11
18
  import { implementTaskLoop, stage } from "./PlanExecution.ts"
19
+ import { truth } from "@llm4ts/core/judgment/Schemas"
20
+ import { certaintyOf, decide, judgmentOf, type JudgmentMode } from "./Judgment.ts"
12
21
  import { minimalReviewers, reviewAndFixLoop, type ReviewResult } from "./Review.ts"
13
22
  import type { Reviewer } from "./Reviewer.ts"
14
23
 
@@ -65,8 +74,65 @@ export interface ImplementPlanOptions {
65
74
  * where one unconfirmed no-op should not sink otherwise-finished work.
66
75
  */
67
76
  readonly noopTaskPolicy?: "fail" | "complete"
77
+ /**
78
+ * How the "already satisfied" confirmation is read (ADR 0017): "literal"
79
+ * (default) looks for TASK_ALREADY_SATISFIED without any judgment call.
80
+ * { mode } asks the run's judgment service, defaulting to observe: observe
81
+ * and advise publish the literal outcome and keep using it. Explicit act
82
+ * uses a confident judgment, falling back to the literal reading on doubt
83
+ * or failure. The legacy "judgment" string remains an alias for { mode: "act" }.
84
+ */
85
+ readonly satisfiedProbe?: "literal" | "judgment" | { readonly mode?: JudgmentMode }
68
86
  }
69
87
 
88
+ /** The judgment form of the empty-diff probe: one Truth question over the coder's reply. */
89
+ export const satisfiedByJudgment = Effect.fn("@llm4ts/flow/Flow.satisfiedByJudgment")(function* (
90
+ context: FlowContextShape,
91
+ taskTitle: string,
92
+ reply: string,
93
+ mode: JudgmentMode = "observe"
94
+ ): Effect.fn.Return<boolean | undefined> {
95
+ const literalMatch = reply.includes("TASK_ALREADY_SATISFIED")
96
+ const judgment = judgmentOf(context)
97
+ const state = { task: taskTitle, reply }
98
+ const question = truth(
99
+ "The reply states that the task is already fully satisfied by the current repository and that no change was made."
100
+ )
101
+ const result = yield* judgment
102
+ .judge({ state, questions: { satisfied: question } })
103
+ .pipe(Effect.option)
104
+ if (result._tag === "None") {
105
+ return mode === "act" ? undefined : literalMatch
106
+ }
107
+ const answer = result.value.answers["satisfied"]
108
+ if (answer === undefined || answer.type !== "truth") {
109
+ return mode === "act" ? undefined : literalMatch
110
+ }
111
+ const decision = decide(answer)
112
+ if (mode !== "act") {
113
+ yield* publishJudgmentObserved(
114
+ context.events,
115
+ JudgmentObserved.make({
116
+ consumer: "satisfied-probe",
117
+ key: "satisfied",
118
+ state,
119
+ question,
120
+ answer,
121
+ judgmentIdentity: judgment.identity,
122
+ decision,
123
+ certainty: certaintyOf(answer),
124
+ support: answer.support,
125
+ origin: answer.origin,
126
+ outcome: { _tag: "SatisfiedProbe", literalMatch },
127
+ mode
128
+ })
129
+ )
130
+ return literalMatch
131
+ }
132
+ if (decision !== "act") return undefined
133
+ return answer.truth >= 0.5
134
+ })
135
+
70
136
  const defaultCommitMessage = (plan: Plan, task: Task): string => `${plan.epicId}: ${task.title}`
71
137
 
72
138
  const composeSystem = (base: string | undefined, note: string): string =>
@@ -130,7 +196,16 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
130
196
  )
131
197
  const afterConfirmation = yield* context.git.diffAll
132
198
  if (afterConfirmation.trim().length === 0) {
133
- if (confirmation.includes("TASK_ALREADY_SATISFIED")) {
199
+ const judged =
200
+ options.satisfiedProbe !== undefined && options.satisfiedProbe !== "literal"
201
+ ? yield* satisfiedByJudgment(
202
+ context,
203
+ task.title,
204
+ confirmation,
205
+ options.satisfiedProbe === "judgment" ? "act" : options.satisfiedProbe.mode
206
+ )
207
+ : undefined
208
+ if (judged ?? confirmation.includes("TASK_ALREADY_SATISFIED")) {
134
209
  yield* context.events.publish(
135
210
  Info.make({
136
211
  message: `task "${task.title}" confirmed already satisfied; skipping review and commit`