dsh-tacit 0.2.3 → 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.
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', '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 ──────────────────────────────────────────────────
@@ -288,6 +307,12 @@ export const analyzeArgSchema = z.object({
288
307
  turn: z.number().int().min(1),
289
308
  })
290
309
 
310
+ /** `/api/tacit/analyze-batch`: one session, up to 50 turns analyzed under a single run. */
311
+ export const analyzeBatchArgSchema = z.object({
312
+ sessionId: z.string().min(1).max(200),
313
+ turns: z.array(z.number().int().min(1)).min(1).max(50),
314
+ })
315
+
291
316
  export const improveArgSchema = z.object({
292
317
  sessionId: z.string().min(1).max(200),
293
318
  draft: z.string().min(1).max(100000),
@@ -308,3 +333,148 @@ export const appliedArgSchema = z.object({
308
333
  export const configArgSchema = z.object({
309
334
  patch: configPatchSchema,
310
335
  })
336
+
337
+ // ── Usage ledger (content-free: no prompts, no responses, no tool args) ────
338
+
339
+ /** Every op a metered model call can be tagged with (Task 1's sink + the distillation/enrichment calls). */
340
+ export const USAGE_OPS = [
341
+ 'analysis',
342
+ 'analysis-repair',
343
+ 'directive-distillation',
344
+ 'style-distillation',
345
+ 'improve',
346
+ 'improve-repair',
347
+ 'enrichment',
348
+ ]
349
+
350
+ /** Every kind of run the tracker groups attempts into. */
351
+ export const USAGE_RUN_TYPES = [
352
+ 'bootstrap',
353
+ 'analysis',
354
+ 'analysis-batch',
355
+ 'improve',
356
+ 'directive-distillation',
357
+ 'style-distillation',
358
+ 'prompt-enrichment',
359
+ ]
360
+
361
+ /** Raw token counts, zero-filled so totals can be summed without null checks. */
362
+ export const tokenBucketsSchema = z.object({
363
+ inputTokens: z.number().default(0),
364
+ outputTokens: z.number().default(0),
365
+ cacheReadTokens: z.number().default(0),
366
+ cacheWriteTokens: z.number().default(0),
367
+ reasoningTokens: z.number().default(0),
368
+ })
369
+
370
+ /**
371
+ * One metered model call. Mirrors the sink record `callCoachModel` hands the
372
+ * tracker (`startedAt`..`usage`) plus the identity fields the tracker itself
373
+ * assigns (`id`, `op`, `sessionId`, `turn`) and the priced result. Never
374
+ * carries prompt/response text, tool args, or API keys.
375
+ */
376
+ export const usageAttemptSchema = z.object({
377
+ id: z.string(),
378
+ op: z.enum(USAGE_OPS),
379
+ startedAt: z.number(),
380
+ durationMs: z.number().default(0),
381
+ model: z.string().default(''),
382
+ provider: z.string().default(''),
383
+ reasoningEffort: z.string().nullable().default(null),
384
+ finish: z.string().default(''),
385
+ status: z.enum(['ok', 'failed', 'unmetered']),
386
+ code: z.string().default(''),
387
+ sessionId: z.string().default(''),
388
+ turn: z.number().nullable().default(null),
389
+ usage: tokenBucketsSchema.nullable().default(null),
390
+ /** null when no price table matched the route/model (e.g. a proxy provider). */
391
+ priced: z.object({
392
+ source: z.enum(['bundled', 'costMeter']),
393
+ tier: z.string(),
394
+ rates: z.object({ cacheHit: z.number(), cacheMiss: z.number(), output: z.number() }),
395
+ asOf: z.string(),
396
+ usd: z.number(),
397
+ }).nullable().default(null),
398
+ })
399
+
400
+ /**
401
+ * A precomputed, already-defaulted instance of a nested object schema.
402
+ * zod's `.default(value)` injects `value` verbatim when a field is absent —
403
+ * it does NOT re-run `value` through the schema — so a literal `{}` default
404
+ * on a nested object would skip that object's own field defaults. Passing
405
+ * `schema.parse({})` instead gives the same "all defaults" shape correctly.
406
+ */
407
+ const emptyTokenBuckets = tokenBucketsSchema.parse({})
408
+
409
+ /** Aggregate counters shared by a run's totals, the lifetime summary, and every summary bucket. */
410
+ export const usageTotalsSchema = z.object({
411
+ attempts: z.number().default(0),
412
+ billedCalls: z.number().default(0),
413
+ unmeteredCalls: z.number().default(0),
414
+ unpricedCalls: z.number().default(0),
415
+ tokens: tokenBucketsSchema.default(emptyTokenBuckets),
416
+ usdKnown: z.number().default(0),
417
+ })
418
+
419
+ const emptyUsageTotals = usageTotalsSchema.parse({})
420
+
421
+ /** One tracker run: a group of attempts sharing a trigger (a single call, an auto-analysis, a bootstrap batch, ...). */
422
+ export const usageRunSchema = z.object({
423
+ runId: z.string(),
424
+ type: z.enum(USAGE_RUN_TYPES),
425
+ trigger: z.string().default(''),
426
+ startedAt: z.number(),
427
+ endedAt: z.number().default(0),
428
+ status: z.enum(['running', 'success', 'partial', 'failed']).default('running'),
429
+ sessionId: z.string().default(''),
430
+ turn: z.number().nullable().default(null),
431
+ workspace: z.string().default(''),
432
+ model: z.string().default(''),
433
+ provider: z.string().default(''),
434
+ results: z.record(z.number()).default({}),
435
+ attempts: z.array(usageAttemptSchema).default([]),
436
+ totals: usageTotalsSchema.default(emptyUsageTotals),
437
+ })
438
+
439
+ /** One day's `usage/YYYY-MM-DD.json` file. */
440
+ export const usageDayFileSchema = z.object({
441
+ version: z.literal(1),
442
+ day: z.string(),
443
+ runs: z.array(usageRunSchema).default([]),
444
+ })
445
+
446
+ const usageDayTotalsSchema = usageTotalsSchema.extend({
447
+ byType: z.record(usageTotalsSchema).default({}),
448
+ })
449
+
450
+ /** `usage/summary.json`: rolling totals kept alongside the day files so reports never have to re-scan every day. */
451
+ export const usageSummarySchema = z.object({
452
+ version: z.literal(1),
453
+ trackingSince: z.number(),
454
+ lifetime: usageTotalsSchema.default(emptyUsageTotals),
455
+ byType: z.record(usageTotalsSchema).default({}),
456
+ byModel: z.record(usageTotalsSchema).default({}),
457
+ days: z.record(usageDayTotalsSchema).default({}),
458
+ })
459
+
460
+ /**
461
+ * Arguments for `/api/tacit/usage`. Declared here (not next to
462
+ * `bootstrapArgSchema`) because `z.enum(USAGE_RUN_TYPES)` needs the run-type
463
+ * list above it. Every field is optional on the wire; `tracker.report()`
464
+ * applies the defaults (`range: '30d'`, `page: 1`, `pageSize: 20`).
465
+ */
466
+ export const usageArgSchema = z.object({
467
+ range: z.enum(['today', '7d', '30d', 'month', 'all']).optional(),
468
+ type: z.enum(USAGE_RUN_TYPES).optional(),
469
+ status: z.enum(['success', 'partial', 'failed']).optional(),
470
+ model: z.string().max(64).optional(),
471
+ workspace: z.string().max(200).optional(),
472
+ sessionId: z.string().max(200).optional(),
473
+ page: z.number().int().min(1).max(1000).optional(),
474
+ pageSize: z.number().int().min(1).max(100).optional(),
475
+ })
476
+
477
+ /** Arguments for `/api/tacit/usage-run`: one run id, as minted by `beginRun`. */
478
+ export const usageRunArgSchema = z.object({
479
+ runId: z.string().min(1).max(64),
480
+ })