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/service.js CHANGED
@@ -15,9 +15,11 @@
15
15
 
16
16
  import {
17
17
  Config,
18
+ COACH_ERROR_CODES,
18
19
  COACH_MODELS,
19
20
  COACH_PROVIDER,
20
21
  analyzeArgSchema,
22
+ analyzeBatchArgSchema,
21
23
  appliedArgSchema,
22
24
  configArgSchema,
23
25
  feedbackArgSchema,
@@ -28,7 +30,13 @@ import {
28
30
  directivesArgSchema,
29
31
  statsArgSchema,
30
32
  bootstrapArgSchema,
33
+ usageArgSchema,
34
+ usageRunArgSchema,
31
35
  } from './schema.js'
36
+ import { dayKey } from './store.js'
37
+ import { createUsageTracker, totalTokens } from './usage.js'
38
+ import { createPricingSource } from './pricing-source.js'
39
+ import { withService } from './routes.js'
32
40
  import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
33
41
  import { textOfBlocks } from './fold.js'
34
42
  import {
@@ -72,12 +80,13 @@ import {
72
80
  DIRECTIVE_TOOL,
73
81
  DIRECTIVE_MAX_TOKENS,
74
82
  DIRECTIVE_TIMEOUT_MS,
75
- MAX_DIRECTIVES,
76
83
  buildDirectiveUserText,
77
84
  buildSteeringSection,
78
85
  renderSteeringSection,
79
86
  workspaceLabel,
80
- MAX_WORKSPACE_DIRECTIVES,
87
+ scopeOf,
88
+ capDirectives,
89
+ mergeDirectives,
81
90
  ENRICH_SYSTEM_PROMPT,
82
91
  ENRICH_TOOL,
83
92
  ENRICH_MAX_TOKENS,
@@ -88,10 +97,18 @@ import {
88
97
  buildEnrichUserText,
89
98
  normalizeEnrichNote,
90
99
  computeTrend,
100
+ markCorrections,
91
101
  } from './analyze.js'
92
102
 
93
103
  /** In-memory rewrite ledger bounds (never persisted). */
94
104
  const MAX_REWRITE_RECORDS = 50
105
+ /**
106
+ * What one analysis costs per model when the ledger cannot say yet: the docs'
107
+ * $0.02–0.05 per 20 bootstrapped turns, scaled to a single analysis.
108
+ */
109
+ const DOC_ANALYSIS_USD = { 'deepseek-v4-flash': 0.0025, 'deepseek-v4-pro': 0.0075 }
110
+ /** Priced analysis attempts the ledger needs before an estimate follows it instead of the docs. */
111
+ const MEASURED_MIN_SAMPLES = 3
95
112
  /** Pending outcome verifications kept per session (FIFO, oldest dropped). */
96
113
  const MAX_PENDING_VERIFICATIONS = 20
97
114
 
@@ -119,6 +136,9 @@ export function mergeConfig(base, patch) {
119
136
  merged.directiveWorseBy = Math.max(0, Math.min(1, Number.isFinite(Number(merged.directiveWorseBy)) ? Number(merged.directiveWorseBy) : 0.15))
120
137
  merged.bootstrapConcurrency = Math.max(1, Math.min(4, Math.round(Number(merged.bootstrapConcurrency) || 1)))
121
138
  merged.learnFromGood = merged.learnFromGood !== false
139
+ merged.costHistoryDays = Math.max(7, Math.min(365, Math.round(Number(merged.costHistoryDays) || 30)))
140
+ merged.costWarnDailyUsd = Math.max(0, Math.min(10000, Number.isFinite(Number(merged.costWarnDailyUsd ?? 0)) ? Number(merged.costWarnDailyUsd ?? 0) : 0))
141
+ merged.costWarnMonthlyUsd = Math.max(0, Math.min(10000, Number.isFinite(Number(merged.costWarnMonthlyUsd ?? 0)) ? Number(merged.costWarnMonthlyUsd ?? 0) : 0))
122
142
  return merged
123
143
  }
124
144
 
@@ -167,21 +187,6 @@ function listWorkspaces(service) {
167
187
  return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label))
168
188
  }
169
189
 
170
- /** At most MAX_DIRECTIVES global directives and MAX_WORKSPACE_DIRECTIVES per workspace, order kept. */
171
- function capDirectives(list) {
172
- const counts = new Map()
173
- const out = []
174
- for (const entry of list) {
175
- const scope = typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : ''
176
- const limit = scope === '' ? MAX_DIRECTIVES : MAX_WORKSPACE_DIRECTIVES
177
- const n = counts.get(scope) ?? 0
178
- if (n >= limit) continue
179
- counts.set(scope, n + 1)
180
- out.push(entry)
181
- }
182
- return out
183
- }
184
-
185
190
  /** Short, secret-free context digest of a session's last two finished turns. */
186
191
  function recentContextOf(turns) {
187
192
  const finished = (Array.isArray(turns) ? turns : []).filter((turn) => turn?.finished === true).slice(-2)
@@ -229,23 +234,53 @@ function lastFinishedTurnOf(turns) {
229
234
  return finished.length > 0 ? finished[finished.length - 1] : null
230
235
  }
231
236
 
237
+ /**
238
+ * One of {@link COACH_ERROR_CODES} for whatever a model call threw.
239
+ *
240
+ * A code is passed through only when it is already one of Tacit's own: the
241
+ * runtime turns an adapter failure into a `finish {kind:'error'|'aborted'}`
242
+ * chunk and `callCoachModel` rethrows it carrying the provider's own code
243
+ * (or a synthesized `ERROR`/`ABORTED`), and the client renders `err.<code>`
244
+ * verbatim — so passing those through paints a literal `err.ABORTED` banner.
245
+ * Anything else is folded, code and message together, through the ladder.
246
+ * The attempt record keeps the raw code; only the envelope is normalized.
247
+ */
232
248
  function coachErrorCode(error) {
233
249
  const message = error instanceof Error ? error.message : String(error)
234
- if (error !== null && typeof error === 'object' && typeof error.code === 'string') return error.code
235
- if (/abort|aborted|timeout/i.test(message)) return 'timeout'
236
- if (/auth|401|403|api key|key not/i.test(message)) return 'no-api-key'
237
- if (/rate|429/i.test(message)) return 'rate-limited'
250
+ const raw = error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : ''
251
+ if (COACH_ERROR_CODES.includes(raw)) return raw
252
+ const text = raw + ' ' + message
253
+ if (/abort|timeout/i.test(text)) return 'timeout'
254
+ if (/auth|401|403|api[ _-]?key|key not/i.test(text)) return 'no-api-key'
255
+ // Ahead of the rate-limit rule: an exhausted balance says "quota" too, but
256
+ // it will not clear by waiting, so it must not read as "try again shortly".
257
+ if (/\binsufficient[ _-]?(quota|balance|credit)|\bexceeded your current quota/i.test(text)) return 'no-credit'
258
+ // Word-bounded: a bare /rate/ matches the "rate" inside "generate", and the
259
+ // ladder now sees the raw code and message of every provider failure. A
260
+ // trailing \b after "quota" would not do here — `_` is a word character, so
261
+ // it could never match `quota_exceeded`; the lookahead stops "quotation"
262
+ // instead, which is what the boundary was for.
263
+ if (/\brate[ _-]?limit|\bquota(?![a-z])|\b429\b|too[ _-]?many[ _-]?requests/i.test(text)) return 'rate-limited'
238
264
  return 'call-failed'
239
265
  }
240
266
 
241
- /** Local calendar day key for the daily auto budget. */
242
- function dayKey(now = Date.now()) {
243
- const date = new Date(now)
244
- const pad = (value) => String(value).padStart(2, '0')
245
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
246
- }
247
-
248
267
  export function createCoachService(ctx, store, effectiveConfig) {
268
+ /** Prices behind the ledger: the optional costMeter sibling when it appears, bundled list prices otherwise. */
269
+ const pricing = createPricingSource(ctx)
270
+ /** The run/attempt ledger. Synchronous by contract — never awaited in a model-call path. */
271
+ const usage = createUsageTracker({ store, config: effectiveConfig, pricing })
272
+ // Fire and forget, twice: once now, once if the sibling shows up later.
273
+ // No model call ever waits on it, and a failure only means bundled prices.
274
+ const refreshPrices = () => { pricing.refresh().catch(() => {}) }
275
+ refreshPrices()
276
+ withService(ctx, 'costMeter', refreshPrices)
277
+
278
+ /** One metered model call: the caller's options plus this run's usage sink. */
279
+ const metered = (runId, tag, options) => ({ ...options, onUsage: usage.attemptSink(runId, tag) })
280
+
281
+ /** End a run (idempotent) and hand back the summary its response envelope carries. */
282
+ const closeRun = (runId, results = {}, status) => usage.endRun(runId, { results, status }) ?? usage.runSummary(runId)
283
+
249
284
  const inFlight = new Map()
250
285
  /** Turns already handed to automatic analysis (sessionId:turn). */
251
286
  const autoSeen = new Set()
@@ -328,11 +363,15 @@ export function createCoachService(ctx, store, effectiveConfig) {
328
363
  */
329
364
  const maybeDistill = async (profile, provider, sessionId) => {
330
365
  if (profile.pendingDistill < 3 || distillInFlight) return profile
366
+ // Read the config BEFORE claiming the in-flight slot: a throw between the
367
+ // two would leave `distillInFlight` stuck true and kill every later
368
+ // distillation for the lifetime of the process.
369
+ const config = effectiveConfig()
370
+ const runId = usage.beginRun({ type: 'style-distillation', trigger: 'feedback', sessionId, model: config.model, provider })
331
371
  distillInFlight = true
332
372
  try {
333
- const config = effectiveConfig()
334
373
  const reasons = lastDownReasons(profile, 3)
335
- const text = await callCoachModel(ctx, {
374
+ const text = await callCoachModel(ctx, metered(runId, { op: 'style-distillation', sessionId }, {
336
375
  provider,
337
376
  model: config.model,
338
377
  system: DISTILL_SYSTEM_PROMPT,
@@ -341,7 +380,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
341
380
  timeoutMs: DISTILL_TIMEOUT_MS,
342
381
  tool: DISTILL_TOOL,
343
382
  sessionId,
344
- })
383
+ }))
345
384
  const rules = normalizeDistillRules(text)
346
385
  if (rules.length === 0) return profile
347
386
  const fresh = safeProfile()
@@ -351,6 +390,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
351
390
  } catch {
352
391
  return profile
353
392
  } finally {
393
+ closeRun(runId)
354
394
  distillInFlight = false
355
395
  }
356
396
  }
@@ -387,26 +427,32 @@ export function createCoachService(ctx, store, effectiveConfig) {
387
427
  handleVerification(sessionId, turns)
388
428
  }
389
429
 
390
- /** Finished turns already counted toward directive trials (sessionId:turn). */
430
+ /** Finished turns already counted toward directive trials, and turns already counted as corrected (sessionId:turn). */
391
431
  const seenFinished = new Set()
432
+ const seenCorrected = new Set()
392
433
 
393
434
  const pct = (rate) => String(Math.round(rate * 100)) + '%'
394
435
 
395
436
  /**
396
437
  * Directive trials ride the same free feed: every NEW finished turn counts
397
438
  * toward each candidate that was actually in that session's frozen steering
398
- * text; after `directiveTrialTurns` such turns the candidate is activated,
399
- * or retired when the messy rate rose past the baseline by more than
400
- * `directiveWorseBy`. A session whose steering was never assembled here
401
- * (started before the candidate existed, or before a restart) counts toward
402
- * nobody its turns say nothing about the candidate.
439
+ * text, and so does every correction of such a turn (the session's next
440
+ * prompt, known the moment it starts). After `directiveTrialTurns` turns the
441
+ * candidate is retired when its correction rate rose past the baseline by
442
+ * more than `directiveWorseBy` (or its messy rate by twice that), otherwise
443
+ * activated. A session whose steering was never assembled here (started
444
+ * before the candidate existed, or before a restart) counts toward nobody —
445
+ * its turns say nothing about the candidate.
403
446
  */
404
447
  const recordTrialTurns = (sessionId, turns) => {
405
- const fresh = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object'
406
- && turn.finished === true && typeof turn.turn === 'number' && typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt
407
- && !seenFinished.has(sessionId + ':' + turn.turn))
408
- if (fresh.length === 0) return
409
- for (const turn of fresh) seenFinished.add(sessionId + ':' + turn.turn)
448
+ const key = (turn) => sessionId + ':' + turn.turn
449
+ const counted = markCorrections(turns).filter((turn) => typeof turn.turn === 'number' && turn.finished === true
450
+ && typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt)
451
+ const fresh = counted.filter((turn) => !seenFinished.has(key(turn)))
452
+ const corrected = counted.filter((turn) => turn.corrected && !seenCorrected.has(key(turn)))
453
+ for (const turn of fresh) seenFinished.add(key(turn))
454
+ for (const turn of corrected) seenCorrected.add(key(turn))
455
+ if (fresh.length === 0 && corrected.length === 0) return
410
456
  const steered = steeringIdsBySession.get(sessionId)
411
457
  if (steered === undefined || steered.length === 0) return
412
458
  const profile = safeProfile()
@@ -414,21 +460,33 @@ export function createCoachService(ctx, store, effectiveConfig) {
414
460
  if (candidates.length === 0) return
415
461
  const config = effectiveConfig()
416
462
  const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
463
+ let verdicts = 0
417
464
  for (const entry of candidates) {
418
- entry.trial.turns += fresh.length
419
- entry.trial.messy += messyCount
420
- if (entry.trial.turns < config.directiveTrialTurns) continue
421
- const rate = entry.trial.messy / entry.trial.turns
422
- if (rate > entry.trial.baselineRate + config.directiveWorseBy) {
465
+ const trial = entry.trial
466
+ if (trial.baselineCorrectionRate < 0) trial.baselineCorrectionRate = baselinesFor(scopeOf(entry)).baselineCorrectionRate
467
+ trial.turns += fresh.length
468
+ trial.messy += messyCount
469
+ trial.corrected += corrected.length
470
+ if (trial.turns < config.directiveTrialTurns) continue
471
+ const correctionRate = trial.corrected / trial.turns
472
+ const messyRate = trial.messy / trial.turns
473
+ const worse = correctionRate > trial.baselineCorrectionRate + config.directiveWorseBy
474
+ ? 'corrections ' + pct(trial.baselineCorrectionRate) + ' → ' + pct(correctionRate)
475
+ : messyRate > trial.baselineMessyRate + 2 * config.directiveWorseBy
476
+ ? 'messy turns ' + pct(trial.baselineMessyRate) + ' → ' + pct(messyRate)
477
+ : null
478
+ verdicts += 1
479
+ if (worse !== null) {
423
480
  entry.status = 'retired'
424
481
  entry.enabled = false
425
- entry.retiredReason = 'messy turns ' + pct(entry.trial.baselineRate) + ' → ' + pct(rate) + ' while active'
482
+ entry.retiredReason = worse + ' while active'
426
483
  console.info('[tacit] retired directive (' + entry.retiredReason + '): ' + entry.text)
427
484
  } else {
428
485
  entry.status = 'active'
429
- console.info('[tacit] activated directive (messy turns ' + pct(entry.trial.baselineRate) + ' → ' + pct(rate) + '): ' + entry.text)
486
+ console.info('[tacit] activated directive (corrections ' + pct(trial.baselineCorrectionRate) + ' → ' + pct(correctionRate) + '): ' + entry.text)
430
487
  }
431
488
  }
489
+ if (verdicts > 0) startNextTrial(profile)
432
490
  capAndSaveProfile(profile)
433
491
  }
434
492
 
@@ -469,9 +527,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
469
527
  return earlier.length > 0 ? earlier[earlier.length - 1] : null
470
528
  }
471
529
 
472
- const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null } = {}) => {
530
+ const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null, runId = '' } = {}) => {
473
531
  const profile = safeProfile()
474
532
  const key = `${sessionId}:${turn}`
533
+ /** The run this analysis is billed to: the caller's batch run, or one of its own. Stays '' on a soft refusal. */
534
+ let usageRunId = ''
535
+ /** True when this analysis owns its run; a child of a batch reports `run: null` and lets the batch carry it. */
536
+ const ownRun = runId === ''
475
537
  const exclusive = runExclusive(key, async () => {
476
538
  const svc = serviceOf(ctx)
477
539
  const { session, turns } = turnsOf(svc, sessionId)
@@ -489,13 +551,19 @@ export function createCoachService(ctx, store, effectiveConfig) {
489
551
  const userText = buildAnalysisUserText(record, { followUp, previous })
490
552
  if (userText === null) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
491
553
  const config = effectiveConfig()
554
+ const provider = typeof record.provider === 'string' && record.provider.length > 0
555
+ ? record.provider
556
+ : COACH_PROVIDER
557
+ // Only now, with a paid call actually about to happen, does a run start:
558
+ // every refusal above this line costs nothing and is not worth a row.
559
+ usageRunId = ownRun
560
+ ? usage.beginRun({ type: 'analysis', trigger, sessionId, turn, workspace: workspaceLabel(cwd), model: config.model, provider })
561
+ : runId
562
+ let ok = false
492
563
  try {
493
- const provider = typeof record.provider === 'string' && record.provider.length > 0
494
- ? record.provider
495
- : COACH_PROVIDER
496
564
  if (trigger === 'good') {
497
565
  // One attempt, no repair retry: a recovery lesson is a bonus, not a diagnosis.
498
- const goodText = await callCoachModel(ctx, {
566
+ const goodText = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis', sessionId, turn }, {
499
567
  provider,
500
568
  model: config.model,
501
569
  system: GOOD_SYSTEM_PROMPT,
@@ -504,7 +572,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
504
572
  timeoutMs: ANALYZE_TIMEOUT_MS,
505
573
  tool: GOOD_TOOL,
506
574
  sessionId,
507
- })
575
+ }))
508
576
  const goodParsed = goodText.trim() === '' ? null : parseJsonObject(goodText)
509
577
  if (goodParsed === null) return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
510
578
  const goodReport = {
@@ -525,9 +593,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
525
593
  const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
526
594
  autoRunning.add(task)
527
595
  }
596
+ ok = true
528
597
  return { ok: true, report: goodReport, profile: grown, code: '', detail: '' }
529
598
  }
530
- let text = await callCoachModel(ctx, {
599
+ let text = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis', sessionId, turn }, {
531
600
  provider,
532
601
  model: config.model,
533
602
  system: ANALYSIS_SYSTEM_PROMPT,
@@ -536,14 +605,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
536
605
  timeoutMs: ANALYZE_TIMEOUT_MS,
537
606
  tool: ANALYSIS_TOOL,
538
607
  sessionId,
539
- })
608
+ }))
540
609
  if (text.trim() === '') {
541
610
  return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
542
611
  }
543
612
  let parsed = parseJsonObject(text)
544
613
  if (parsed === null) {
545
614
  // One-shot repair: the model answered in prose; re-ask for strict JSON.
546
- const repaired = await callCoachModel(ctx, {
615
+ const repaired = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis-repair', sessionId, turn }, {
547
616
  provider,
548
617
  model: config.model,
549
618
  system: ANALYSIS_REPAIR_SYSTEM_PROMPT,
@@ -552,7 +621,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
552
621
  timeoutMs: ANALYZE_TIMEOUT_MS,
553
622
  tool: ANALYSIS_TOOL,
554
623
  sessionId,
555
- })
624
+ }))
556
625
  if (repaired.trim() !== '') {
557
626
  const reparsed = parseJsonObject(repaired)
558
627
  if (reparsed !== null) {
@@ -584,14 +653,21 @@ export function createCoachService(ctx, store, effectiveConfig) {
584
653
  const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
585
654
  autoRunning.add(task)
586
655
  }
656
+ ok = true
587
657
  return { ok: true, report, profile: nextProfile, code: '', detail: '' }
588
658
  } catch (error) {
589
659
  const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
590
660
  return { ok: false, report: null, profile, code: coachErrorCode(error), detail }
661
+ } finally {
662
+ // A batch run is closed by its owner; an own run always ends here.
663
+ if (ownRun) closeRun(usageRunId, { ok: ok ? 1 : 0 })
591
664
  }
592
665
  })
593
- if (exclusive === null) return Promise.resolve({ ok: false, report: null, profile, code: 'busy', detail: '' })
594
- return exclusive
666
+ if (exclusive === null) return Promise.resolve({ ok: false, report: null, profile, code: 'busy', detail: '', run: null })
667
+ return exclusive.then((result) => ({
668
+ ...result,
669
+ run: ownRun && usageRunId !== '' ? usage.runSummary(usageRunId) : null,
670
+ }))
595
671
  }
596
672
 
597
673
  const steeringStatus = (cwd) => {
@@ -621,73 +697,41 @@ export function createCoachService(ctx, store, effectiveConfig) {
621
697
  return frozen.text
622
698
  }
623
699
 
624
- /**
625
- * Replace the distilled directives with the model's new complete set; user
626
- * entries are untouched, and a re-emitted directive keeps the enabled flag
627
- * the user gave its identical text. Capped at MAX_DIRECTIVES overall.
628
- */
629
- const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : '')
630
- const directiveKey = (scope, text) => scope + '\n' + text.trim().toLowerCase()
631
-
632
- /** Messy-turn baseline for a new candidate: the workspace's own turns when there are enough, else everything. */
633
- const baselineRateFor = (cwd) => {
634
- const scoped = cwd !== undefined ? allFinishedTurns({ cwd }) : []
700
+ /** Baselines for a new trial: the workspace's own turns when there are enough, else everything. */
701
+ const baselinesFor = (scope) => {
702
+ const scoped = scope !== '' ? allFinishedTurns({ cwd: scope }) : []
635
703
  const turns = scoped.length >= 20 ? scoped : allFinishedTurns()
636
- return computeTrend(turns, { window: 20 }).recent.messyRate
704
+ const { messyRate, correctionRate } = computeTrend(turns, { window: 20 }).recent
705
+ return { baselineMessyRate: messyRate, baselineCorrectionRate: correctionRate }
637
706
  }
638
707
 
639
708
  /**
640
- * Merge the model's new complete set of directives ({ text, workspace? }):
641
- * user entries are untouched; the global distilled set and the distilled set
642
- * of every workspace the model mentioned are replaced; distilled entries of
643
- * other workspaces are kept (their evidence was not in this batch). A
644
- * re-emitted directive keeps its identity, state and enabled flag.
709
+ * One trial per scope at a time: wherever no candidate is on trial, the
710
+ * oldest enabled queued directive of that scope starts its trial now, with
711
+ * baselines measured at this moment. Idempotent.
645
712
  */
646
- const mergeDirectives = (profile, items) => {
647
- const users = profile.directives.filter((entry) => entry.source === 'user')
648
- const userKeys = new Set(users.map((entry) => directiveKey(scopeOf(entry), entry.text)))
649
- const prior = profile.directives.filter((entry) => entry.source !== 'user')
650
- const previous = new Map(prior.map((entry) => [directiveKey(scopeOf(entry), entry.text), entry]))
651
- const mentioned = new Set([''])
652
- for (const item of items) mentioned.add(scopeOf(item))
653
- const untouched = prior.filter((entry) => !mentioned.has(scopeOf(entry)))
654
- const distilled = []
655
- const seen = new Set()
656
- const baselines = new Map()
657
- for (const item of items) {
658
- const scope = scopeOf(item)
659
- const key = directiveKey(scope, item.text)
660
- if (seen.has(key) || userKeys.has(key)) continue
661
- seen.add(key)
662
- const kept = previous.get(key)
663
- if (kept !== undefined) {
664
- distilled.push({ ...kept, text: item.text })
665
- continue
666
- }
667
- // A new distilled directive goes on trial against the current messy-turn rate.
668
- if (!baselines.has(scope)) baselines.set(scope, baselineRateFor(scope === '' ? undefined : scope))
669
- distilled.push({
670
- id: nextDirectiveId(),
671
- text: item.text,
672
- enabled: true,
673
- source: 'distilled',
674
- createdAt: Date.now(),
675
- status: 'candidate',
676
- trial: { turns: 0, messy: 0, baselineRate: baselines.get(scope), startedAt: Date.now() },
677
- ...(scope === '' ? {} : { workspace: scope }),
678
- })
713
+ const startNextTrial = (profile) => {
714
+ const busy = new Set(profile.directives.filter((entry) => entry.status === 'candidate').map(scopeOf))
715
+ for (const entry of profile.directives) {
716
+ if (entry.status !== 'queued' || entry.enabled === false || busy.has(scopeOf(entry))) continue
717
+ busy.add(scopeOf(entry))
718
+ entry.status = 'candidate'
719
+ entry.trial = { turns: 0, messy: 0, corrected: 0, ...baselinesFor(scopeOf(entry)), startedAt: Date.now() }
720
+ console.info('[tacit] directive on trial: ' + entry.text)
679
721
  }
680
- profile.directives = capDirectives([...users, ...distilled, ...untouched])
681
- return profile
682
722
  }
683
723
 
684
724
  /** ONE small call every `directiveEvery` new analyses (or forced). Soft-fails; never throws. */
685
- const maybeDistillDirectives = async (sessionId, provider, { force = false } = {}) => {
725
+ const maybeDistillDirectives = async (sessionId, provider, { force = false, runId = '' } = {}) => {
686
726
  if (directivesInFlight) return
687
727
  const config = effectiveConfig()
688
728
  let profile = safeProfile()
689
729
  if (!force && profile.analysesSinceDirectives < config.directiveEvery) return
690
730
  directivesInFlight = true
731
+ const ownRun = runId === ''
732
+ const usageRunId = ownRun
733
+ ? usage.beginRun({ type: 'directive-distillation', trigger: 'auto', sessionId, model: config.model, provider })
734
+ : runId
691
735
  try {
692
736
  const recent = store.listAllReports(20).map((entry) => store.report(entry.sessionId, entry.turn)).filter((report) => report !== null)
693
737
  // The model sees workspace names only; map them back to the directories they stand for.
@@ -697,7 +741,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
697
741
  const label = workspaceLabel(report.cwd)
698
742
  if (label.length > 0 && !workspaces.has(label)) workspaces.set(label, report.cwd)
699
743
  }
700
- const text = await callCoachModel(ctx, {
744
+ const text = await callCoachModel(ctx, metered(usageRunId, { op: 'directive-distillation', sessionId }, {
701
745
  provider,
702
746
  model: config.model,
703
747
  system: DIRECTIVE_SYSTEM_PROMPT,
@@ -706,17 +750,20 @@ export function createCoachService(ctx, store, effectiveConfig) {
706
750
  timeoutMs: DIRECTIVE_TIMEOUT_MS,
707
751
  tool: DIRECTIVE_TOOL,
708
752
  sessionId,
709
- })
753
+ }))
710
754
  const { kept, rejected } = classifyDirectives(text)
711
755
  for (const dropped of rejected) console.warn('[tacit] dropped directive (it asks the user instead of compensating):', dropped)
712
756
  if (kept.length === 0) {
713
757
  console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
714
758
  return
715
759
  }
716
- const items = kept.map((item) => (item.workspace !== undefined && workspaces.has(item.workspace)
717
- ? { text: item.text, workspace: workspaces.get(item.workspace) }
718
- : { text: item.text }))
719
- profile = mergeDirectives(safeProfile(), items)
760
+ const items = kept.map((item) => ({
761
+ text: item.text,
762
+ ...(item.id === undefined ? {} : { id: item.id }),
763
+ ...(item.workspace !== undefined && workspaces.has(item.workspace) ? { workspace: workspaces.get(item.workspace) } : {}),
764
+ }))
765
+ profile = mergeDirectives(safeProfile(), items, { nextId: nextDirectiveId })
766
+ startNextTrial(profile)
720
767
  profile.analysesSinceDirectives = 0
721
768
  capAndSaveProfile(profile)
722
769
  const scoped = items.filter((item) => item.workspace !== undefined).length
@@ -725,6 +772,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
725
772
  // Soft: the counter stays and the next analysis retries.
726
773
  console.warn('[tacit] directive distillation failed (will retry):', error instanceof Error ? error.message : String(error))
727
774
  } finally {
775
+ if (ownRun) closeRun(usageRunId)
728
776
  directivesInFlight = false
729
777
  }
730
778
  }
@@ -736,6 +784,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
736
784
  * empty note, or a later step leaves the step exactly as it was.
737
785
  */
738
786
  const preStep = async (payload, next) => {
787
+ /** '' until the enrichment call is actually about to happen. */
788
+ let usageRunId = ''
739
789
  try {
740
790
  const config = effectiveConfig()
741
791
  if (!config.enrichPrompts) return next()
@@ -746,8 +796,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
746
796
  if (draft.length < ENRICH_MIN_DRAFT_CHARS || draft.length > ENRICH_MAX_DRAFT_CHARS) return next()
747
797
  const sessionId = typeof payload.agent?.session?.id === 'string' ? payload.agent.session.id : (typeof payload.agent?.id === 'string' ? payload.agent.id : '')
748
798
  const { turns } = sessionId.length > 0 ? turnsOf(serviceOf(ctx), sessionId) : { turns: [] }
749
- const text = await callCoachModel(ctx, {
750
- provider: sessionId.length > 0 ? providerForSession(sessionId) : COACH_PROVIDER,
799
+ const provider = sessionId.length > 0 ? providerForSession(sessionId) : COACH_PROVIDER
800
+ usageRunId = usage.beginRun({ type: 'prompt-enrichment', trigger: 'send', sessionId, model: config.model, provider })
801
+ const text = await callCoachModel(ctx, metered(usageRunId, { op: 'enrichment', sessionId }, {
802
+ provider,
751
803
  model: config.model,
752
804
  system: ENRICH_SYSTEM_PROMPT,
753
805
  userText: buildEnrichUserText({ draft, profile: safeProfile(), recentContext: recentContextOf(turns) }),
@@ -755,7 +807,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
755
807
  timeoutMs: ENRICH_TIMEOUT_MS,
756
808
  tool: ENRICH_TOOL,
757
809
  sessionId,
758
- })
810
+ }))
759
811
  const note = normalizeEnrichNote(text)
760
812
  if (note.length === 0) return next()
761
813
  const base = await next()
@@ -767,6 +819,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
767
819
  return { kind: 'enter', messages: [...base.messages, added] }
768
820
  } catch {
769
821
  return next()
822
+ } finally {
823
+ if (usageRunId !== '') closeRun(usageRunId)
770
824
  }
771
825
  }
772
826
 
@@ -779,27 +833,53 @@ export function createCoachService(ctx, store, effectiveConfig) {
779
833
  if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
780
834
  if (cwd !== undefined && cwdOf(session) !== cwd) continue
781
835
  const { turns } = turnsOf(svc, session.id)
782
- for (const turn of turns) if (turn?.finished === true) out.push(turn)
836
+ for (const turn of markCorrections(turns)) if (turn.finished === true) out.push(turn)
783
837
  }
784
838
  return out
785
839
  }
786
840
 
787
- /** One bootstrap at a time; progress is exposed through /state. */
788
- const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0 }
841
+ /** One bootstrap at a time; progress AND its running cost are exposed through /state. */
842
+ /** Live bootstrap progress for `/state`. `tokensTotal` is a single billed-token count
843
+ * deliberately not named `tokens`, which everywhere else in the ledger is the five-bucket object. */
844
+ const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0, runId: '', billedCalls: 0, unpricedCalls: 0, usdKnown: 0, tokensTotal: 0 }
845
+
846
+ /** Back to "no bootstrap has run": every field, so a no-op never shows the previous batch's figures. */
847
+ const resetBootstrapState = () => {
848
+ bootstrapState.running = false
849
+ bootstrapState.done = 0
850
+ bootstrapState.total = 0
851
+ bootstrapState.startedAt = 0
852
+ bootstrapState.runId = ''
853
+ bootstrapState.billedCalls = 0
854
+ bootstrapState.unpricedCalls = 0
855
+ bootstrapState.usdKnown = 0
856
+ bootstrapState.tokensTotal = 0
857
+ }
858
+
859
+ /** Mirror the bootstrap run's live counters into the state the panel polls. */
860
+ const refreshBootstrapUsage = () => {
861
+ const summary = bootstrapState.runId === '' ? null : usage.runSummary(bootstrapState.runId)
862
+ if (summary === null) return
863
+ bootstrapState.billedCalls = summary.billedCalls
864
+ bootstrapState.unpricedCalls = summary.unpricedCalls
865
+ bootstrapState.usdKnown = summary.usdKnown
866
+ bootstrapState.tokensTotal = totalTokens(summary.tokens)
867
+ }
789
868
 
790
869
  /**
791
- * "Learn from my last N turns now": explicit user action, so it ignores the
792
- * daily auto budget. Skips continuations, tiny prompts and turns that already
793
- * have a report; runs up to `bootstrapConcurrency` analyses at once (same
794
- * number of calls either way); then forces one directive distillation.
870
+ * Which turns a bootstrap would analyze, newest first: every finished turn of
871
+ * one session (or of every live session), minus continuations, tiny prompts
872
+ * and turns that already have a report, capped at `limit`. Pure selection
873
+ * no model call, no run, no state touched so the preview and the batch it
874
+ * previews always answer from the same rule. `code` is `'no-session'` when a
875
+ * requested session is not live.
795
876
  */
796
- const runBootstrap = async ({ sessionId, limit }) => {
797
- if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '' }
877
+ const bootstrapCandidates = ({ sessionId, limit }) => {
798
878
  const svc = serviceOf(ctx)
799
879
  const pool = []
800
880
  if (typeof sessionId === 'string' && sessionId.length > 0) {
801
881
  const { session, turns } = turnsOf(svc, sessionId)
802
- if (session === undefined) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'no-session', detail: '' }
882
+ if (session === undefined) return { eligible: [], skipped: 0, code: 'no-session' }
803
883
  for (const turn of turns) if (turn?.finished === true) pool.push({ sessionId, turn, turns })
804
884
  } else {
805
885
  const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
@@ -821,10 +901,79 @@ export function createCoachService(ctx, store, effectiveConfig) {
821
901
  eligible.push(item)
822
902
  if (eligible.length >= limit) break
823
903
  }
904
+ return { eligible, skipped, code: '' }
905
+ }
906
+
907
+ /**
908
+ * What a bootstrap of `count` turns is likely to cost. The ledger wins as
909
+ * soon as it holds enough priced analyses (median, plus the one directive
910
+ * distillation a batch also pays for); until then the documented per-analysis
911
+ * figure for the configured model does.
912
+ */
913
+ const estimateBootstrap = (count, model) => {
914
+ const sample = usage.analysisCostSample()
915
+ if (sample.samples >= MEASURED_MIN_SAMPLES && typeof sample.perAnalysisUsd === 'number') {
916
+ const perAnalysisUsd = sample.perAnalysisUsd
917
+ const distillationUsd = typeof sample.distillationUsd === 'number' ? sample.distillationUsd : 0
918
+ return { usd: perAnalysisUsd * count + (count > 0 ? distillationUsd : 0), basis: 'measured', samples: sample.samples, perAnalysisUsd }
919
+ }
920
+ const perAnalysisUsd = DOC_ANALYSIS_USD[model] ?? DOC_ANALYSIS_USD['deepseek-v4-flash']
921
+ return { usd: perAnalysisUsd * count, basis: 'doc', samples: sample.samples, perAnalysisUsd }
922
+ }
923
+
924
+ /**
925
+ * "What would ⚡ Bootstrap do, and what would it cost?" — the same selection
926
+ * the run itself uses, priced from the ledger. Read-only: no model call, no
927
+ * run, and deliberately NOT blocked by a bootstrap already running.
928
+ */
929
+ const bootstrapPreview = ({ sessionId, limit }) => {
930
+ const config = effectiveConfig()
931
+ const { eligible, skipped, code } = bootstrapCandidates({ sessionId, limit })
932
+ const count = code === '' ? eligible.length : 0
933
+ return {
934
+ ok: code === '',
935
+ eligible: count,
936
+ skipped: code === '' ? skipped : 0,
937
+ limit,
938
+ model: config.model,
939
+ estimate: estimateBootstrap(count, config.model),
940
+ code,
941
+ detail: '',
942
+ }
943
+ }
944
+
945
+ /**
946
+ * "Learn from my last N turns now": explicit user action, so it ignores the
947
+ * daily auto budget. Skips continuations, tiny prompts and turns that already
948
+ * have a report; runs up to `bootstrapConcurrency` analyses at once (same
949
+ * number of calls either way); then forces one directive distillation.
950
+ */
951
+ const runBootstrap = async ({ sessionId, limit }) => {
952
+ if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '', run: null }
953
+ const { eligible, skipped, code } = bootstrapCandidates({ sessionId, limit })
954
+ if (code !== '') return { ok: false, analyzed: 0, skipped: 0, directives: 0, code, detail: '', run: null }
955
+ // Nothing to analyze is a no-op, not a run: an empty run would be written
956
+ // to the ledger as `failed` (no attempts) and read as a broken bootstrap.
957
+ if (eligible.length === 0) {
958
+ resetBootstrapState()
959
+ return { ok: true, analyzed: 0, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: null }
960
+ }
961
+ const config = effectiveConfig()
962
+ const scopedToSession = typeof sessionId === 'string' && sessionId.length > 0
963
+ // ONE parent run for the whole batch: every analysis and the forced
964
+ // distillation are attempts of it, so the panel shows one line, one price.
965
+ const runId = usage.beginRun({
966
+ type: 'bootstrap',
967
+ trigger: 'bootstrap',
968
+ sessionId: sessionId ?? '',
969
+ model: config.model,
970
+ provider: scopedToSession ? providerForSession(sessionId) : COACH_PROVIDER,
971
+ })
972
+ resetBootstrapState()
824
973
  bootstrapState.running = true
825
- bootstrapState.done = 0
826
974
  bootstrapState.total = eligible.length
827
975
  bootstrapState.startedAt = Date.now()
976
+ bootstrapState.runId = runId
828
977
  let analyzed = 0
829
978
  try {
830
979
  // A small worker pool: each worker pulls the next eligible turn until the
@@ -837,23 +986,80 @@ export function createCoachService(ctx, store, effectiveConfig) {
837
986
  const item = eligible[next]
838
987
  next += 1
839
988
  const previous = previousFinishedOf(item.turns, item.turn.turn)
840
- const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous })
989
+ const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous, runId })
841
990
  if (result !== null && typeof result === 'object' && result.ok === true) analyzed += 1
842
991
  else console.warn('[tacit] bootstrap: ' + item.sessionId + ':' + item.turn.turn + ' skipped: ' + (result?.code ?? 'unknown'))
843
992
  bootstrapState.done += 1
993
+ refreshBootstrapUsage()
844
994
  }
845
995
  }
846
- const concurrency = Math.min(effectiveConfig().bootstrapConcurrency, Math.max(1, eligible.length))
996
+ const concurrency = Math.min(config.bootstrapConcurrency, Math.max(1, eligible.length))
847
997
  await Promise.all(Array.from({ length: concurrency }, () => worker()))
848
998
  if (analyzed > 0) {
849
999
  await service.flushAuto() // let any scheduled distillation settle before forcing one
850
1000
  // The forced distillation is attributed to the newest eligible turn's session.
851
- await maybeDistillDirectives(eligible[0].sessionId, providerForSession(eligible[0].sessionId), { force: true })
1001
+ await maybeDistillDirectives(eligible[0].sessionId, providerForSession(eligible[0].sessionId), { force: true, runId })
852
1002
  }
853
1003
  } finally {
854
1004
  bootstrapState.running = false
1005
+ closeRun(runId, { requested: limit, eligible: eligible.length, analyzed, skipped, directives: safeProfile().directives.length })
1006
+ refreshBootstrapUsage()
1007
+ // Mirror the final counters first, then drop the id: the tile keeps the
1008
+ // finished run's figures, but nothing may still call it the live run.
1009
+ bootstrapState.runId = ''
1010
+ }
1011
+ return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: usage.runSummary(runId) }
1012
+ }
1013
+
1014
+ /**
1015
+ * "Analyze exactly these turns": the user picked them, so nothing is filtered
1016
+ * out and the daily auto budget does not apply. One parent run covers the
1017
+ * whole batch (the analyses are its attempts); the same `runExclusive` key as
1018
+ * every other analysis means a turn already being analyzed — by a bootstrap,
1019
+ * an auto trigger or another batch — reports `busy` and costs nothing. A
1020
+ * bootstrap running elsewhere does NOT block the batch.
1021
+ */
1022
+ const analyzeBatch = async ({ sessionId, turns }) => {
1023
+ const { session } = turnsOf(serviceOf(ctx), sessionId)
1024
+ if (session === undefined) return { ok: false, results: [], profile: safeProfile(), run: null, code: 'no-session', detail: '' }
1025
+ const wanted = [...new Set(turns)].sort((a, b) => a - b)
1026
+ const config = effectiveConfig()
1027
+ const runId = usage.beginRun({
1028
+ type: 'analysis-batch',
1029
+ trigger: 'manual',
1030
+ sessionId,
1031
+ workspace: workspaceLabel(cwdOf(session)),
1032
+ model: config.model,
1033
+ provider: providerForSession(sessionId),
1034
+ })
1035
+ const results = new Array(wanted.length)
1036
+ let analyzed = 0
1037
+ try {
1038
+ // The same worker pool as the bootstrap: different turns never share an
1039
+ // in-flight key, so the pool is the only thing bounding concurrency.
1040
+ let next = 0
1041
+ const worker = async () => {
1042
+ while (next < wanted.length) {
1043
+ const at = next
1044
+ next += 1
1045
+ const turn = wanted[at]
1046
+ const result = await runAnalysis(sessionId, turn, { trigger: 'manual', runId })
1047
+ const ok = result !== null && typeof result === 'object' && result.ok === true
1048
+ if (ok) analyzed += 1
1049
+ results[at] = { turn, ok, code: result?.code ?? 'call-failed', report: ok ? result.report : null }
1050
+ }
1051
+ }
1052
+ const concurrency = Math.min(config.bootstrapConcurrency, Math.max(1, wanted.length))
1053
+ await Promise.all(Array.from({ length: concurrency }, () => worker()))
1054
+ } finally {
1055
+ // Every requested turn was already being analyzed elsewhere: no call was
1056
+ // made and nothing failed, so this is a real request that succeeded —
1057
+ // not the zero-attempt `failed` run the default derivation would write.
1058
+ const entries = results.filter((entry) => entry !== null && entry !== undefined)
1059
+ const allBusy = analyzed === 0 && entries.length === wanted.length && entries.every((entry) => entry.code === 'busy')
1060
+ closeRun(runId, { requested: wanted.length, analyzed, skipped: wanted.length - analyzed }, allBusy ? 'success' : undefined)
855
1061
  }
856
- return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '' }
1062
+ return { ok: true, results, profile: safeProfile(), run: usage.runSummary(runId), code: '', detail: '' }
857
1063
  }
858
1064
 
859
1065
  const autoStatus = () => {
@@ -931,6 +1137,11 @@ export function createCoachService(ctx, store, effectiveConfig) {
931
1137
  }
932
1138
 
933
1139
  const service = {
1140
+ /** The usage/cost ledger (flushed on dispose; read by the cost routes). */
1141
+ usage,
1142
+ /** The price source behind the ledger. */
1143
+ pricing,
1144
+
934
1145
  /** Await every in-flight automatic analysis (tests / orderly shutdown). */
935
1146
  async flushAuto() {
936
1147
  await Promise.all([...autoRunning])
@@ -941,6 +1152,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
941
1152
  const svc = serviceOf(ctx)
942
1153
  const sessionId = args !== null && typeof args === 'object' && typeof args.sessionId === 'string' && args.sessionId.length > 0 ? args.sessionId : null
943
1154
  const session = sessionId !== null && typeof svc.sessions?.get === 'function' ? svc.sessions.get(sessionId) : undefined
1155
+ refreshBootstrapUsage()
944
1156
  return {
945
1157
  ok: true,
946
1158
  config: effectiveConfig(),
@@ -975,21 +1187,30 @@ export function createCoachService(ctx, store, effectiveConfig) {
975
1187
  async analyzeTurn(args) {
976
1188
  const parsed = analyzeArgSchema.safeParse(args)
977
1189
  if (!parsed.success) {
978
- return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '' }
1190
+ return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '', run: null }
979
1191
  }
980
1192
  return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual' })
981
1193
  },
982
1194
 
1195
+ /** Analyze a hand-picked set of turns of one session under a single run. */
1196
+ async analyzeBatch(args) {
1197
+ const parsed = analyzeBatchArgSchema.safeParse(args)
1198
+ if (!parsed.success) {
1199
+ return { ok: false, results: [], profile: safeProfile(), run: null, code: 'bad-request', detail: '' }
1200
+ }
1201
+ return analyzeBatch(parsed.data)
1202
+ },
1203
+
983
1204
  async improveDraft(args) {
984
1205
  const parsed = improveArgSchema.safeParse(args)
985
1206
  if (!parsed.success) {
986
- return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'bad-request', detail: '' }
1207
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'bad-request', detail: '', run: null }
987
1208
  }
988
1209
  const { sessionId, draft } = parsed.data
989
1210
  const config = effectiveConfig()
990
1211
  const profile = safeProfile()
991
1212
  const svc = serviceOf(ctx)
992
- const { turns } = turnsOf(svc, sessionId)
1213
+ const { session, turns } = turnsOf(svc, sessionId)
993
1214
  const recentContext = recentContextOf(turns)
994
1215
  // Distillation also fires on user-triggered improve calls (soft, in-flight
995
1216
  // deduped, never awaited: an improve call is never blocked by it).
@@ -1007,12 +1228,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
1007
1228
  styleRules: profile.styleRules,
1008
1229
  negativeFeedback: lastDownReasons(profile, 3),
1009
1230
  })
1231
+ // Provider follows the session's own route (latest known), so proxy
1232
+ // or custom provider setups keep working; the shipped DeepSeek
1233
+ // adapter id is the fallback.
1234
+ const provider = providerForSession(sessionId)
1235
+ const runId = usage.beginRun({ type: 'improve', trigger: 'manual', sessionId, workspace: workspaceLabel(cwdOf(session)), model: config.model, provider })
1010
1236
  try {
1011
- // Provider follows the session's own route (latest known), so proxy
1012
- // or custom provider setups keep working; the shipped DeepSeek
1013
- // adapter id is the fallback.
1014
- const provider = providerForSession(sessionId)
1015
- let text = await callCoachModel(ctx, {
1237
+ let text = await callCoachModel(ctx, metered(runId, { op: 'improve', sessionId }, {
1016
1238
  provider,
1017
1239
  model: config.model,
1018
1240
  system: IMPROVE_SYSTEM_PROMPT,
@@ -1021,13 +1243,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
1021
1243
  timeoutMs: IMPROVE_TIMEOUT_MS,
1022
1244
  tool: IMPROVE_TOOL,
1023
1245
  sessionId,
1024
- })
1246
+ }))
1025
1247
  if (text.trim() === '') {
1026
- return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'empty-response', detail: '' }
1248
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'empty-response', detail: '', run: closeRun(runId) }
1027
1249
  }
1028
1250
  let parsed = parseJsonObject(text)
1029
1251
  if (parsed === null) {
1030
- const repaired = await callCoachModel(ctx, {
1252
+ const repaired = await callCoachModel(ctx, metered(runId, { op: 'improve-repair', sessionId }, {
1031
1253
  provider,
1032
1254
  model: config.model,
1033
1255
  system: IMPROVE_REPAIR_SYSTEM_PROMPT,
@@ -1036,7 +1258,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
1036
1258
  timeoutMs: IMPROVE_TIMEOUT_MS,
1037
1259
  tool: IMPROVE_TOOL,
1038
1260
  sessionId,
1039
- })
1261
+ }))
1040
1262
  if (repaired.trim() !== '') {
1041
1263
  const reparsed = parseJsonObject(repaired)
1042
1264
  if (reparsed !== null) parsed = reparsed
@@ -1052,10 +1274,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
1052
1274
  draft: draft.trim().slice(0, 1000),
1053
1275
  improved: result.improved.slice(0, 2000),
1054
1276
  })
1055
- return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '' }
1277
+ return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '', run: closeRun(runId) }
1056
1278
  } catch (error) {
1057
1279
  const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
1058
- return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: coachErrorCode(error), detail }
1280
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: coachErrorCode(error), detail, run: closeRun(runId) }
1281
+ } finally {
1282
+ // Idempotent: the returns above already closed it; this catches a throw.
1283
+ closeRun(runId)
1059
1284
  }
1060
1285
  },
1061
1286
 
@@ -1153,6 +1378,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
1153
1378
  } else {
1154
1379
  profile.directives = profile.directives.filter((entry) => entry.id !== input.id)
1155
1380
  }
1381
+ startNextTrial(profile)
1156
1382
  const saved = capAndSaveProfile(profile)
1157
1383
  return { ok: true, profile: saved, steering: steeringStatus(), code: '', detail: '' }
1158
1384
  },
@@ -1165,10 +1391,29 @@ export function createCoachService(ctx, store, effectiveConfig) {
1165
1391
 
1166
1392
  async bootstrap(args) {
1167
1393
  const parsed = bootstrapArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1168
- if (!parsed.success) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'bad-request', detail: '' }
1394
+ if (!parsed.success) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'bad-request', detail: '', run: null }
1169
1395
  return runBootstrap({ sessionId: parsed.data.sessionId, limit: parsed.data.limit ?? 20 })
1170
1396
  },
1171
1397
 
1398
+ /** What a bootstrap would analyze and what it would cost. Free: no model call, no run. */
1399
+ async bootstrapPreview(args) {
1400
+ const parsed = bootstrapArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1401
+ if (!parsed.success) {
1402
+ const config = effectiveConfig()
1403
+ return {
1404
+ ok: false,
1405
+ eligible: 0,
1406
+ skipped: 0,
1407
+ limit: 20,
1408
+ model: config.model,
1409
+ estimate: estimateBootstrap(0, config.model),
1410
+ code: 'bad-request',
1411
+ detail: '',
1412
+ }
1413
+ }
1414
+ return bootstrapPreview({ sessionId: parsed.data.sessionId, limit: parsed.data.limit ?? 20 })
1415
+ },
1416
+
1172
1417
  async stats(args) {
1173
1418
  const parsed = statsArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1174
1419
  const window = parsed.success && typeof parsed.data.window === 'number' ? parsed.data.window : 20
@@ -1190,6 +1435,39 @@ export function createCoachService(ctx, store, effectiveConfig) {
1190
1435
  const removed = store.clearReports()
1191
1436
  return { ok: true, removed, code: '', detail: '' }
1192
1437
  },
1438
+
1439
+ /** The whole cost panel: period cards, series, breakdowns, warnings and one page of runs. */
1440
+ async usageReport(args) {
1441
+ const parsed = usageArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1442
+ if (!parsed.success) return { ok: false, code: 'bad-request', detail: '' }
1443
+ return usage.report({
1444
+ config: effectiveConfig(),
1445
+ pricingStatus: pricing.status(),
1446
+ pricingRates: pricing.rates(),
1447
+ filters: parsed.data,
1448
+ })
1449
+ },
1450
+
1451
+ /** One run with its attempt rows (a live run included); expired ids are a soft `unknown-run`. */
1452
+ async usageRun(args) {
1453
+ const parsed = usageRunArgSchema.safeParse(args)
1454
+ if (!parsed.success) return { ok: false, run: null, code: 'bad-request', detail: '' }
1455
+ const run = usage.run(parsed.data.runId)
1456
+ if (run === null) return { ok: false, run: null, code: 'unknown-run', detail: '' }
1457
+ return { ok: true, run, code: '', detail: '' }
1458
+ },
1459
+
1460
+ /** Delete the ledger and restart the tracking window (live runs keep recording into it). */
1461
+ async usageClear() {
1462
+ const { removed, trackingSince } = usage.clear()
1463
+ return { ok: true, removed, trackingSince, code: '', detail: '' }
1464
+ },
1465
+
1466
+ /** Re-read the optional costMeter sibling; `refresh()` never throws, so this never fails. */
1467
+ async pricingRefresh() {
1468
+ await pricing.refresh()
1469
+ return { ok: true, pricing: { ...pricing.status(), rates: pricing.rates() }, code: '', detail: '' }
1470
+ },
1193
1471
  }
1194
1472
 
1195
1473
  return service