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/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 {
@@ -92,6 +100,13 @@ import {
92
100
 
93
101
  /** In-memory rewrite ledger bounds (never persisted). */
94
102
  const MAX_REWRITE_RECORDS = 50
103
+ /**
104
+ * What one analysis costs per model when the ledger cannot say yet: the docs'
105
+ * $0.02–0.05 per 20 bootstrapped turns, scaled to a single analysis.
106
+ */
107
+ const DOC_ANALYSIS_USD = { 'deepseek-v4-flash': 0.0025, 'deepseek-v4-pro': 0.0075 }
108
+ /** Priced analysis attempts the ledger needs before an estimate follows it instead of the docs. */
109
+ const MEASURED_MIN_SAMPLES = 3
95
110
  /** Pending outcome verifications kept per session (FIFO, oldest dropped). */
96
111
  const MAX_PENDING_VERIFICATIONS = 20
97
112
 
@@ -119,6 +134,9 @@ export function mergeConfig(base, patch) {
119
134
  merged.directiveWorseBy = Math.max(0, Math.min(1, Number.isFinite(Number(merged.directiveWorseBy)) ? Number(merged.directiveWorseBy) : 0.15))
120
135
  merged.bootstrapConcurrency = Math.max(1, Math.min(4, Math.round(Number(merged.bootstrapConcurrency) || 1)))
121
136
  merged.learnFromGood = merged.learnFromGood !== false
137
+ merged.costHistoryDays = Math.max(7, Math.min(365, Math.round(Number(merged.costHistoryDays) || 30)))
138
+ merged.costWarnDailyUsd = Math.max(0, Math.min(10000, Number.isFinite(Number(merged.costWarnDailyUsd ?? 0)) ? Number(merged.costWarnDailyUsd ?? 0) : 0))
139
+ merged.costWarnMonthlyUsd = Math.max(0, Math.min(10000, Number.isFinite(Number(merged.costWarnMonthlyUsd ?? 0)) ? Number(merged.costWarnMonthlyUsd ?? 0) : 0))
122
140
  return merged
123
141
  }
124
142
 
@@ -229,23 +247,50 @@ function lastFinishedTurnOf(turns) {
229
247
  return finished.length > 0 ? finished[finished.length - 1] : null
230
248
  }
231
249
 
250
+ /**
251
+ * One of {@link COACH_ERROR_CODES} for whatever a model call threw.
252
+ *
253
+ * A code is passed through only when it is already one of Tacit's own: the
254
+ * runtime turns an adapter failure into a `finish {kind:'error'|'aborted'}`
255
+ * chunk and `callCoachModel` rethrows it carrying the provider's own code
256
+ * (or a synthesized `ERROR`/`ABORTED`), and the client renders `err.<code>`
257
+ * verbatim — so passing those through paints a literal `err.ABORTED` banner.
258
+ * Anything else is folded, code and message together, through the ladder.
259
+ * The attempt record keeps the raw code; only the envelope is normalized.
260
+ */
232
261
  function coachErrorCode(error) {
233
262
  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'
263
+ const raw = error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : ''
264
+ if (COACH_ERROR_CODES.includes(raw)) return raw
265
+ const text = raw + ' ' + message
266
+ if (/abort|timeout/i.test(text)) return 'timeout'
267
+ if (/auth|401|403|api[ _-]?key|key not/i.test(text)) return 'no-api-key'
268
+ // Word-bounded: a bare /rate/ matches the "rate" inside "generate", and the
269
+ // ladder now sees the raw code and message of every provider failure. A
270
+ // trailing \b after "quota" would not do here — `_` is a word character, so
271
+ // it could never match `quota_exceeded`; the lookahead stops "quotation"
272
+ // instead, which is what the boundary was for.
273
+ if (/\brate[ _-]?limit|\bquota(?![a-z])|\b429\b|too[ _-]?many[ _-]?requests/i.test(text)) return 'rate-limited'
238
274
  return 'call-failed'
239
275
  }
240
276
 
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
277
  export function createCoachService(ctx, store, effectiveConfig) {
278
+ /** Prices behind the ledger: the optional costMeter sibling when it appears, bundled list prices otherwise. */
279
+ const pricing = createPricingSource(ctx)
280
+ /** The run/attempt ledger. Synchronous by contract — never awaited in a model-call path. */
281
+ const usage = createUsageTracker({ store, config: effectiveConfig, pricing })
282
+ // Fire and forget, twice: once now, once if the sibling shows up later.
283
+ // No model call ever waits on it, and a failure only means bundled prices.
284
+ const refreshPrices = () => { pricing.refresh().catch(() => {}) }
285
+ refreshPrices()
286
+ withService(ctx, 'costMeter', refreshPrices)
287
+
288
+ /** One metered model call: the caller's options plus this run's usage sink. */
289
+ const metered = (runId, tag, options) => ({ ...options, onUsage: usage.attemptSink(runId, tag) })
290
+
291
+ /** End a run (idempotent) and hand back the summary its response envelope carries. */
292
+ const closeRun = (runId, results = {}, status) => usage.endRun(runId, { results, status }) ?? usage.runSummary(runId)
293
+
249
294
  const inFlight = new Map()
250
295
  /** Turns already handed to automatic analysis (sessionId:turn). */
251
296
  const autoSeen = new Set()
@@ -328,11 +373,15 @@ export function createCoachService(ctx, store, effectiveConfig) {
328
373
  */
329
374
  const maybeDistill = async (profile, provider, sessionId) => {
330
375
  if (profile.pendingDistill < 3 || distillInFlight) return profile
376
+ // Read the config BEFORE claiming the in-flight slot: a throw between the
377
+ // two would leave `distillInFlight` stuck true and kill every later
378
+ // distillation for the lifetime of the process.
379
+ const config = effectiveConfig()
380
+ const runId = usage.beginRun({ type: 'style-distillation', trigger: 'feedback', sessionId, model: config.model, provider })
331
381
  distillInFlight = true
332
382
  try {
333
- const config = effectiveConfig()
334
383
  const reasons = lastDownReasons(profile, 3)
335
- const text = await callCoachModel(ctx, {
384
+ const text = await callCoachModel(ctx, metered(runId, { op: 'style-distillation', sessionId }, {
336
385
  provider,
337
386
  model: config.model,
338
387
  system: DISTILL_SYSTEM_PROMPT,
@@ -341,7 +390,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
341
390
  timeoutMs: DISTILL_TIMEOUT_MS,
342
391
  tool: DISTILL_TOOL,
343
392
  sessionId,
344
- })
393
+ }))
345
394
  const rules = normalizeDistillRules(text)
346
395
  if (rules.length === 0) return profile
347
396
  const fresh = safeProfile()
@@ -351,6 +400,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
351
400
  } catch {
352
401
  return profile
353
402
  } finally {
403
+ closeRun(runId)
354
404
  distillInFlight = false
355
405
  }
356
406
  }
@@ -469,9 +519,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
469
519
  return earlier.length > 0 ? earlier[earlier.length - 1] : null
470
520
  }
471
521
 
472
- const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null } = {}) => {
522
+ const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null, runId = '' } = {}) => {
473
523
  const profile = safeProfile()
474
524
  const key = `${sessionId}:${turn}`
525
+ /** The run this analysis is billed to: the caller's batch run, or one of its own. Stays '' on a soft refusal. */
526
+ let usageRunId = ''
527
+ /** True when this analysis owns its run; a child of a batch reports `run: null` and lets the batch carry it. */
528
+ const ownRun = runId === ''
475
529
  const exclusive = runExclusive(key, async () => {
476
530
  const svc = serviceOf(ctx)
477
531
  const { session, turns } = turnsOf(svc, sessionId)
@@ -489,13 +543,19 @@ export function createCoachService(ctx, store, effectiveConfig) {
489
543
  const userText = buildAnalysisUserText(record, { followUp, previous })
490
544
  if (userText === null) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
491
545
  const config = effectiveConfig()
546
+ const provider = typeof record.provider === 'string' && record.provider.length > 0
547
+ ? record.provider
548
+ : COACH_PROVIDER
549
+ // Only now, with a paid call actually about to happen, does a run start:
550
+ // every refusal above this line costs nothing and is not worth a row.
551
+ usageRunId = ownRun
552
+ ? usage.beginRun({ type: 'analysis', trigger, sessionId, turn, workspace: workspaceLabel(cwd), model: config.model, provider })
553
+ : runId
554
+ let ok = false
492
555
  try {
493
- const provider = typeof record.provider === 'string' && record.provider.length > 0
494
- ? record.provider
495
- : COACH_PROVIDER
496
556
  if (trigger === 'good') {
497
557
  // One attempt, no repair retry: a recovery lesson is a bonus, not a diagnosis.
498
- const goodText = await callCoachModel(ctx, {
558
+ const goodText = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis', sessionId, turn }, {
499
559
  provider,
500
560
  model: config.model,
501
561
  system: GOOD_SYSTEM_PROMPT,
@@ -504,7 +564,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
504
564
  timeoutMs: ANALYZE_TIMEOUT_MS,
505
565
  tool: GOOD_TOOL,
506
566
  sessionId,
507
- })
567
+ }))
508
568
  const goodParsed = goodText.trim() === '' ? null : parseJsonObject(goodText)
509
569
  if (goodParsed === null) return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
510
570
  const goodReport = {
@@ -525,9 +585,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
525
585
  const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
526
586
  autoRunning.add(task)
527
587
  }
588
+ ok = true
528
589
  return { ok: true, report: goodReport, profile: grown, code: '', detail: '' }
529
590
  }
530
- let text = await callCoachModel(ctx, {
591
+ let text = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis', sessionId, turn }, {
531
592
  provider,
532
593
  model: config.model,
533
594
  system: ANALYSIS_SYSTEM_PROMPT,
@@ -536,14 +597,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
536
597
  timeoutMs: ANALYZE_TIMEOUT_MS,
537
598
  tool: ANALYSIS_TOOL,
538
599
  sessionId,
539
- })
600
+ }))
540
601
  if (text.trim() === '') {
541
602
  return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
542
603
  }
543
604
  let parsed = parseJsonObject(text)
544
605
  if (parsed === null) {
545
606
  // One-shot repair: the model answered in prose; re-ask for strict JSON.
546
- const repaired = await callCoachModel(ctx, {
607
+ const repaired = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis-repair', sessionId, turn }, {
547
608
  provider,
548
609
  model: config.model,
549
610
  system: ANALYSIS_REPAIR_SYSTEM_PROMPT,
@@ -552,7 +613,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
552
613
  timeoutMs: ANALYZE_TIMEOUT_MS,
553
614
  tool: ANALYSIS_TOOL,
554
615
  sessionId,
555
- })
616
+ }))
556
617
  if (repaired.trim() !== '') {
557
618
  const reparsed = parseJsonObject(repaired)
558
619
  if (reparsed !== null) {
@@ -584,14 +645,21 @@ export function createCoachService(ctx, store, effectiveConfig) {
584
645
  const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
585
646
  autoRunning.add(task)
586
647
  }
648
+ ok = true
587
649
  return { ok: true, report, profile: nextProfile, code: '', detail: '' }
588
650
  } catch (error) {
589
651
  const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
590
652
  return { ok: false, report: null, profile, code: coachErrorCode(error), detail }
653
+ } finally {
654
+ // A batch run is closed by its owner; an own run always ends here.
655
+ if (ownRun) closeRun(usageRunId, { ok: ok ? 1 : 0 })
591
656
  }
592
657
  })
593
- if (exclusive === null) return Promise.resolve({ ok: false, report: null, profile, code: 'busy', detail: '' })
594
- return exclusive
658
+ if (exclusive === null) return Promise.resolve({ ok: false, report: null, profile, code: 'busy', detail: '', run: null })
659
+ return exclusive.then((result) => ({
660
+ ...result,
661
+ run: ownRun && usageRunId !== '' ? usage.runSummary(usageRunId) : null,
662
+ }))
595
663
  }
596
664
 
597
665
  const steeringStatus = (cwd) => {
@@ -682,12 +750,16 @@ export function createCoachService(ctx, store, effectiveConfig) {
682
750
  }
683
751
 
684
752
  /** ONE small call every `directiveEvery` new analyses (or forced). Soft-fails; never throws. */
685
- const maybeDistillDirectives = async (sessionId, provider, { force = false } = {}) => {
753
+ const maybeDistillDirectives = async (sessionId, provider, { force = false, runId = '' } = {}) => {
686
754
  if (directivesInFlight) return
687
755
  const config = effectiveConfig()
688
756
  let profile = safeProfile()
689
757
  if (!force && profile.analysesSinceDirectives < config.directiveEvery) return
690
758
  directivesInFlight = true
759
+ const ownRun = runId === ''
760
+ const usageRunId = ownRun
761
+ ? usage.beginRun({ type: 'directive-distillation', trigger: 'auto', sessionId, model: config.model, provider })
762
+ : runId
691
763
  try {
692
764
  const recent = store.listAllReports(20).map((entry) => store.report(entry.sessionId, entry.turn)).filter((report) => report !== null)
693
765
  // The model sees workspace names only; map them back to the directories they stand for.
@@ -697,7 +769,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
697
769
  const label = workspaceLabel(report.cwd)
698
770
  if (label.length > 0 && !workspaces.has(label)) workspaces.set(label, report.cwd)
699
771
  }
700
- const text = await callCoachModel(ctx, {
772
+ const text = await callCoachModel(ctx, metered(usageRunId, { op: 'directive-distillation', sessionId }, {
701
773
  provider,
702
774
  model: config.model,
703
775
  system: DIRECTIVE_SYSTEM_PROMPT,
@@ -706,7 +778,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
706
778
  timeoutMs: DIRECTIVE_TIMEOUT_MS,
707
779
  tool: DIRECTIVE_TOOL,
708
780
  sessionId,
709
- })
781
+ }))
710
782
  const { kept, rejected } = classifyDirectives(text)
711
783
  for (const dropped of rejected) console.warn('[tacit] dropped directive (it asks the user instead of compensating):', dropped)
712
784
  if (kept.length === 0) {
@@ -725,6 +797,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
725
797
  // Soft: the counter stays and the next analysis retries.
726
798
  console.warn('[tacit] directive distillation failed (will retry):', error instanceof Error ? error.message : String(error))
727
799
  } finally {
800
+ if (ownRun) closeRun(usageRunId)
728
801
  directivesInFlight = false
729
802
  }
730
803
  }
@@ -736,6 +809,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
736
809
  * empty note, or a later step leaves the step exactly as it was.
737
810
  */
738
811
  const preStep = async (payload, next) => {
812
+ /** '' until the enrichment call is actually about to happen. */
813
+ let usageRunId = ''
739
814
  try {
740
815
  const config = effectiveConfig()
741
816
  if (!config.enrichPrompts) return next()
@@ -746,8 +821,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
746
821
  if (draft.length < ENRICH_MIN_DRAFT_CHARS || draft.length > ENRICH_MAX_DRAFT_CHARS) return next()
747
822
  const sessionId = typeof payload.agent?.session?.id === 'string' ? payload.agent.session.id : (typeof payload.agent?.id === 'string' ? payload.agent.id : '')
748
823
  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,
824
+ const provider = sessionId.length > 0 ? providerForSession(sessionId) : COACH_PROVIDER
825
+ usageRunId = usage.beginRun({ type: 'prompt-enrichment', trigger: 'send', sessionId, model: config.model, provider })
826
+ const text = await callCoachModel(ctx, metered(usageRunId, { op: 'enrichment', sessionId }, {
827
+ provider,
751
828
  model: config.model,
752
829
  system: ENRICH_SYSTEM_PROMPT,
753
830
  userText: buildEnrichUserText({ draft, profile: safeProfile(), recentContext: recentContextOf(turns) }),
@@ -755,7 +832,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
755
832
  timeoutMs: ENRICH_TIMEOUT_MS,
756
833
  tool: ENRICH_TOOL,
757
834
  sessionId,
758
- })
835
+ }))
759
836
  const note = normalizeEnrichNote(text)
760
837
  if (note.length === 0) return next()
761
838
  const base = await next()
@@ -767,6 +844,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
767
844
  return { kind: 'enter', messages: [...base.messages, added] }
768
845
  } catch {
769
846
  return next()
847
+ } finally {
848
+ if (usageRunId !== '') closeRun(usageRunId)
770
849
  }
771
850
  }
772
851
 
@@ -784,22 +863,35 @@ export function createCoachService(ctx, store, effectiveConfig) {
784
863
  return out
785
864
  }
786
865
 
787
- /** One bootstrap at a time; progress is exposed through /state. */
788
- const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0 }
866
+ /** One bootstrap at a time; progress AND its running cost are exposed through /state. */
867
+ /** Live bootstrap progress for `/state`. `tokensTotal` is a single billed-token count
868
+ * deliberately not named `tokens`, which everywhere else in the ledger is the five-bucket object. */
869
+ const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0, runId: '', billedCalls: 0, unpricedCalls: 0, usdKnown: 0, tokensTotal: 0 }
870
+
871
+ /** Mirror the bootstrap run's live counters into the state the panel polls. */
872
+ const refreshBootstrapUsage = () => {
873
+ const summary = bootstrapState.runId === '' ? null : usage.runSummary(bootstrapState.runId)
874
+ if (summary === null) return
875
+ bootstrapState.billedCalls = summary.billedCalls
876
+ bootstrapState.unpricedCalls = summary.unpricedCalls
877
+ bootstrapState.usdKnown = summary.usdKnown
878
+ bootstrapState.tokensTotal = totalTokens(summary.tokens)
879
+ }
789
880
 
790
881
  /**
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.
882
+ * Which turns a bootstrap would analyze, newest first: every finished turn of
883
+ * one session (or of every live session), minus continuations, tiny prompts
884
+ * and turns that already have a report, capped at `limit`. Pure selection
885
+ * no model call, no run, no state touched so the preview and the batch it
886
+ * previews always answer from the same rule. `code` is `'no-session'` when a
887
+ * requested session is not live.
795
888
  */
796
- const runBootstrap = async ({ sessionId, limit }) => {
797
- if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '' }
889
+ const bootstrapCandidates = ({ sessionId, limit }) => {
798
890
  const svc = serviceOf(ctx)
799
891
  const pool = []
800
892
  if (typeof sessionId === 'string' && sessionId.length > 0) {
801
893
  const { session, turns } = turnsOf(svc, sessionId)
802
- if (session === undefined) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'no-session', detail: '' }
894
+ if (session === undefined) return { eligible: [], skipped: 0, code: 'no-session' }
803
895
  for (const turn of turns) if (turn?.finished === true) pool.push({ sessionId, turn, turns })
804
896
  } else {
805
897
  const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
@@ -821,10 +913,82 @@ export function createCoachService(ctx, store, effectiveConfig) {
821
913
  eligible.push(item)
822
914
  if (eligible.length >= limit) break
823
915
  }
916
+ return { eligible, skipped, code: '' }
917
+ }
918
+
919
+ /**
920
+ * What a bootstrap of `count` turns is likely to cost. The ledger wins as
921
+ * soon as it holds enough priced analyses (median, plus the one directive
922
+ * distillation a batch also pays for); until then the documented per-analysis
923
+ * figure for the configured model does.
924
+ */
925
+ const estimateBootstrap = (count, model) => {
926
+ const sample = usage.analysisCostSample()
927
+ if (sample.samples >= MEASURED_MIN_SAMPLES && typeof sample.perAnalysisUsd === 'number') {
928
+ const perAnalysisUsd = sample.perAnalysisUsd
929
+ const distillationUsd = typeof sample.distillationUsd === 'number' ? sample.distillationUsd : 0
930
+ return { usd: perAnalysisUsd * count + (count > 0 ? distillationUsd : 0), basis: 'measured', samples: sample.samples, perAnalysisUsd }
931
+ }
932
+ const perAnalysisUsd = DOC_ANALYSIS_USD[model] ?? DOC_ANALYSIS_USD['deepseek-v4-flash']
933
+ return { usd: perAnalysisUsd * count, basis: 'doc', samples: sample.samples, perAnalysisUsd }
934
+ }
935
+
936
+ /**
937
+ * "What would ⚡ Bootstrap do, and what would it cost?" — the same selection
938
+ * the run itself uses, priced from the ledger. Read-only: no model call, no
939
+ * run, and deliberately NOT blocked by a bootstrap already running.
940
+ */
941
+ const bootstrapPreview = ({ sessionId, limit }) => {
942
+ const config = effectiveConfig()
943
+ const { eligible, skipped, code } = bootstrapCandidates({ sessionId, limit })
944
+ const count = code === '' ? eligible.length : 0
945
+ return {
946
+ ok: code === '',
947
+ eligible: count,
948
+ skipped: code === '' ? skipped : 0,
949
+ limit,
950
+ model: config.model,
951
+ estimate: estimateBootstrap(count, config.model),
952
+ code,
953
+ detail: '',
954
+ }
955
+ }
956
+
957
+ /**
958
+ * "Learn from my last N turns now": explicit user action, so it ignores the
959
+ * daily auto budget. Skips continuations, tiny prompts and turns that already
960
+ * have a report; runs up to `bootstrapConcurrency` analyses at once (same
961
+ * number of calls either way); then forces one directive distillation.
962
+ */
963
+ const runBootstrap = async ({ sessionId, limit }) => {
964
+ if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '', run: null }
965
+ const { eligible, skipped, code } = bootstrapCandidates({ sessionId, limit })
966
+ if (code !== '') return { ok: false, analyzed: 0, skipped: 0, directives: 0, code, detail: '', run: null }
967
+ // Nothing to analyze is a no-op, not a run: an empty run would be written
968
+ // to the ledger as `failed` (no attempts) and read as a broken bootstrap.
969
+ if (eligible.length === 0) {
970
+ return { ok: true, analyzed: 0, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: null }
971
+ }
972
+ const config = effectiveConfig()
973
+ const scopedToSession = typeof sessionId === 'string' && sessionId.length > 0
974
+ // ONE parent run for the whole batch: every analysis and the forced
975
+ // distillation are attempts of it, so the panel shows one line, one price.
976
+ const runId = usage.beginRun({
977
+ type: 'bootstrap',
978
+ trigger: 'bootstrap',
979
+ sessionId: sessionId ?? '',
980
+ model: config.model,
981
+ provider: scopedToSession ? providerForSession(sessionId) : COACH_PROVIDER,
982
+ })
824
983
  bootstrapState.running = true
825
984
  bootstrapState.done = 0
826
985
  bootstrapState.total = eligible.length
827
986
  bootstrapState.startedAt = Date.now()
987
+ bootstrapState.runId = runId
988
+ bootstrapState.billedCalls = 0
989
+ bootstrapState.unpricedCalls = 0
990
+ bootstrapState.usdKnown = 0
991
+ bootstrapState.tokensTotal = 0
828
992
  let analyzed = 0
829
993
  try {
830
994
  // A small worker pool: each worker pulls the next eligible turn until the
@@ -837,23 +1001,77 @@ export function createCoachService(ctx, store, effectiveConfig) {
837
1001
  const item = eligible[next]
838
1002
  next += 1
839
1003
  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 })
1004
+ const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous, runId })
841
1005
  if (result !== null && typeof result === 'object' && result.ok === true) analyzed += 1
842
1006
  else console.warn('[tacit] bootstrap: ' + item.sessionId + ':' + item.turn.turn + ' skipped: ' + (result?.code ?? 'unknown'))
843
1007
  bootstrapState.done += 1
1008
+ refreshBootstrapUsage()
844
1009
  }
845
1010
  }
846
- const concurrency = Math.min(effectiveConfig().bootstrapConcurrency, Math.max(1, eligible.length))
1011
+ const concurrency = Math.min(config.bootstrapConcurrency, Math.max(1, eligible.length))
847
1012
  await Promise.all(Array.from({ length: concurrency }, () => worker()))
848
1013
  if (analyzed > 0) {
849
1014
  await service.flushAuto() // let any scheduled distillation settle before forcing one
850
1015
  // The forced distillation is attributed to the newest eligible turn's session.
851
- await maybeDistillDirectives(eligible[0].sessionId, providerForSession(eligible[0].sessionId), { force: true })
1016
+ await maybeDistillDirectives(eligible[0].sessionId, providerForSession(eligible[0].sessionId), { force: true, runId })
852
1017
  }
853
1018
  } finally {
854
1019
  bootstrapState.running = false
1020
+ closeRun(runId, { requested: limit, eligible: eligible.length, analyzed, skipped, directives: safeProfile().directives.length })
1021
+ refreshBootstrapUsage()
1022
+ }
1023
+ return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: usage.runSummary(runId) }
1024
+ }
1025
+
1026
+ /**
1027
+ * "Analyze exactly these turns": the user picked them, so nothing is filtered
1028
+ * out and the daily auto budget does not apply. One parent run covers the
1029
+ * whole batch (the analyses are its attempts); the same `runExclusive` key as
1030
+ * every other analysis means a turn already being analyzed — by a bootstrap,
1031
+ * an auto trigger or another batch — reports `busy` and costs nothing. A
1032
+ * bootstrap running elsewhere does NOT block the batch.
1033
+ */
1034
+ const analyzeBatch = async ({ sessionId, turns }) => {
1035
+ const { session } = turnsOf(serviceOf(ctx), sessionId)
1036
+ if (session === undefined) return { ok: false, results: [], profile: safeProfile(), run: null, code: 'no-session', detail: '' }
1037
+ const wanted = [...new Set(turns)].sort((a, b) => a - b)
1038
+ const config = effectiveConfig()
1039
+ const runId = usage.beginRun({
1040
+ type: 'analysis-batch',
1041
+ trigger: 'manual',
1042
+ sessionId,
1043
+ workspace: workspaceLabel(cwdOf(session)),
1044
+ model: config.model,
1045
+ provider: providerForSession(sessionId),
1046
+ })
1047
+ const results = new Array(wanted.length)
1048
+ let analyzed = 0
1049
+ try {
1050
+ // The same worker pool as the bootstrap: different turns never share an
1051
+ // in-flight key, so the pool is the only thing bounding concurrency.
1052
+ let next = 0
1053
+ const worker = async () => {
1054
+ while (next < wanted.length) {
1055
+ const at = next
1056
+ next += 1
1057
+ const turn = wanted[at]
1058
+ const result = await runAnalysis(sessionId, turn, { trigger: 'manual', runId })
1059
+ const ok = result !== null && typeof result === 'object' && result.ok === true
1060
+ if (ok) analyzed += 1
1061
+ results[at] = { turn, ok, code: result?.code ?? 'call-failed', report: ok ? result.report : null }
1062
+ }
1063
+ }
1064
+ const concurrency = Math.min(config.bootstrapConcurrency, Math.max(1, wanted.length))
1065
+ await Promise.all(Array.from({ length: concurrency }, () => worker()))
1066
+ } finally {
1067
+ // Every requested turn was already being analyzed elsewhere: no call was
1068
+ // made and nothing failed, so this is a real request that succeeded —
1069
+ // not the zero-attempt `failed` run the default derivation would write.
1070
+ const entries = results.filter((entry) => entry !== null && entry !== undefined)
1071
+ const allBusy = analyzed === 0 && entries.length === wanted.length && entries.every((entry) => entry.code === 'busy')
1072
+ closeRun(runId, { requested: wanted.length, analyzed, skipped: wanted.length - analyzed }, allBusy ? 'success' : undefined)
855
1073
  }
856
- return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '' }
1074
+ return { ok: true, results, profile: safeProfile(), run: usage.runSummary(runId), code: '', detail: '' }
857
1075
  }
858
1076
 
859
1077
  const autoStatus = () => {
@@ -931,6 +1149,11 @@ export function createCoachService(ctx, store, effectiveConfig) {
931
1149
  }
932
1150
 
933
1151
  const service = {
1152
+ /** The usage/cost ledger (flushed on dispose; read by the cost routes). */
1153
+ usage,
1154
+ /** The price source behind the ledger. */
1155
+ pricing,
1156
+
934
1157
  /** Await every in-flight automatic analysis (tests / orderly shutdown). */
935
1158
  async flushAuto() {
936
1159
  await Promise.all([...autoRunning])
@@ -941,6 +1164,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
941
1164
  const svc = serviceOf(ctx)
942
1165
  const sessionId = args !== null && typeof args === 'object' && typeof args.sessionId === 'string' && args.sessionId.length > 0 ? args.sessionId : null
943
1166
  const session = sessionId !== null && typeof svc.sessions?.get === 'function' ? svc.sessions.get(sessionId) : undefined
1167
+ refreshBootstrapUsage()
944
1168
  return {
945
1169
  ok: true,
946
1170
  config: effectiveConfig(),
@@ -975,21 +1199,30 @@ export function createCoachService(ctx, store, effectiveConfig) {
975
1199
  async analyzeTurn(args) {
976
1200
  const parsed = analyzeArgSchema.safeParse(args)
977
1201
  if (!parsed.success) {
978
- return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '' }
1202
+ return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '', run: null }
979
1203
  }
980
1204
  return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual' })
981
1205
  },
982
1206
 
1207
+ /** Analyze a hand-picked set of turns of one session under a single run. */
1208
+ async analyzeBatch(args) {
1209
+ const parsed = analyzeBatchArgSchema.safeParse(args)
1210
+ if (!parsed.success) {
1211
+ return { ok: false, results: [], profile: safeProfile(), run: null, code: 'bad-request', detail: '' }
1212
+ }
1213
+ return analyzeBatch(parsed.data)
1214
+ },
1215
+
983
1216
  async improveDraft(args) {
984
1217
  const parsed = improveArgSchema.safeParse(args)
985
1218
  if (!parsed.success) {
986
- return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'bad-request', detail: '' }
1219
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'bad-request', detail: '', run: null }
987
1220
  }
988
1221
  const { sessionId, draft } = parsed.data
989
1222
  const config = effectiveConfig()
990
1223
  const profile = safeProfile()
991
1224
  const svc = serviceOf(ctx)
992
- const { turns } = turnsOf(svc, sessionId)
1225
+ const { session, turns } = turnsOf(svc, sessionId)
993
1226
  const recentContext = recentContextOf(turns)
994
1227
  // Distillation also fires on user-triggered improve calls (soft, in-flight
995
1228
  // deduped, never awaited: an improve call is never blocked by it).
@@ -1007,12 +1240,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
1007
1240
  styleRules: profile.styleRules,
1008
1241
  negativeFeedback: lastDownReasons(profile, 3),
1009
1242
  })
1243
+ // Provider follows the session's own route (latest known), so proxy
1244
+ // or custom provider setups keep working; the shipped DeepSeek
1245
+ // adapter id is the fallback.
1246
+ const provider = providerForSession(sessionId)
1247
+ const runId = usage.beginRun({ type: 'improve', trigger: 'manual', sessionId, workspace: workspaceLabel(cwdOf(session)), model: config.model, provider })
1010
1248
  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, {
1249
+ let text = await callCoachModel(ctx, metered(runId, { op: 'improve', sessionId }, {
1016
1250
  provider,
1017
1251
  model: config.model,
1018
1252
  system: IMPROVE_SYSTEM_PROMPT,
@@ -1021,13 +1255,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
1021
1255
  timeoutMs: IMPROVE_TIMEOUT_MS,
1022
1256
  tool: IMPROVE_TOOL,
1023
1257
  sessionId,
1024
- })
1258
+ }))
1025
1259
  if (text.trim() === '') {
1026
- return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'empty-response', detail: '' }
1260
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'empty-response', detail: '', run: closeRun(runId) }
1027
1261
  }
1028
1262
  let parsed = parseJsonObject(text)
1029
1263
  if (parsed === null) {
1030
- const repaired = await callCoachModel(ctx, {
1264
+ const repaired = await callCoachModel(ctx, metered(runId, { op: 'improve-repair', sessionId }, {
1031
1265
  provider,
1032
1266
  model: config.model,
1033
1267
  system: IMPROVE_REPAIR_SYSTEM_PROMPT,
@@ -1036,7 +1270,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
1036
1270
  timeoutMs: IMPROVE_TIMEOUT_MS,
1037
1271
  tool: IMPROVE_TOOL,
1038
1272
  sessionId,
1039
- })
1273
+ }))
1040
1274
  if (repaired.trim() !== '') {
1041
1275
  const reparsed = parseJsonObject(repaired)
1042
1276
  if (reparsed !== null) parsed = reparsed
@@ -1052,10 +1286,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
1052
1286
  draft: draft.trim().slice(0, 1000),
1053
1287
  improved: result.improved.slice(0, 2000),
1054
1288
  })
1055
- return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '' }
1289
+ return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '', run: closeRun(runId) }
1056
1290
  } catch (error) {
1057
1291
  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 }
1292
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: coachErrorCode(error), detail, run: closeRun(runId) }
1293
+ } finally {
1294
+ // Idempotent: the returns above already closed it; this catches a throw.
1295
+ closeRun(runId)
1059
1296
  }
1060
1297
  },
1061
1298
 
@@ -1165,10 +1402,29 @@ export function createCoachService(ctx, store, effectiveConfig) {
1165
1402
 
1166
1403
  async bootstrap(args) {
1167
1404
  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: '' }
1405
+ if (!parsed.success) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'bad-request', detail: '', run: null }
1169
1406
  return runBootstrap({ sessionId: parsed.data.sessionId, limit: parsed.data.limit ?? 20 })
1170
1407
  },
1171
1408
 
1409
+ /** What a bootstrap would analyze and what it would cost. Free: no model call, no run. */
1410
+ async bootstrapPreview(args) {
1411
+ const parsed = bootstrapArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1412
+ if (!parsed.success) {
1413
+ const config = effectiveConfig()
1414
+ return {
1415
+ ok: false,
1416
+ eligible: 0,
1417
+ skipped: 0,
1418
+ limit: 20,
1419
+ model: config.model,
1420
+ estimate: estimateBootstrap(0, config.model),
1421
+ code: 'bad-request',
1422
+ detail: '',
1423
+ }
1424
+ }
1425
+ return bootstrapPreview({ sessionId: parsed.data.sessionId, limit: parsed.data.limit ?? 20 })
1426
+ },
1427
+
1172
1428
  async stats(args) {
1173
1429
  const parsed = statsArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1174
1430
  const window = parsed.success && typeof parsed.data.window === 'number' ? parsed.data.window : 20
@@ -1190,6 +1446,39 @@ export function createCoachService(ctx, store, effectiveConfig) {
1190
1446
  const removed = store.clearReports()
1191
1447
  return { ok: true, removed, code: '', detail: '' }
1192
1448
  },
1449
+
1450
+ /** The whole cost panel: period cards, series, breakdowns, warnings and one page of runs. */
1451
+ async usageReport(args) {
1452
+ const parsed = usageArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1453
+ if (!parsed.success) return { ok: false, code: 'bad-request', detail: '' }
1454
+ return usage.report({
1455
+ config: effectiveConfig(),
1456
+ pricingStatus: pricing.status(),
1457
+ pricingRates: pricing.rates(),
1458
+ filters: parsed.data,
1459
+ })
1460
+ },
1461
+
1462
+ /** One run with its attempt rows (a live run included); expired ids are a soft `unknown-run`. */
1463
+ async usageRun(args) {
1464
+ const parsed = usageRunArgSchema.safeParse(args)
1465
+ if (!parsed.success) return { ok: false, run: null, code: 'bad-request', detail: '' }
1466
+ const run = usage.run(parsed.data.runId)
1467
+ if (run === null) return { ok: false, run: null, code: 'unknown-run', detail: '' }
1468
+ return { ok: true, run, code: '', detail: '' }
1469
+ },
1470
+
1471
+ /** Delete the ledger and restart the tracking window (live runs keep recording into it). */
1472
+ async usageClear() {
1473
+ const { removed, trackingSince } = usage.clear()
1474
+ return { ok: true, removed, trackingSince, code: '', detail: '' }
1475
+ },
1476
+
1477
+ /** Re-read the optional costMeter sibling; `refresh()` never throws, so this never fails. */
1478
+ async pricingRefresh() {
1479
+ await pricing.refresh()
1480
+ return { ok: true, pricing: { ...pricing.status(), rates: pricing.rates() }, code: '', detail: '' }
1481
+ },
1193
1482
  }
1194
1483
 
1195
1484
  return service