dsh-tacit 0.2.3 → 0.4.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/lib/pricing.js ADDED
@@ -0,0 +1,311 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ /**
4
+ * dsh-tacit — pure pricing (no I/O).
5
+ *
6
+ * Prices one model call from its token usage against either the bundled
7
+ * DeepSeek list prices (peak / off-peak / Beijing-weekend tiers) or a price
8
+ * table sourced from the `dsh-cost-meter` plugin's state. Nothing here
9
+ * touches the network, the store, or the service — `lib/pricing-source.js`
10
+ * is responsible for fetching the cost-meter state, normalizing it with
11
+ * `normalizeCostMeterState`, and handing the result in as `table`.
12
+ *
13
+ * Tier is decided once, at the request's start time (`atMs`) — not at
14
+ * finish, so a call that straddles a boundary is priced consistently.
15
+ */
16
+
17
+ export const PRICES_AS_OF = '2026-08-22'
18
+
19
+ /**
20
+ * `reasoningTokens` (DeepSeek adapter) is always a subset of `outputTokens`,
21
+ * never a separate quantity — so it must not be billed again. Kept as a
22
+ * named constant (rather than inlined `false`) so it can be flipped if a
23
+ * future adapter ever reports reasoning as additional to output.
24
+ */
25
+ export const REASONING_BILLED_SEPARATELY = false
26
+
27
+ /** Provider ids that route through DeepSeek's own API (bundled list prices apply). */
28
+ export const OFFICIAL_PROVIDERS = ['deepseek-official', 'deepseek']
29
+
30
+ /** USD per 1M tokens, as of {@link PRICES_AS_OF}. */
31
+ export const BUNDLED_PRICES = {
32
+ 'deepseek-v4-flash': {
33
+ offPeak: { cacheHit: 0.007, cacheMiss: 0.22, output: 0.66 },
34
+ peak: { cacheHit: 0.014, cacheMiss: 0.44, output: 1.32 },
35
+ },
36
+ 'deepseek-v4-pro': {
37
+ offPeak: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 },
38
+ peak: { cacheHit: 0.044, cacheMiss: 1.32, output: 3.96 },
39
+ },
40
+ }
41
+
42
+ /** Peak hour windows, UTC, `[start, end)`. */
43
+ export const PEAK_WINDOWS_UTC = [{ start: 1, end: 4 }, { start: 6, end: 10 }]
44
+
45
+ /** Beijing-weekend off-peak rule only applies from this moment on. */
46
+ export const WEEKEND_OFFPEAK_FROM = Date.parse('2026-08-22T16:00:00Z')
47
+
48
+ const MS_PER_HOUR = 3600 * 1000
49
+
50
+ function isFiniteNumber(value) {
51
+ return typeof value === 'number' && Number.isFinite(value)
52
+ }
53
+
54
+ function isPositiveNumber(value) {
55
+ return isFiniteNumber(value) && value > 0
56
+ }
57
+
58
+ function isPlainObject(value) {
59
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
60
+ }
61
+
62
+ /** Is the day-of-week of `ms + 8h`, in UTC, a Saturday or Sunday (Beijing calendar)? */
63
+ export function isBeijingWeekend(ms) {
64
+ if (!isFiniteNumber(ms)) return false
65
+ const day = new Date(ms + 8 * MS_PER_HOUR).getUTCDay()
66
+ return day === 0 || day === 6
67
+ }
68
+
69
+ /**
70
+ * The pricing tier in effect at `ms`. `offPeak` when `!peakEnabled`, when
71
+ * `ms < effectiveAtMs`, when it's a Beijing weekend at/after
72
+ * {@link WEEKEND_OFFPEAK_FROM}, or when the UTC hour falls outside every
73
+ * window; `peak` otherwise.
74
+ */
75
+ export function tierAt(ms, { windows = PEAK_WINDOWS_UTC, effectiveAtMs = 0, peakEnabled = true } = {}) {
76
+ if (!peakEnabled) return 'offPeak'
77
+ if (!isFiniteNumber(ms)) return 'offPeak'
78
+ if (isFiniteNumber(effectiveAtMs) && ms < effectiveAtMs) return 'offPeak'
79
+ if (ms >= WEEKEND_OFFPEAK_FROM && isBeijingWeekend(ms)) return 'offPeak'
80
+
81
+ const hour = new Date(ms).getUTCHours()
82
+ const activeWindows = Array.isArray(windows) ? windows : PEAK_WINDOWS_UTC
83
+ const inWindow = activeWindows.some(
84
+ (w) => isPlainObject(w) && isFiniteNumber(w.start) && isFiniteNumber(w.end) && hour >= w.start && hour < w.end,
85
+ )
86
+ return inWindow ? 'peak' : 'offPeak'
87
+ }
88
+
89
+ /**
90
+ * Does `provider` route through DeepSeek's own API (as opposed to a
91
+ * proxy/custom route)? Case-folded: a harness reporting `DeepSeek-Official`
92
+ * names the same route, and an exact match would leave its calls unpriced.
93
+ */
94
+ export function isOfficialRoute(provider) {
95
+ return typeof provider === 'string' && OFFICIAL_PROVIDERS.includes(provider.toLowerCase())
96
+ }
97
+
98
+ /** A well-formed `{cacheHit, cacheMiss, output}` triple, or `null`. */
99
+ function isRateTriple(triple) {
100
+ return isPlainObject(triple) && isFiniteNumber(triple.cacheHit) && isFiniteNumber(triple.cacheMiss) && isFiniteNumber(triple.output)
101
+ }
102
+
103
+ /** Look up `table.models[model]`'s tiered rates for an official-route call, or `null`. */
104
+ function costMeterModelRates(table, model, atMs) {
105
+ const models = isPlainObject(table.models) ? table.models : null
106
+ const entry = models === null ? undefined : models[model]
107
+ if (!isPlainObject(entry) || !isRateTriple(entry.offPeak) || !isRateTriple(entry.peak)) return null
108
+ const tier = tierAt(atMs, {
109
+ windows: table.windows,
110
+ effectiveAtMs: table.effectiveAtMs,
111
+ peakEnabled: table.peakEnabled !== false,
112
+ })
113
+ return { tier, rates: entry[tier] }
114
+ }
115
+
116
+ /**
117
+ * Look up `table.providers[provider][model]`'s flat rates, or `null`. The key
118
+ * is case-folded on both sides (`normalizeCostMeterState` lower-cases what it
119
+ * stores): the cost-meter table is a foreign schema whose provider ids are
120
+ * whatever its own user typed, and a casing mismatch must not silently drop
121
+ * the call through to bundled pricing.
122
+ */
123
+ function costMeterProviderRates(table, provider, model) {
124
+ const providers = isPlainObject(table.providers) ? table.providers : null
125
+ const key = typeof provider === 'string' ? provider.toLowerCase() : provider
126
+ const providerEntry = providers === null ? undefined : providers[key]
127
+ const flat = isPlainObject(providerEntry) ? providerEntry[model] : undefined
128
+ return isRateTriple(flat) ? flat : null
129
+ }
130
+
131
+ /**
132
+ * Resolve the price source and rates for one call. Resolution order:
133
+ * (1) `table.models[model]` when the route is official → `costMeter`, tier
134
+ * from `tierAt` using the table's own windows/effectiveAt/peakEnabled;
135
+ * (2) `table.providers[provider][model]` → `costMeter`, `tier:'flat'`;
136
+ * (3) the bundled list price, when the route is official and the model is
137
+ * known → `bundled`;
138
+ * (4) `null`.
139
+ *
140
+ * Always returns fresh copies of `rates` — never a reference into
141
+ * `BUNDLED_PRICES` or `table`.
142
+ */
143
+ export function ratesFor({ model, provider, atMs, table = null }) {
144
+ if (isPlainObject(table)) {
145
+ if (isOfficialRoute(provider)) {
146
+ const found = costMeterModelRates(table, model, atMs)
147
+ if (found !== null) {
148
+ return {
149
+ source: 'costMeter',
150
+ tier: found.tier,
151
+ rates: { ...found.rates },
152
+ asOf: typeof table.asOf === 'string' ? table.asOf : PRICES_AS_OF,
153
+ }
154
+ }
155
+ }
156
+ const flat = costMeterProviderRates(table, provider, model)
157
+ if (flat !== null) {
158
+ return {
159
+ source: 'costMeter',
160
+ tier: 'flat',
161
+ rates: { ...flat },
162
+ asOf: typeof table.asOf === 'string' ? table.asOf : PRICES_AS_OF,
163
+ }
164
+ }
165
+ }
166
+
167
+ if (isOfficialRoute(provider) && Object.prototype.hasOwnProperty.call(BUNDLED_PRICES, model)) {
168
+ const tier = tierAt(atMs)
169
+ return {
170
+ source: 'bundled',
171
+ tier,
172
+ rates: { ...BUNDLED_PRICES[model][tier] },
173
+ asOf: PRICES_AS_OF,
174
+ }
175
+ }
176
+
177
+ return null
178
+ }
179
+
180
+ /** Read a usage field as a non-negative finite number, defaulting to 0. */
181
+ function tokensOf(usage, key) {
182
+ const value = usage === null || typeof usage !== 'object' ? undefined : usage[key]
183
+ return isFiniteNumber(value) && value >= 0 ? value : 0
184
+ }
185
+
186
+ /**
187
+ * USD cost of `usage` at `rates`. `inputTokens` is uncached input, billed at
188
+ * `cacheMiss`; `cacheReadTokens` + `cacheWriteTokens` bill at `cacheHit`;
189
+ * `reasoningTokens` is added to output only if {@link REASONING_BILLED_SEPARATELY}.
190
+ */
191
+ export function costOf(usage, rates) {
192
+ const input = tokensOf(usage, 'inputTokens')
193
+ const output = tokensOf(usage, 'outputTokens')
194
+ const cacheRead = tokensOf(usage, 'cacheReadTokens')
195
+ const cacheWrite = tokensOf(usage, 'cacheWriteTokens')
196
+ const reasoning = tokensOf(usage, 'reasoningTokens')
197
+ const billedOutput = REASONING_BILLED_SEPARATELY ? output + reasoning : output
198
+ const cacheHitTokens = cacheRead + cacheWrite
199
+
200
+ const cacheHit = isFiniteNumber(rates?.cacheHit) ? rates.cacheHit : 0
201
+ const cacheMiss = isFiniteNumber(rates?.cacheMiss) ? rates.cacheMiss : 0
202
+ const outputRate = isFiniteNumber(rates?.output) ? rates.output : 0
203
+
204
+ return (input * cacheMiss + cacheHitTokens * cacheHit + billedOutput * outputRate) / 1e6
205
+ }
206
+
207
+ /** Price one call end to end: resolve rates via {@link ratesFor}, then cost via {@link costOf}. */
208
+ export function priceCall({ model, provider, atMs, usage, table = null }) {
209
+ const priced = ratesFor({ model, provider, atMs, table })
210
+ if (priced === null) return null
211
+ return { ...priced, usd: costOf(usage, priced.rates) }
212
+ }
213
+
214
+ // ── costMeter state normalization ───────────────────────────────────────
215
+
216
+ const DEFAULT_EXCHANGE_RATE = 7.2
217
+
218
+ /** A `{cacheHit, cacheMiss, output}` triple, currency-converted; `null` if any rate is invalid. */
219
+ function normalizeTriple(triple, currency, exchangeRate) {
220
+ if (!isRateTriple(triple) || triple.cacheHit < 0 || triple.cacheMiss < 0 || triple.output < 0) return null
221
+ const divisor = currency === 'CNY' ? (isPositiveNumber(exchangeRate) ? exchangeRate : DEFAULT_EXCHANGE_RATE) : 1
222
+ return { cacheHit: triple.cacheHit / divisor, cacheMiss: triple.cacheMiss / divisor, output: triple.output / divisor }
223
+ }
224
+
225
+ /** A model price entry (`{cacheHit,cacheMiss,output}` or `{offPeak,peak}`) → `{offPeak, peak}`, or `null`. */
226
+ function normalizeModelEntry(entry, currency, exchangeRate) {
227
+ if (!isPlainObject(entry)) return null
228
+ if (entry.offPeak !== undefined || entry.peak !== undefined) {
229
+ const offPeak = normalizeTriple(entry.offPeak, currency, exchangeRate)
230
+ const peak = normalizeTriple(entry.peak, currency, exchangeRate)
231
+ return offPeak === null || peak === null ? null : { offPeak, peak }
232
+ }
233
+ const flat = normalizeTriple(entry, currency, exchangeRate)
234
+ return flat === null ? null : { offPeak: flat, peak: { ...flat } }
235
+ }
236
+
237
+ /** A provider price entry `{input, cachedInput?, output}` → `{cacheMiss, cacheHit, output}`, or `null`. */
238
+ function normalizeProviderModelEntry(entry, currency, exchangeRate) {
239
+ if (!isPlainObject(entry)) return null
240
+ const cachedInput = entry.cachedInput !== undefined ? entry.cachedInput : entry.input
241
+ return normalizeTriple({ cacheMiss: entry.input, cacheHit: cachedInput, output: entry.output }, currency, exchangeRate)
242
+ }
243
+
244
+ /**
245
+ * Duck-typed normalization of the `dsh-cost-meter` service state (or its
246
+ * `config` sub-object) into the shape {@link ratesFor} consumes:
247
+ * `{models, providers, windows, effectiveAtMs, peakEnabled, asOf}`.
248
+ * Invalid rates (non-finite or negative) drop the entry that carries them;
249
+ * a non-object input (or config root) yields `null`.
250
+ */
251
+ /** Epoch ms from either a number or an ISO-ish date string; 0 when it is neither. */
252
+ function normalizeMoment(value) {
253
+ if (isFiniteNumber(value)) return value
254
+ if (typeof value !== 'string' || value.length === 0) return 0
255
+ const parsed = Date.parse(value)
256
+ return Number.isFinite(parsed) ? parsed : 0
257
+ }
258
+
259
+ export function normalizeCostMeterState(state) {
260
+ if (!isPlainObject(state)) return null
261
+ const config = isPlainObject(state.config) ? state.config : state
262
+ if (!isPlainObject(config)) return null
263
+
264
+ const prices = isPlainObject(config.prices) ? config.prices : {}
265
+ const currency = prices.currency === 'CNY' ? 'CNY' : 'USD'
266
+ const exchangeRate = isPositiveNumber(config.exchangeRate) ? config.exchangeRate : DEFAULT_EXCHANGE_RATE
267
+
268
+ const models = {}
269
+ const rawModels = isPlainObject(prices.models) ? prices.models : {}
270
+ for (const [id, entry] of Object.entries(rawModels)) {
271
+ const normalized = normalizeModelEntry(entry, currency, exchangeRate)
272
+ if (normalized !== null) models[id] = normalized
273
+ }
274
+
275
+ const providers = {}
276
+ const rawProviders = isPlainObject(prices.providers) ? prices.providers : {}
277
+ for (const [providerId, providerEntry] of Object.entries(rawProviders)) {
278
+ if (!isPlainObject(providerEntry)) continue
279
+ const rawProviderModels = isPlainObject(providerEntry.models) ? providerEntry.models : {}
280
+ const byModel = {}
281
+ for (const [modelId, entry] of Object.entries(rawProviderModels)) {
282
+ const normalized = normalizeProviderModelEntry(entry, currency, exchangeRate)
283
+ if (normalized !== null) byModel[modelId] = normalized
284
+ }
285
+ // Lower-cased so a lookup can match whatever case the caller reports; two
286
+ // keys differing only by case merge, and the first one listed wins.
287
+ const key = String(providerId).toLowerCase()
288
+ if (Object.keys(byModel).length > 0 && providers[key] === undefined) providers[key] = byModel
289
+ }
290
+
291
+ // An array that filters down to nothing says as little as no array at all —
292
+ // and an empty window list would silently price every call off-peak.
293
+ const rawWindows = Array.isArray(config.peakWindows)
294
+ ? config.peakWindows.filter((w) => isPlainObject(w) && isFiniteNumber(w.start) && isFiniteNumber(w.end))
295
+ : []
296
+ const windows = rawWindows.length > 0
297
+ ? rawWindows.map((w) => ({ start: w.start, end: w.end }))
298
+ : PEAK_WINDOWS_UTC.map((w) => ({ ...w }))
299
+
300
+ const effectiveAtMs = normalizeMoment(config.peakEffectiveAt)
301
+ const peakEnabled = typeof config.peakEnabled === 'boolean' ? config.peakEnabled : true
302
+
303
+ return {
304
+ models,
305
+ providers,
306
+ windows,
307
+ effectiveAtMs,
308
+ peakEnabled,
309
+ asOf: new Date().toISOString(),
310
+ }
311
+ }
package/lib/routes.js CHANGED
@@ -10,7 +10,14 @@
10
10
  * into one ctx.effect so a fiber unload removes every route.
11
11
  */
12
12
 
13
- function withService(ctx, serviceName, fn) {
13
+ /**
14
+ * Run `fn(service)` as soon as `serviceName` exists — immediately when it is
15
+ * already registered, otherwise once on the next `internal/service` event for
16
+ * it (the listener removes itself). Exported so the service layer can wait on
17
+ * optional siblings (e.g. `costMeter`) the same way the routes wait on
18
+ * `webServer`.
19
+ */
20
+ export function withService(ctx, serviceName, fn) {
14
21
  const existing = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined
15
22
  if (existing !== undefined && existing !== null) {
16
23
  fn(existing)
@@ -130,14 +137,20 @@ export function registerWebRoutes(ctx, service) {
130
137
  route('POST', '/api/tacit/reports', (body) => service.getReports(body))
131
138
  route('POST', '/api/tacit/history', (body) => service.listHistory(body))
132
139
  route('POST', '/api/tacit/analyze', (body) => service.analyzeTurn(body))
140
+ route('POST', '/api/tacit/analyze-batch', (body) => service.analyzeBatch(body))
133
141
  route('POST', '/api/tacit/improve', (body) => service.improveDraft(body))
134
142
  route('POST', '/api/tacit/feedback', (body) => service.feedback(body))
135
143
  route('POST', '/api/tacit/applied', (body) => service.applied(body))
136
144
  route('POST', '/api/tacit/directives', (body) => service.directives(body))
137
145
  route('POST', '/api/tacit/stats', (body) => service.stats(body))
138
146
  route('POST', '/api/tacit/bootstrap', (body) => service.bootstrap(body))
147
+ route('POST', '/api/tacit/bootstrap-preview', (body) => service.bootstrapPreview(body))
139
148
  route('POST', '/api/tacit/config', (body) => service.updateConfig(body))
140
149
  route('POST', '/api/tacit/clear', () => service.clearReports())
150
+ route('POST', '/api/tacit/usage', (body) => service.usageReport(body))
151
+ route('POST', '/api/tacit/usage-run', (body) => service.usageRun(body))
152
+ route('POST', '/api/tacit/usage-clear', () => service.usageClear())
153
+ route('POST', '/api/tacit/pricing-refresh', () => service.pricingRefresh())
141
154
 
142
155
  ctx.effect(() => () => {
143
156
  for (const dispose of disposers.splice(0).reverse()) {
package/lib/schema.js CHANGED
@@ -21,6 +21,16 @@ export const COACH_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
21
21
  */
22
22
  export const COACH_PROVIDER = 'deepseek-official'
23
23
 
24
+ /**
25
+ * The model-call failure codes Tacit is allowed to put on the wire. The client
26
+ * renders `err.<code>` for whatever the envelope carries, so a raw provider
27
+ * code (`RATE_LIMIT`, `ERROR`, `ABORTED`, …) would surface as a literal
28
+ * `err.ABORTED` banner — `coachErrorCode` (`lib/service.js`) maps anything
29
+ * outside this list onto one of these. Every entry has an `err.*` key in both
30
+ * dictionaries (`client/src/10-i18n.js`, test-enforced).
31
+ */
32
+ export const COACH_ERROR_CODES = ['no-llm', 'no-api-key', 'no-credit', 'rate-limited', 'timeout', 'empty-response', 'call-failed']
33
+
24
34
  /**
25
35
  * The loader-facing plugin config. Wrapped in `z.preprocess` so a patch row
26
36
  * without a `config:` block (`undefined`) resolves to all defaults — a bare
@@ -63,6 +73,12 @@ export const Config = z.preprocess((v) => v ?? {}, z.object({
63
73
  bootstrapConcurrency: z.number().default(1),
64
74
  /** Also learn from a clean turn that follows a messy one (what the user included the second time). Automatic, capped. */
65
75
  learnFromGood: z.boolean().default(true),
76
+ /** Days of detailed usage-ledger day files kept before they expire (7-365, clamped in mergeConfig). */
77
+ costHistoryDays: z.number().default(30),
78
+ /** Daily USD spend that triggers the warn/exceeded cost UI; 0 disables (clamped in mergeConfig). */
79
+ costWarnDailyUsd: z.number().default(0),
80
+ /** Same as `costWarnDailyUsd`, over a calendar month; 0 disables (clamped in mergeConfig). */
81
+ costWarnMonthlyUsd: z.number().default(0),
66
82
  }))
67
83
 
68
84
  /**
@@ -88,6 +104,9 @@ const configPatchSchema = z.object({
88
104
  directiveWorseBy: z.number().optional(),
89
105
  bootstrapConcurrency: z.number().optional(),
90
106
  learnFromGood: z.boolean().optional(),
107
+ costHistoryDays: z.number().optional(),
108
+ costWarnDailyUsd: z.number().optional(),
109
+ costWarnMonthlyUsd: z.number().optional(),
91
110
  })
92
111
 
93
112
  // ── Trajectory projection ──────────────────────────────────────────────────
@@ -213,15 +232,23 @@ const feedbackEntrySchema = z.object({
213
232
  * system prompt). `distilled` entries come from analyses; `user` entries are
214
233
  * typed in Settings and survive every distillation.
215
234
  */
216
- const directiveTrialSchema = z.object({
217
- /** Finished turns observed while the candidate was injected. */
218
- turns: z.number().int().min(0),
219
- /** How many of those were messy. */
220
- messy: z.number().int().min(0),
221
- /** Messy-turn rate over the 20 turns before the trial started. */
222
- baselineRate: z.number(),
223
- startedAt: z.number(),
224
- })
235
+ const directiveTrialSchema = z.preprocess(
236
+ // Profiles written before corrections were graded named the messy baseline `baselineRate`.
237
+ (raw) => (raw !== null && typeof raw === 'object' && 'baselineRate' in raw && !('baselineMessyRate' in raw) ? { ...raw, baselineMessyRate: raw.baselineRate } : raw),
238
+ z.object({
239
+ /** Finished turns observed while the candidate was injected. */
240
+ turns: z.number().int().min(0),
241
+ /** How many of those were messy. */
242
+ messy: z.number().int().min(0),
243
+ /** How many of those the user corrected with their next message. */
244
+ corrected: z.number().int().min(0).default(0),
245
+ /** Messy-turn rate over the 20 turns before the trial started. */
246
+ baselineMessyRate: z.number(),
247
+ /** Correction rate over those 20 turns; -1 = unknown (older profile), recomputed when the trial next advances. */
248
+ baselineCorrectionRate: z.number().default(-1),
249
+ startedAt: z.number(),
250
+ }),
251
+ )
225
252
 
226
253
  const directiveSchema = z.object({
227
254
  id: z.string(),
@@ -229,8 +256,8 @@ const directiveSchema = z.object({
229
256
  enabled: z.boolean().default(true),
230
257
  source: z.enum(['distilled', 'user']).default('distilled'),
231
258
  createdAt: z.number(),
232
- /** candidate = injected on trial; active = proven (or user-made); retired = made things worse. */
233
- status: z.enum(['candidate', 'active', 'retired']).default('active'),
259
+ /** queued = waiting for its scope's trial slot; candidate = injected on trial; active = proven (or user-made); retired = made things worse. */
260
+ status: z.enum(['queued', 'candidate', 'active', 'retired']).default('active'),
234
261
  trial: directiveTrialSchema.optional(),
235
262
  retiredReason: z.string().optional(),
236
263
  /** Absolute workspace directory this directive is limited to; absent = every conversation. */
@@ -288,6 +315,12 @@ export const analyzeArgSchema = z.object({
288
315
  turn: z.number().int().min(1),
289
316
  })
290
317
 
318
+ /** `/api/tacit/analyze-batch`: one session, up to 50 turns analyzed under a single run. */
319
+ export const analyzeBatchArgSchema = z.object({
320
+ sessionId: z.string().min(1).max(200),
321
+ turns: z.array(z.number().int().min(1)).min(1).max(50),
322
+ })
323
+
291
324
  export const improveArgSchema = z.object({
292
325
  sessionId: z.string().min(1).max(200),
293
326
  draft: z.string().min(1).max(100000),
@@ -308,3 +341,154 @@ export const appliedArgSchema = z.object({
308
341
  export const configArgSchema = z.object({
309
342
  patch: configPatchSchema,
310
343
  })
344
+
345
+ // ── Usage ledger (content-free: no prompts, no responses, no tool args) ────
346
+
347
+ /** Every op a metered model call can be tagged with (Task 1's sink + the distillation/enrichment calls). */
348
+ export const USAGE_OPS = [
349
+ 'analysis',
350
+ 'analysis-repair',
351
+ 'directive-distillation',
352
+ 'style-distillation',
353
+ 'improve',
354
+ 'improve-repair',
355
+ 'enrichment',
356
+ ]
357
+
358
+ /** Every state one recorded attempt can end in. */
359
+ export const USAGE_ATTEMPT_STATUSES = ['ok', 'failed', 'unmetered']
360
+
361
+ /** Every state a run can be in. `running` is written too: the flush persists live runs. */
362
+ export const USAGE_RUN_STATUSES = ['running', 'success', 'partial', 'failed']
363
+
364
+ /** Every kind of run the tracker groups attempts into. */
365
+ export const USAGE_RUN_TYPES = [
366
+ 'bootstrap',
367
+ 'analysis',
368
+ 'analysis-batch',
369
+ 'improve',
370
+ 'directive-distillation',
371
+ 'style-distillation',
372
+ 'prompt-enrichment',
373
+ ]
374
+
375
+ /** Raw token counts, zero-filled so totals can be summed without null checks. */
376
+ export const tokenBucketsSchema = z.object({
377
+ inputTokens: z.number().default(0),
378
+ outputTokens: z.number().default(0),
379
+ cacheReadTokens: z.number().default(0),
380
+ cacheWriteTokens: z.number().default(0),
381
+ reasoningTokens: z.number().default(0),
382
+ })
383
+
384
+ /**
385
+ * One metered model call. Mirrors the sink record `callCoachModel` hands the
386
+ * tracker (`startedAt`..`usage`) plus the identity fields the tracker itself
387
+ * assigns (`id`, `op`, `sessionId`, `turn`) and the priced result. Never
388
+ * carries prompt/response text, tool args, or API keys.
389
+ */
390
+ export const usageAttemptSchema = z.object({
391
+ id: z.string(),
392
+ op: z.enum(USAGE_OPS),
393
+ startedAt: z.number(),
394
+ durationMs: z.number().default(0),
395
+ model: z.string().default(''),
396
+ provider: z.string().default(''),
397
+ reasoningEffort: z.string().nullable().default(null),
398
+ finish: z.string().default(''),
399
+ status: z.enum(USAGE_ATTEMPT_STATUSES),
400
+ code: z.string().default(''),
401
+ sessionId: z.string().default(''),
402
+ turn: z.number().nullable().default(null),
403
+ usage: tokenBucketsSchema.nullable().default(null),
404
+ /** null when no price table matched the route/model (e.g. a proxy provider). */
405
+ priced: z.object({
406
+ source: z.enum(['bundled', 'costMeter']),
407
+ tier: z.string(),
408
+ rates: z.object({ cacheHit: z.number(), cacheMiss: z.number(), output: z.number() }),
409
+ asOf: z.string(),
410
+ usd: z.number(),
411
+ }).nullable().default(null),
412
+ })
413
+
414
+ /**
415
+ * A precomputed, already-defaulted instance of a nested object schema.
416
+ * zod's `.default(value)` injects `value` verbatim when a field is absent —
417
+ * it does NOT re-run `value` through the schema — so a literal `{}` default
418
+ * on a nested object would skip that object's own field defaults. Passing
419
+ * `schema.parse({})` instead gives the same "all defaults" shape correctly.
420
+ */
421
+ const emptyTokenBuckets = tokenBucketsSchema.parse({})
422
+
423
+ /** Aggregate counters shared by a run's totals, the lifetime summary, and every summary bucket. */
424
+ export const usageTotalsSchema = z.object({
425
+ attempts: z.number().default(0),
426
+ billedCalls: z.number().default(0),
427
+ unmeteredCalls: z.number().default(0),
428
+ unpricedCalls: z.number().default(0),
429
+ tokens: tokenBucketsSchema.default(emptyTokenBuckets),
430
+ usdKnown: z.number().default(0),
431
+ })
432
+
433
+ const emptyUsageTotals = usageTotalsSchema.parse({})
434
+
435
+ /** One tracker run: a group of attempts sharing a trigger (a single call, an auto-analysis, a bootstrap batch, ...). */
436
+ export const usageRunSchema = z.object({
437
+ runId: z.string(),
438
+ type: z.enum(USAGE_RUN_TYPES),
439
+ trigger: z.string().default(''),
440
+ startedAt: z.number(),
441
+ endedAt: z.number().default(0),
442
+ status: z.enum(USAGE_RUN_STATUSES).default('running'),
443
+ sessionId: z.string().default(''),
444
+ turn: z.number().nullable().default(null),
445
+ workspace: z.string().default(''),
446
+ model: z.string().default(''),
447
+ provider: z.string().default(''),
448
+ results: z.record(z.number()).default({}),
449
+ attempts: z.array(usageAttemptSchema).default([]),
450
+ totals: usageTotalsSchema.default(emptyUsageTotals),
451
+ })
452
+
453
+ /** One day's `usage/YYYY-MM-DD.json` file. */
454
+ export const usageDayFileSchema = z.object({
455
+ version: z.literal(1),
456
+ day: z.string(),
457
+ runs: z.array(usageRunSchema).default([]),
458
+ })
459
+
460
+ const usageDayTotalsSchema = usageTotalsSchema.extend({
461
+ byType: z.record(usageTotalsSchema).default({}),
462
+ })
463
+
464
+ /** `usage/summary.json`: rolling totals kept alongside the day files so reports never have to re-scan every day. */
465
+ export const usageSummarySchema = z.object({
466
+ version: z.literal(1),
467
+ trackingSince: z.number(),
468
+ lifetime: usageTotalsSchema.default(emptyUsageTotals),
469
+ byType: z.record(usageTotalsSchema).default({}),
470
+ byModel: z.record(usageTotalsSchema).default({}),
471
+ days: z.record(usageDayTotalsSchema).default({}),
472
+ })
473
+
474
+ /**
475
+ * Arguments for `/api/tacit/usage`. Declared here (not next to
476
+ * `bootstrapArgSchema`) because `z.enum(USAGE_RUN_TYPES)` needs the run-type
477
+ * list above it. Every field is optional on the wire; `tracker.report()`
478
+ * applies the defaults (`range: '30d'`, `page: 1`, `pageSize: 20`).
479
+ */
480
+ export const usageArgSchema = z.object({
481
+ range: z.enum(['today', '7d', '30d', 'month', 'all']).optional(),
482
+ type: z.enum(USAGE_RUN_TYPES).optional(),
483
+ status: z.enum(USAGE_RUN_STATUSES).optional(),
484
+ model: z.string().max(64).optional(),
485
+ workspace: z.string().max(200).optional(),
486
+ sessionId: z.string().max(200).optional(),
487
+ page: z.number().int().min(1).max(1000).optional(),
488
+ pageSize: z.number().int().min(1).max(100).optional(),
489
+ })
490
+
491
+ /** Arguments for `/api/tacit/usage-run`: one run id, as minted by `beginRun`. */
492
+ export const usageRunArgSchema = z.object({
493
+ runId: z.string().min(1).max(64),
494
+ })