@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.
@@ -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",