dsh-tacit 0.2.2 → 0.3.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,133 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ /**
4
+ * dsh-tacit — the price table behind the usage tracker.
5
+ *
6
+ * Wraps the pure `lib/pricing.js` with one optional input: the sibling
7
+ * `dsh-cost-meter` plugin's `costMeter` service. When that service is
8
+ * installed and hands over a usable state, its prices win; otherwise the
9
+ * bundled DeepSeek list prices apply. The service is fully duck-typed and
10
+ * never trusted: `refresh()` never throws, never blocks longer than
11
+ * `timeoutMs`, and any failure (absent, throwing, hanging, junk) leaves the
12
+ * source on the bundled table with a human-readable `error`.
13
+ *
14
+ * A model call must never wait on this — the service refreshes it in the
15
+ * background and every `priceCall` reads whatever snapshot is current.
16
+ */
17
+
18
+ import { priceCall as priceCallWith, normalizeCostMeterState, tierAt, PRICES_AS_OF, BUNDLED_PRICES } from './pricing.js'
19
+ import { COACH_MODELS } from './schema.js'
20
+
21
+ /** A `{cacheHit, cacheMiss, output}` triple as a fresh object (never a reference into a shared table). */
22
+ function copyTriple(triple) {
23
+ return { cacheHit: triple.cacheHit, cacheMiss: triple.cacheMiss, output: triple.output }
24
+ }
25
+
26
+ /** A normalized snapshot is only worth using if it actually carries a price. */
27
+ function hasPrices(snapshot) {
28
+ if (snapshot === null || typeof snapshot !== 'object') return false
29
+ const models = snapshot.models !== null && typeof snapshot.models === 'object' ? Object.keys(snapshot.models) : []
30
+ const providers = snapshot.providers !== null && typeof snapshot.providers === 'object' ? Object.keys(snapshot.providers) : []
31
+ return models.length > 0 || providers.length > 0
32
+ }
33
+
34
+ /** Whatever was thrown/rejected, as a short message. */
35
+ function messageOf(error) {
36
+ if (error !== null && typeof error === 'object' && typeof error.message === 'string' && error.message.length > 0) return error.message
37
+ return String(error)
38
+ }
39
+
40
+ /** Reject after `ms`; the timer is unref'd so a pending refresh never holds the process open. */
41
+ function rejectAfter(ms) {
42
+ let timer = null
43
+ const promise = new Promise((_resolve, reject) => {
44
+ timer = setTimeout(() => reject(new Error(`costMeter getState() timed out after ${ms}ms`)), ms)
45
+ if (typeof timer?.unref === 'function') timer.unref()
46
+ })
47
+ return { promise, cancel: () => { if (timer !== null) clearTimeout(timer) } }
48
+ }
49
+
50
+ /**
51
+ * The price source the tracker and the reports read from.
52
+ * `now`/`timeoutMs` are injectable so tests can drive the clock and the
53
+ * hang path without waiting five seconds.
54
+ */
55
+ export function createPricingSource(ctx, { now = Date.now, timeoutMs = 5000 } = {}) {
56
+ const state = { snapshot: null, source: 'bundled', refreshedAt: 0, error: '' }
57
+
58
+ /** Drop back to the bundled table, remembering why. */
59
+ function fallBack(error) {
60
+ state.snapshot = null
61
+ state.source = 'bundled'
62
+ state.refreshedAt = 0
63
+ state.error = error
64
+ }
65
+
66
+ async function refresh() {
67
+ const service = ctx !== null && typeof ctx === 'object' && typeof ctx.get === 'function' ? ctx.get('costMeter') : undefined
68
+ if (service === undefined || service === null || typeof service.getState !== 'function') {
69
+ fallBack('the costMeter service is not available — using bundled list prices')
70
+ return
71
+ }
72
+ const timeout = rejectAfter(timeoutMs)
73
+ let raw
74
+ try {
75
+ raw = await Promise.race([Promise.resolve(service.getState()), timeout.promise])
76
+ } catch (error) {
77
+ fallBack(messageOf(error))
78
+ return
79
+ } finally {
80
+ timeout.cancel()
81
+ }
82
+ const snapshot = normalizeCostMeterState(raw)
83
+ if (!hasPrices(snapshot)) {
84
+ fallBack('the costMeter state carried no usable prices — using bundled list prices')
85
+ return
86
+ }
87
+ state.snapshot = snapshot
88
+ state.source = 'costMeter'
89
+ state.refreshedAt = now()
90
+ state.error = ''
91
+ }
92
+
93
+ /** {@link priceCallWith} against the current snapshot (bundled when there is none). */
94
+ function priceCall(args) {
95
+ return priceCallWith({ ...args, table: state.snapshot })
96
+ }
97
+
98
+ /**
99
+ * What the Pricing card shows about the source itself. `tierNow` is read
100
+ * off the same snapshot `priceCall` prices against — a cost-meter table
101
+ * that turns peak pricing off, shifts the windows, or dates them into the
102
+ * future must not leave the card quoting the bundled schedule.
103
+ */
104
+ function status() {
105
+ const snapshot = state.snapshot
106
+ const tierNow = snapshot === null
107
+ ? tierAt(now())
108
+ : tierAt(now(), { windows: snapshot.windows, effectiveAtMs: snapshot.effectiveAtMs, peakEnabled: snapshot.peakEnabled !== false })
109
+ return {
110
+ source: state.source,
111
+ asOf: typeof snapshot?.asOf === 'string' ? snapshot.asOf : PRICES_AS_OF,
112
+ refreshedAt: state.refreshedAt,
113
+ tierNow,
114
+ error: state.error,
115
+ }
116
+ }
117
+
118
+ /** `{model: {offPeak, peak}}` for both coach models — snapshot first, bundled per model otherwise. */
119
+ function rates() {
120
+ const models = state.snapshot?.models
121
+ const out = {}
122
+ for (const model of COACH_MODELS) {
123
+ const entry = models !== null && typeof models === 'object' ? models[model] : undefined
124
+ const source = entry !== null && typeof entry === 'object' && entry.offPeak !== undefined && entry.peak !== undefined
125
+ ? entry
126
+ : BUNDLED_PRICES[model]
127
+ out[model] = { offPeak: copyTriple(source.offPeak), peak: copyTriple(source.peak) }
128
+ }
129
+ return out
130
+ }
131
+
132
+ return { refresh, priceCall, status, rates }
133
+ }
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)
@@ -126,18 +133,24 @@ export function registerWebRoutes(ctx, service) {
126
133
  }))
127
134
  }
128
135
 
129
- route('POST', '/api/tacit/state', () => service.getState())
136
+ route('POST', '/api/tacit/state', (body) => service.getState(body))
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()) {