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.
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
@@ -59,6 +69,16 @@ export const Config = z.preprocess((v) => v ?? {}, z.object({
59
69
  directiveTrialTurns: z.number().default(10),
60
70
  /** A candidate retires when the messy-turn rate during its trial exceeds the baseline by more than this. */
61
71
  directiveWorseBy: z.number().default(0.15),
72
+ /** Bootstrap analyses run at once (1 = one after another; same calls, less waiting). */
73
+ bootstrapConcurrency: z.number().default(1),
74
+ /** Also learn from a clean turn that follows a messy one (what the user included the second time). Automatic, capped. */
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),
62
82
  }))
63
83
 
64
84
  /**
@@ -82,6 +102,11 @@ const configPatchSchema = z.object({
82
102
  enrichPrompts: z.boolean().optional(),
83
103
  directiveTrialTurns: z.number().optional(),
84
104
  directiveWorseBy: z.number().optional(),
105
+ bootstrapConcurrency: z.number().optional(),
106
+ learnFromGood: z.boolean().optional(),
107
+ costHistoryDays: z.number().optional(),
108
+ costWarnDailyUsd: z.number().optional(),
109
+ costWarnMonthlyUsd: z.number().optional(),
85
110
  })
86
111
 
87
112
  // ── Trajectory projection ──────────────────────────────────────────────────
@@ -160,6 +185,12 @@ export const reportSchema = z.object({
160
185
  trigger: z.string().default('manual'),
161
186
  /** The user's next message when it triggered the analysis (clipped). */
162
187
  followUp: z.string().optional(),
188
+ /** Absolute workspace directory of the conversation, when the harness knew it. */
189
+ cwd: z.string().optional(),
190
+ /** trigger 'good' only: what the clean prompt supplied that the messy one before it lacked. */
191
+ strengths: z.array(z.object({ kind: z.string(), what: z.string() })).optional(),
192
+ /** trigger 'good' only: the one-sentence lesson fed to the distiller. */
193
+ lesson: z.string().optional(),
163
194
  })
164
195
 
165
196
  /**
@@ -178,6 +209,8 @@ export const patternCountersSchema = z.object({
178
209
  verified: z.number().int().default(0),
179
210
  /** Times the next turn's outcome was same/worse than the baseline. */
180
211
  unverified: z.number().int().default(0),
212
+ /** Times a clean prompt right after a messy turn showed the user supplying this themselves. */
213
+ resolved: z.number().int().default(0),
181
214
  })
182
215
 
183
216
  /** One distilled durable style rule (from rejected-improvement reasons). */
@@ -219,6 +252,8 @@ const directiveSchema = z.object({
219
252
  status: z.enum(['candidate', 'active', 'retired']).default('active'),
220
253
  trial: directiveTrialSchema.optional(),
221
254
  retiredReason: z.string().optional(),
255
+ /** Absolute workspace directory this directive is limited to; absent = every conversation. */
256
+ workspace: z.string().optional(),
222
257
  })
223
258
 
224
259
  /** The persistent user-wide mistake profile. */
@@ -257,7 +292,7 @@ export const statsArgSchema = z.object({
257
292
 
258
293
  export const directivesArgSchema = z.discriminatedUnion('action', [
259
294
  z.object({ action: z.literal('toggle'), id: z.string().min(1).max(64), enabled: z.boolean() }),
260
- z.object({ action: z.literal('add'), text: z.string().min(1).max(300) }),
295
+ z.object({ action: z.literal('add'), text: z.string().min(1).max(300), workspace: z.string().max(1000).optional() }),
261
296
  z.object({ action: z.literal('remove'), id: z.string().min(1).max(64) }),
262
297
  ])
263
298
 
@@ -272,6 +307,12 @@ export const analyzeArgSchema = z.object({
272
307
  turn: z.number().int().min(1),
273
308
  })
274
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
+
275
316
  export const improveArgSchema = z.object({
276
317
  sessionId: z.string().min(1).max(200),
277
318
  draft: z.string().min(1).max(100000),
@@ -292,3 +333,148 @@ export const appliedArgSchema = z.object({
292
333
  export const configArgSchema = z.object({
293
334
  patch: configPatchSchema,
294
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
+ })