@llm4ts/flow 0.13.5 → 0.14.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/dist/BoardSync.d.ts +81 -0
- package/dist/BoardSync.d.ts.map +1 -0
- package/dist/BoardSync.js +259 -0
- package/dist/BoardSync.js.map +1 -0
- package/dist/EstimatedUsage.d.ts +28 -0
- package/dist/EstimatedUsage.d.ts.map +1 -0
- package/dist/EstimatedUsage.js +91 -0
- package/dist/EstimatedUsage.js.map +1 -0
- package/dist/Pack.d.ts +1 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +7 -0
- package/dist/Pack.js.map +1 -1
- package/dist/PageSpec.d.ts +104 -0
- package/dist/PageSpec.d.ts.map +1 -0
- package/dist/PageSpec.js +272 -0
- package/dist/PageSpec.js.map +1 -0
- package/dist/SpecChecks.d.ts +6 -1
- package/dist/SpecChecks.d.ts.map +1 -1
- package/dist/SpecChecks.js +11 -4
- package/dist/SpecChecks.js.map +1 -1
- package/dist/Survey.d.ts +38 -1
- package/dist/Survey.d.ts.map +1 -1
- package/dist/Survey.js +101 -5
- package/dist/Survey.js.map +1 -1
- package/dist/Workspace.d.ts +44 -5
- package/dist/Workspace.d.ts.map +1 -1
- package/dist/Workspace.js +84 -14
- package/dist/Workspace.js.map +1 -1
- package/package.json +5 -2
- package/src/BoardSync.ts +358 -0
- package/src/EstimatedUsage.ts +180 -0
- package/src/Pack.ts +11 -0
- package/src/PageSpec.ts +354 -0
- package/src/SpecChecks.ts +12 -6
- package/src/Survey.ts +137 -5
- package/src/Workspace.ts +120 -15
package/src/BoardSync.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Ref from "effect/Ref"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import { quoteWiql, type AzureDevOpsToolShape } from "./AzureDevOpsTool.ts"
|
|
5
|
+
import { FlowAborted, type FlowError } from "./FlowError.ts"
|
|
6
|
+
import { loadVersioned, saveVersioned, type PlainFileStoreShape } from "./Persistence.ts"
|
|
7
|
+
|
|
8
|
+
// The progress board of the conversion scenario: a port with the work-item
|
|
9
|
+
// lifecycle (plan → start → complete/fail/skip) and two adapters — a local
|
|
10
|
+
// file board (the default, fully offline: board.json + rendered board.md)
|
|
11
|
+
// and an Azure DevOps adapter (the stretch goal) mapping the same lifecycle
|
|
12
|
+
// onto work items. Flows depend only on the port; which board the client
|
|
13
|
+
// sees is wiring, not policy.
|
|
14
|
+
|
|
15
|
+
export const BoardVersion = 1
|
|
16
|
+
|
|
17
|
+
export const BoardStatus = Schema.Literals(["planned", "active", "done", "failed", "skipped"])
|
|
18
|
+
export type BoardStatus = typeof BoardStatus.Type
|
|
19
|
+
|
|
20
|
+
export class BoardItem extends Schema.Class<BoardItem>("BoardItem")({
|
|
21
|
+
id: Schema.String,
|
|
22
|
+
title: Schema.String,
|
|
23
|
+
wave: Schema.optionalKey(Schema.String),
|
|
24
|
+
status: BoardStatus,
|
|
25
|
+
branch: Schema.optionalKey(Schema.String),
|
|
26
|
+
reportPath: Schema.optionalKey(Schema.String),
|
|
27
|
+
/** Free-form note: a failure reason, a skip rationale, a triage disposition. */
|
|
28
|
+
detail: Schema.optionalKey(Schema.String),
|
|
29
|
+
/** ESTIMATES, never measurements — see EstimatedUsage (ADR 0012). */
|
|
30
|
+
estimatedTokens: Schema.optionalKey(Schema.Int),
|
|
31
|
+
estimatedCostUsd: Schema.optionalKey(Schema.Number)
|
|
32
|
+
}) {}
|
|
33
|
+
|
|
34
|
+
export class Board extends Schema.Class<Board>("Board")({
|
|
35
|
+
title: Schema.String,
|
|
36
|
+
items: Schema.Array(BoardItem)
|
|
37
|
+
}) {}
|
|
38
|
+
|
|
39
|
+
export interface BoardItemResult {
|
|
40
|
+
readonly branch?: string
|
|
41
|
+
readonly reportPath?: string
|
|
42
|
+
readonly detail?: string
|
|
43
|
+
readonly estimatedTokens?: number
|
|
44
|
+
readonly estimatedCostUsd?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BoardSyncShape {
|
|
48
|
+
/** Publish the full planned work list. Idempotent: known ids keep their state. */
|
|
49
|
+
readonly plan: (items: ReadonlyArray<BoardItem>) => Effect.Effect<void, FlowError>
|
|
50
|
+
readonly start: (id: string) => Effect.Effect<void, FlowError>
|
|
51
|
+
readonly complete: (id: string, result: BoardItemResult) => Effect.Effect<void, FlowError>
|
|
52
|
+
readonly fail: (id: string, reason: string) => Effect.Effect<void, FlowError>
|
|
53
|
+
readonly skip: (id: string, reason: string) => Effect.Effect<void, FlowError>
|
|
54
|
+
readonly snapshot: Effect.Effect<Board, FlowError>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sectionOrder: ReadonlyArray<readonly [BoardStatus, string]> = [
|
|
58
|
+
["active", "Active"],
|
|
59
|
+
["planned", "Planned"],
|
|
60
|
+
["done", "Done"],
|
|
61
|
+
["failed", "Failed"],
|
|
62
|
+
["skipped", "Skipped"]
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
const itemLine = (item: BoardItem): string => {
|
|
66
|
+
const parts: Array<string> = [`- **${item.id}** — ${item.title}`]
|
|
67
|
+
if (item.wave !== undefined) {
|
|
68
|
+
parts.push(`(wave: ${item.wave})`)
|
|
69
|
+
}
|
|
70
|
+
if (item.branch !== undefined) {
|
|
71
|
+
parts.push(`branch \`${item.branch}\``)
|
|
72
|
+
}
|
|
73
|
+
if (item.reportPath !== undefined) {
|
|
74
|
+
parts.push(`[report](${item.reportPath})`)
|
|
75
|
+
}
|
|
76
|
+
if (item.estimatedTokens !== undefined || item.estimatedCostUsd !== undefined) {
|
|
77
|
+
const tokens = item.estimatedTokens === undefined ? "" : `~${item.estimatedTokens} tokens`
|
|
78
|
+
const cost = item.estimatedCostUsd === undefined ? "" : `~$${item.estimatedCostUsd.toFixed(2)}`
|
|
79
|
+
parts.push(`(${[tokens, cost].filter((part) => part.length > 0).join(", ")} — estimated)`)
|
|
80
|
+
}
|
|
81
|
+
if (item.detail !== undefined) {
|
|
82
|
+
parts.push(`— ${item.detail}`)
|
|
83
|
+
}
|
|
84
|
+
return parts.join(" ")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The markdown board — every figure on it labelled estimated. */
|
|
88
|
+
export const renderBoard = (board: Board): string => {
|
|
89
|
+
const lines: Array<string> = [`# Board: ${board.title}`, ""]
|
|
90
|
+
const counts = sectionOrder
|
|
91
|
+
.map(([status]) => `${status}: ${board.items.filter((item) => item.status === status).length}`)
|
|
92
|
+
.join(" · ")
|
|
93
|
+
lines.push(counts, "")
|
|
94
|
+
lines.push(
|
|
95
|
+
"All token and cost figures are ESTIMATES (character-count heuristics), not",
|
|
96
|
+
"measurements.",
|
|
97
|
+
""
|
|
98
|
+
)
|
|
99
|
+
for (const [status, heading] of sectionOrder) {
|
|
100
|
+
const items = board.items.filter((item) => item.status === status)
|
|
101
|
+
if (items.length === 0) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
lines.push(`## ${heading}`, "")
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
lines.push(itemLine(item))
|
|
107
|
+
}
|
|
108
|
+
lines.push("")
|
|
109
|
+
}
|
|
110
|
+
return lines.join("\n").trimEnd() + "\n"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const join = (root: string, path: string): string =>
|
|
114
|
+
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
115
|
+
|
|
116
|
+
const applyResult = (item: BoardItem, status: BoardStatus, result: BoardItemResult): BoardItem =>
|
|
117
|
+
BoardItem.make({
|
|
118
|
+
...item,
|
|
119
|
+
status,
|
|
120
|
+
...(result.branch === undefined ? {} : { branch: result.branch }),
|
|
121
|
+
...(result.reportPath === undefined ? {} : { reportPath: result.reportPath }),
|
|
122
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
123
|
+
...(result.estimatedTokens === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { estimatedTokens: Math.round(result.estimatedTokens) }),
|
|
126
|
+
...(result.estimatedCostUsd === undefined ? {} : { estimatedCostUsd: result.estimatedCostUsd })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The default adapter: `board.json` (versioned) plus a rendered `board.md`
|
|
131
|
+
* under `directory`. The JSON is the state, the markdown is the demo surface;
|
|
132
|
+
* both are rewritten on every mutation so a crash never leaves them apart.
|
|
133
|
+
*/
|
|
134
|
+
export const makeLocalBoardSync = (
|
|
135
|
+
files: PlainFileStoreShape,
|
|
136
|
+
directory: string,
|
|
137
|
+
title: string
|
|
138
|
+
): BoardSyncShape => {
|
|
139
|
+
const jsonPath = join(directory, "board.json")
|
|
140
|
+
const markdownPath = join(directory, "board.md")
|
|
141
|
+
|
|
142
|
+
const load: Effect.Effect<Board, FlowError> = loadVersioned(
|
|
143
|
+
files,
|
|
144
|
+
jsonPath,
|
|
145
|
+
BoardVersion,
|
|
146
|
+
Board
|
|
147
|
+
).pipe(Effect.map((board) => board ?? Board.make({ title, items: [] })))
|
|
148
|
+
|
|
149
|
+
const save = (board: Board): Effect.Effect<void, FlowError> =>
|
|
150
|
+
saveVersioned(files, jsonPath, BoardVersion, Board, board).pipe(
|
|
151
|
+
Effect.andThen(files.writeAtomic(markdownPath, renderBoard(board)))
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const update = (
|
|
155
|
+
id: string,
|
|
156
|
+
transform: (item: BoardItem) => BoardItem
|
|
157
|
+
): Effect.Effect<void, FlowError> =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
const board = yield* load
|
|
160
|
+
if (!board.items.some((item) => item.id === id)) {
|
|
161
|
+
return yield* FlowAborted.make({ message: `board has no item '${id}' — plan it first` })
|
|
162
|
+
}
|
|
163
|
+
yield* save(
|
|
164
|
+
Board.make({
|
|
165
|
+
...board,
|
|
166
|
+
items: board.items.map((item) => (item.id === id ? transform(item) : item))
|
|
167
|
+
})
|
|
168
|
+
)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
plan: (items) =>
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const board = yield* load
|
|
175
|
+
const known = new Map(board.items.map((item) => [item.id, item] as const))
|
|
176
|
+
// Known ids keep their lived state — re-planning must never demote a
|
|
177
|
+
// converted page back to "planned".
|
|
178
|
+
const merged = [...board.items, ...items.filter((item) => !known.has(item.id))]
|
|
179
|
+
yield* save(Board.make({ title: board.title, items: merged }))
|
|
180
|
+
}),
|
|
181
|
+
start: (id) => update(id, (item) => BoardItem.make({ ...item, status: "active" })),
|
|
182
|
+
complete: (id, result) => update(id, (item) => applyResult(item, "done", result)),
|
|
183
|
+
fail: (id, reason) =>
|
|
184
|
+
update(id, (item) => BoardItem.make({ ...item, status: "failed", detail: reason })),
|
|
185
|
+
skip: (id, reason) =>
|
|
186
|
+
update(id, (item) => BoardItem.make({ ...item, status: "skipped", detail: reason })),
|
|
187
|
+
snapshot: load
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fan one lifecycle out to several boards (local file + ADO, typically).
|
|
193
|
+
* Mutations hit every board in order; `snapshot` reads the FIRST — the local
|
|
194
|
+
* board is the source of truth, the others are mirrors.
|
|
195
|
+
*/
|
|
196
|
+
export const composeBoardSync = (boards: ReadonlyArray<BoardSyncShape>): BoardSyncShape => {
|
|
197
|
+
const first = boards[0]
|
|
198
|
+
const each = (
|
|
199
|
+
operation: (board: BoardSyncShape) => Effect.Effect<void, FlowError>
|
|
200
|
+
): Effect.Effect<void, FlowError> => Effect.forEach(boards, operation, { discard: true })
|
|
201
|
+
return {
|
|
202
|
+
plan: (items) => each((board) => board.plan(items)),
|
|
203
|
+
start: (id) => each((board) => board.start(id)),
|
|
204
|
+
complete: (id, result) => each((board) => board.complete(id, result)),
|
|
205
|
+
fail: (id, reason) => each((board) => board.fail(id, reason)),
|
|
206
|
+
skip: (id, reason) => each((board) => board.skip(id, reason)),
|
|
207
|
+
snapshot:
|
|
208
|
+
first === undefined
|
|
209
|
+
? Effect.succeed(Board.make({ title: "empty", items: [] }))
|
|
210
|
+
: first.snapshot
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface AdoBoardOptions {
|
|
215
|
+
/** Work item type created for each page. Default "Task". */
|
|
216
|
+
readonly workItemType?: string
|
|
217
|
+
/** Tag identifying this board's items — the idempotency key. Default "llm4ts-convert". */
|
|
218
|
+
readonly tag?: string
|
|
219
|
+
/** Lifecycle → System.State mapping. Defaults: New / Active / Closed. */
|
|
220
|
+
readonly states?: {
|
|
221
|
+
readonly planned?: string
|
|
222
|
+
readonly active?: string
|
|
223
|
+
readonly done?: string
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const markerQuery = (tag: string, id: string): string =>
|
|
228
|
+
"SELECT [System.Id] FROM WorkItems WHERE " +
|
|
229
|
+
`[System.Tags] CONTAINS ${quoteWiql(tag)} AND ` +
|
|
230
|
+
`[System.Title] CONTAINS ${quoteWiql(`[${id}]`)}`
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The stretch adapter (ADR 0012): the same lifecycle mapped onto Azure DevOps
|
|
234
|
+
* work items through the az-CLI AzureDevOpsTool (ADR 0011). Idempotent by
|
|
235
|
+
* title marker — `[<id>]` plus the board tag — so a re-run finds its items
|
|
236
|
+
* instead of duplicating them. Failure and skip are tags plus a comment,
|
|
237
|
+
* never invented states.
|
|
238
|
+
*/
|
|
239
|
+
export const makeAdoBoardSync = Effect.fn("@llm4ts/flow/BoardSync.makeAdo")(function* (
|
|
240
|
+
ado: AzureDevOpsToolShape,
|
|
241
|
+
boardTitle: string,
|
|
242
|
+
options: AdoBoardOptions = {}
|
|
243
|
+
): Effect.fn.Return<BoardSyncShape> {
|
|
244
|
+
const workItemType = options.workItemType ?? "Task"
|
|
245
|
+
const tag = options.tag ?? "llm4ts-convert"
|
|
246
|
+
const states = {
|
|
247
|
+
planned: options.states?.planned ?? "New",
|
|
248
|
+
active: options.states?.active ?? "Active",
|
|
249
|
+
done: options.states?.done ?? "Closed"
|
|
250
|
+
}
|
|
251
|
+
const ids = yield* Ref.make<ReadonlyMap<string, number>>(new Map())
|
|
252
|
+
|
|
253
|
+
const lookup = (id: string): Effect.Effect<number, FlowError> =>
|
|
254
|
+
Effect.gen(function* () {
|
|
255
|
+
const cached = (yield* Ref.get(ids)).get(id)
|
|
256
|
+
if (cached !== undefined) {
|
|
257
|
+
return cached
|
|
258
|
+
}
|
|
259
|
+
const found = yield* ado.wiqlIds(markerQuery(tag, id))
|
|
260
|
+
const first = found[0]
|
|
261
|
+
if (first === undefined) {
|
|
262
|
+
return yield* FlowAborted.make({
|
|
263
|
+
message: `no ADO work item tagged '${tag}' with '[${id}]' in its title — plan it first`
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
yield* Ref.update(ids, (current) => new Map(current).set(id, first))
|
|
267
|
+
return first
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
plan: (items) =>
|
|
272
|
+
Effect.gen(function* () {
|
|
273
|
+
for (const item of items) {
|
|
274
|
+
const existing = yield* ado.wiqlIds(markerQuery(tag, item.id))
|
|
275
|
+
const first = existing[0]
|
|
276
|
+
if (first !== undefined) {
|
|
277
|
+
yield* Ref.update(ids, (current) => new Map(current).set(item.id, first))
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
const created = yield* ado.createWorkItem(
|
|
281
|
+
workItemType,
|
|
282
|
+
`[${item.id}] ${item.title}`,
|
|
283
|
+
item.detail ?? item.title,
|
|
284
|
+
[tag]
|
|
285
|
+
)
|
|
286
|
+
yield* Ref.update(ids, (current) => new Map(current).set(item.id, created.id))
|
|
287
|
+
if (states.planned !== "New") {
|
|
288
|
+
yield* ado.setState(created.id, states.planned)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}),
|
|
292
|
+
start: (id) =>
|
|
293
|
+
Effect.gen(function* () {
|
|
294
|
+
const workItem = yield* lookup(id)
|
|
295
|
+
yield* ado.setState(workItem, states.active)
|
|
296
|
+
}),
|
|
297
|
+
complete: (id, result) =>
|
|
298
|
+
Effect.gen(function* () {
|
|
299
|
+
const workItem = yield* lookup(id)
|
|
300
|
+
const parts = [
|
|
301
|
+
result.branch === undefined ? undefined : `Branch: ${result.branch}`,
|
|
302
|
+
result.reportPath === undefined ? undefined : `Report: ${result.reportPath}`,
|
|
303
|
+
result.estimatedTokens === undefined
|
|
304
|
+
? undefined
|
|
305
|
+
: `~${Math.round(result.estimatedTokens)} tokens (estimated)`,
|
|
306
|
+
result.estimatedCostUsd === undefined
|
|
307
|
+
? undefined
|
|
308
|
+
: `~$${result.estimatedCostUsd.toFixed(2)} (estimated)`,
|
|
309
|
+
result.detail
|
|
310
|
+
].filter((part): part is string => part !== undefined)
|
|
311
|
+
if (parts.length > 0) {
|
|
312
|
+
yield* ado.writeComment(workItem, parts.join("\n"))
|
|
313
|
+
}
|
|
314
|
+
yield* ado.setState(workItem, states.done)
|
|
315
|
+
}),
|
|
316
|
+
fail: (id, reason) =>
|
|
317
|
+
Effect.gen(function* () {
|
|
318
|
+
const workItem = yield* lookup(id)
|
|
319
|
+
yield* ado.editTags(workItem, [`${tag}-failed`], [])
|
|
320
|
+
yield* ado.writeComment(workItem, `FAILED: ${reason}`)
|
|
321
|
+
}),
|
|
322
|
+
skip: (id, reason) =>
|
|
323
|
+
Effect.gen(function* () {
|
|
324
|
+
const workItem = yield* lookup(id)
|
|
325
|
+
yield* ado.editTags(workItem, [`${tag}-skipped`], [])
|
|
326
|
+
yield* ado.writeComment(workItem, `SKIPPED: ${reason}`)
|
|
327
|
+
}),
|
|
328
|
+
snapshot: Effect.gen(function* () {
|
|
329
|
+
const found = yield* ado.wiqlIds(
|
|
330
|
+
`SELECT [System.Id] FROM WorkItems WHERE [System.Tags] CONTAINS ${quoteWiql(tag)}`
|
|
331
|
+
)
|
|
332
|
+
const items: Array<BoardItem> = []
|
|
333
|
+
for (const workItemId of found) {
|
|
334
|
+
const workItem = yield* ado.readWorkItem(workItemId)
|
|
335
|
+
const match = /^\[([^\]]+)\] (.*)$/.exec(workItem.title)
|
|
336
|
+
const failed = workItem.tags.includes(`${tag}-failed`)
|
|
337
|
+
const skipped = workItem.tags.includes(`${tag}-skipped`)
|
|
338
|
+
const status: BoardStatus = failed
|
|
339
|
+
? "failed"
|
|
340
|
+
: skipped
|
|
341
|
+
? "skipped"
|
|
342
|
+
: workItem.state === states.done
|
|
343
|
+
? "done"
|
|
344
|
+
: workItem.state === states.active
|
|
345
|
+
? "active"
|
|
346
|
+
: "planned"
|
|
347
|
+
items.push(
|
|
348
|
+
BoardItem.make({
|
|
349
|
+
id: match?.[1] ?? String(workItemId),
|
|
350
|
+
title: match?.[2] ?? workItem.title,
|
|
351
|
+
status
|
|
352
|
+
})
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
return Board.make({ title: boardTitle, items })
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Ref from "effect/Ref"
|
|
3
|
+
import * as Stream from "effect/Stream"
|
|
4
|
+
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"
|
|
7
|
+
import { estimateCostUsd } from "./PriceList.ts"
|
|
8
|
+
|
|
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).
|
|
17
|
+
|
|
18
|
+
export interface EstimatedUsageOptions {
|
|
19
|
+
/** Pricing reference for the estimate — a `PriceList` model prefix. */
|
|
20
|
+
readonly referenceModel: string
|
|
21
|
+
/** Characters per token for the estimate. Default 4. */
|
|
22
|
+
readonly charsPerToken?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const defaultReferenceModel = "claude-sonnet-4"
|
|
26
|
+
|
|
27
|
+
export const estimatedUsageOptionsFromEnv = (
|
|
28
|
+
environment: Readonly<Record<string, string | undefined>>
|
|
29
|
+
): EstimatedUsageOptions => {
|
|
30
|
+
const parsed = Number.parseInt(environment.LLM4TS_ESTIMATE_CHARS_PER_TOKEN ?? "", 10)
|
|
31
|
+
return {
|
|
32
|
+
referenceModel: environment.LLM4TS_ESTIMATE_MODEL?.trim() || defaultReferenceModel,
|
|
33
|
+
...(Number.isFinite(parsed) && parsed > 0 ? { charsPerToken: parsed } : {})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The model label estimates are published under — never a real model id. */
|
|
38
|
+
export const estimatedModelLabel = (referenceModel: string): string => `estimated:${referenceModel}`
|
|
39
|
+
|
|
40
|
+
export const isEstimatedModel = (model: string | undefined): boolean =>
|
|
41
|
+
model !== undefined && model.startsWith("estimated:")
|
|
42
|
+
|
|
43
|
+
const tokensFor = (chars: number, charsPerToken: number): number =>
|
|
44
|
+
Math.ceil(Math.max(0, chars) / charsPerToken)
|
|
45
|
+
|
|
46
|
+
export const estimateUsage = (
|
|
47
|
+
promptChars: number,
|
|
48
|
+
completionChars: number,
|
|
49
|
+
options: EstimatedUsageOptions
|
|
50
|
+
): TokenUsage => {
|
|
51
|
+
const charsPerToken = options.charsPerToken ?? 4
|
|
52
|
+
const prompt = tokensFor(promptChars, charsPerToken)
|
|
53
|
+
const completion = tokensFor(completionChars, charsPerToken)
|
|
54
|
+
const base = TokenUsage.make({ prompt, completion, total: prompt + completion })
|
|
55
|
+
const costUsd = estimateCostUsd(options.referenceModel, base)
|
|
56
|
+
return costUsd === undefined ? base : TokenUsage.make({ ...base, costUsd })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const messagesChars = (messages: ReadonlyArray<Message>): number =>
|
|
60
|
+
messages.reduce((sum, message) => sum + message.content.length, 0)
|
|
61
|
+
|
|
62
|
+
const mergeTotals = (previous: TokenUsage | undefined, usage: TokenUsage): TokenUsage => {
|
|
63
|
+
const cached = [previous?.cached, usage.cached].flatMap((value) =>
|
|
64
|
+
value === undefined ? [] : [value]
|
|
65
|
+
)
|
|
66
|
+
const cost = [previous?.costUsd, usage.costUsd].flatMap((value) =>
|
|
67
|
+
value === undefined ? [] : [value]
|
|
68
|
+
)
|
|
69
|
+
return TokenUsage.make({
|
|
70
|
+
prompt: (previous?.prompt ?? 0) + usage.prompt,
|
|
71
|
+
completion: (previous?.completion ?? 0) + usage.completion,
|
|
72
|
+
total: (previous?.total ?? 0) + usage.total,
|
|
73
|
+
...(cached.length === 0 ? {} : { cached: cached.reduce((sum, value) => sum + value, 0) }),
|
|
74
|
+
...(cost.length === 0 ? {} : { costUsd: cost.reduce((sum, value) => sum + value, 0) })
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface EstimatedUsageMeter {
|
|
79
|
+
/** The decorated service — use this in place of the raw connector. */
|
|
80
|
+
readonly service: LlmServiceShape
|
|
81
|
+
/** Cumulative usage seen so far: backend-reported where present, estimated otherwise. */
|
|
82
|
+
readonly totals: Effect.Effect<TokenUsage | undefined>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const estimatedStream = (
|
|
86
|
+
stream: Stream.Stream<LlmChunk, LlmError>,
|
|
87
|
+
promptChars: number,
|
|
88
|
+
options: EstimatedUsageOptions,
|
|
89
|
+
record: (usage: TokenUsage) => Effect.Effect<void>
|
|
90
|
+
): Stream.Stream<LlmChunk, LlmError> =>
|
|
91
|
+
Stream.unwrap(
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const sawUsage = yield* Ref.make(false)
|
|
94
|
+
const completionChars = yield* Ref.make(0)
|
|
95
|
+
const tapped = stream.pipe(
|
|
96
|
+
Stream.tap((chunk) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
yield* Ref.update(completionChars, (count) => count + chunk.delta.length)
|
|
99
|
+
if (chunk.usage !== undefined) {
|
|
100
|
+
yield* Ref.set(sawUsage, true)
|
|
101
|
+
yield* record(chunk.usage)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
// Appended only after a SUCCESSFUL stream that reported nothing: a
|
|
107
|
+
// failed request estimates nothing, and a backend that reported real
|
|
108
|
+
// usage is never double-counted.
|
|
109
|
+
const tail: Stream.Stream<LlmChunk, LlmError> = Stream.unwrap(
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
if (yield* Ref.get(sawUsage)) {
|
|
112
|
+
return Stream.empty
|
|
113
|
+
}
|
|
114
|
+
const usage = estimateUsage(promptChars, yield* Ref.get(completionChars), options)
|
|
115
|
+
yield* record(usage)
|
|
116
|
+
return Stream.succeed(
|
|
117
|
+
LlmChunk.make({
|
|
118
|
+
delta: "",
|
|
119
|
+
usage,
|
|
120
|
+
metadata: { model: estimatedModelLabel(options.referenceModel) }
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
)
|
|
125
|
+
return Stream.concat(tapped, tail)
|
|
126
|
+
})
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decorates a service so every request yields usage — real when the backend
|
|
131
|
+
* reported it, estimated (and labelled so) when it did not — and exposes the
|
|
132
|
+
* running totals for per-run reports.
|
|
133
|
+
*/
|
|
134
|
+
export const makeEstimatedUsageMeter = Effect.fn("@llm4ts/flow/EstimatedUsage.make")(function* (
|
|
135
|
+
service: LlmServiceShape,
|
|
136
|
+
options: EstimatedUsageOptions
|
|
137
|
+
): Effect.fn.Return<EstimatedUsageMeter> {
|
|
138
|
+
const totals = yield* Ref.make<TokenUsage | undefined>(undefined)
|
|
139
|
+
const record = (usage: TokenUsage): Effect.Effect<void> =>
|
|
140
|
+
Ref.update(totals, (previous) => mergeTotals(previous, usage))
|
|
141
|
+
|
|
142
|
+
const decorated: LlmServiceShape = {
|
|
143
|
+
executeStream: (prompt) =>
|
|
144
|
+
estimatedStream(service.executeStream(prompt), prompt.length, options, record),
|
|
145
|
+
executeStreamWithHistory: (messages) =>
|
|
146
|
+
estimatedStream(
|
|
147
|
+
service.executeStreamWithHistory(messages),
|
|
148
|
+
messagesChars(messages),
|
|
149
|
+
options,
|
|
150
|
+
record
|
|
151
|
+
),
|
|
152
|
+
executeWithTools: (prompt, tools) => service.executeWithTools(prompt, tools),
|
|
153
|
+
executeStructured: (prompt, schema, jsonSchema) =>
|
|
154
|
+
service
|
|
155
|
+
.executeStructured(prompt, schema, jsonSchema)
|
|
156
|
+
.pipe(
|
|
157
|
+
Effect.tap((value) =>
|
|
158
|
+
record(estimateUsage(prompt.length, (JSON.stringify(value) ?? "").length, options))
|
|
159
|
+
)
|
|
160
|
+
),
|
|
161
|
+
executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
|
|
162
|
+
service.executeStructuredWithUsage(prompt, schema, jsonSchema).pipe(
|
|
163
|
+
Effect.flatMap(([value, usage, model]) => {
|
|
164
|
+
if (usage !== undefined) {
|
|
165
|
+
return record(usage).pipe(Effect.as([value, usage, model] as const))
|
|
166
|
+
}
|
|
167
|
+
const estimated = estimateUsage(
|
|
168
|
+
prompt.length,
|
|
169
|
+
(JSON.stringify(value) ?? "").length,
|
|
170
|
+
options
|
|
171
|
+
)
|
|
172
|
+
return record(estimated).pipe(
|
|
173
|
+
Effect.as([value, estimated, estimatedModelLabel(options.referenceModel)] as const)
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
),
|
|
177
|
+
isAvailable: service.isAvailable
|
|
178
|
+
}
|
|
179
|
+
return { service: decorated, totals: Ref.get(totals) }
|
|
180
|
+
})
|
package/src/Pack.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface Pack {
|
|
|
21
21
|
readonly source: string
|
|
22
22
|
readonly scaffold: string | undefined
|
|
23
23
|
readonly sources: string | undefined
|
|
24
|
+
// Regex over repo-relative paths that `sources:` matches but the estate
|
|
25
|
+
// does not own: vendored copies, generated exports, test doubles. Excluded
|
|
26
|
+
// paths never enter the survey graph or the extraction inventory.
|
|
27
|
+
readonly exclude: string | undefined
|
|
24
28
|
readonly programs: string | undefined
|
|
25
29
|
readonly specsDir: string
|
|
26
30
|
readonly featuresDir: string
|
|
@@ -202,11 +206,18 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
202
206
|
message: `pack manifest 'programFiles:' is not a valid regex template: ${programFiles}`
|
|
203
207
|
})
|
|
204
208
|
}
|
|
209
|
+
const exclude = fields.exclude
|
|
210
|
+
if (exclude !== undefined && !isValidRegExp(exclude)) {
|
|
211
|
+
return yield* PlanParseError.make({
|
|
212
|
+
message: `pack manifest 'exclude:' is not a valid regex: ${exclude}`
|
|
213
|
+
})
|
|
214
|
+
}
|
|
205
215
|
const pack: Pack = {
|
|
206
216
|
name: manifest.name,
|
|
207
217
|
source: fields.source ?? "",
|
|
208
218
|
scaffold: fields.scaffold,
|
|
209
219
|
sources: fields.sources,
|
|
220
|
+
exclude,
|
|
210
221
|
programs: fields.programs,
|
|
211
222
|
specsDir: fields["specs-dir"] ?? "docs/specs",
|
|
212
223
|
featuresDir: fields["features-dir"] ?? "features",
|