dsh-tacit 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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 {
@@ -44,6 +52,9 @@ import {
44
52
  normalizeReport,
45
53
  parseJsonObject,
46
54
  ANALYSIS_SYSTEM_PROMPT,
55
+ GOOD_SYSTEM_PROMPT,
56
+ GOOD_TOOL,
57
+ normalizeGoodReport,
47
58
  ANALYSIS_REPAIR_SYSTEM_PROMPT,
48
59
  IMPROVE_SYSTEM_PROMPT,
49
60
  IMPROVE_REPAIR_SYSTEM_PROMPT,
@@ -71,7 +82,10 @@ import {
71
82
  DIRECTIVE_TIMEOUT_MS,
72
83
  MAX_DIRECTIVES,
73
84
  buildDirectiveUserText,
85
+ buildSteeringSection,
74
86
  renderSteeringSection,
87
+ workspaceLabel,
88
+ MAX_WORKSPACE_DIRECTIVES,
75
89
  ENRICH_SYSTEM_PROMPT,
76
90
  ENRICH_TOOL,
77
91
  ENRICH_MAX_TOKENS,
@@ -86,6 +100,13 @@ import {
86
100
 
87
101
  /** In-memory rewrite ledger bounds (never persisted). */
88
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
89
110
  /** Pending outcome verifications kept per session (FIFO, oldest dropped). */
90
111
  const MAX_PENDING_VERIFICATIONS = 20
91
112
 
@@ -111,6 +132,11 @@ export function mergeConfig(base, patch) {
111
132
  merged.enrichPrompts = merged.enrichPrompts === true
112
133
  merged.directiveTrialTurns = Math.max(1, Math.min(500, Math.round(Number(merged.directiveTrialTurns) || 10)))
113
134
  merged.directiveWorseBy = Math.max(0, Math.min(1, Number.isFinite(Number(merged.directiveWorseBy)) ? Number(merged.directiveWorseBy) : 0.15))
135
+ merged.bootstrapConcurrency = Math.max(1, Math.min(4, Math.round(Number(merged.bootstrapConcurrency) || 1)))
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))
114
140
  return merged
115
141
  }
116
142
 
@@ -134,15 +160,44 @@ function turnsOf(service, sessionId) {
134
160
  }
135
161
  }
136
162
 
137
- /** A human label for a session: the workspace directory's basename, else ''. */
138
- function sessionLabelOf(service, sessionId) {
139
- const session = typeof service.sessions?.get === 'function' ? service.sessions.get(sessionId) : undefined
163
+ /** The absolute workspace directory a session was created in, else undefined. */
164
+ function cwdOf(session) {
140
165
  const cwd = session !== null && typeof session === 'object' && session.header !== null && typeof session.header === 'object'
141
166
  ? session.header.cwd
142
167
  : undefined
143
- if (typeof cwd !== 'string' || cwd.length === 0) return ''
144
- const parts = cwd.split(/[\\/]+/).filter((part) => part.length > 0)
145
- return parts.length > 0 ? parts[parts.length - 1] : ''
168
+ return typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined
169
+ }
170
+
171
+ /** A human label for a session: the workspace directory's basename, else ''. */
172
+ function sessionLabelOf(service, sessionId) {
173
+ const session = typeof service.sessions?.get === 'function' ? service.sessions.get(sessionId) : undefined
174
+ return workspaceLabel(cwdOf(session))
175
+ }
176
+
177
+ /** Every distinct workspace among the live sessions, labelled for the UI. */
178
+ function listWorkspaces(service) {
179
+ const sessions = typeof service.sessions?.list === 'function' ? service.sessions.list() : []
180
+ const seen = new Map()
181
+ for (const session of Array.isArray(sessions) ? sessions : []) {
182
+ const cwd = cwdOf(session)
183
+ if (cwd !== undefined && !seen.has(cwd)) seen.set(cwd, { cwd, label: workspaceLabel(cwd) })
184
+ }
185
+ return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label))
186
+ }
187
+
188
+ /** At most MAX_DIRECTIVES global directives and MAX_WORKSPACE_DIRECTIVES per workspace, order kept. */
189
+ function capDirectives(list) {
190
+ const counts = new Map()
191
+ const out = []
192
+ for (const entry of list) {
193
+ const scope = typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : ''
194
+ const limit = scope === '' ? MAX_DIRECTIVES : MAX_WORKSPACE_DIRECTIVES
195
+ const n = counts.get(scope) ?? 0
196
+ if (n >= limit) continue
197
+ counts.set(scope, n + 1)
198
+ out.push(entry)
199
+ }
200
+ return out
146
201
  }
147
202
 
148
203
  /** Short, secret-free context digest of a session's last two finished turns. */
@@ -192,23 +247,50 @@ function lastFinishedTurnOf(turns) {
192
247
  return finished.length > 0 ? finished[finished.length - 1] : null
193
248
  }
194
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
+ */
195
261
  function coachErrorCode(error) {
196
262
  const message = error instanceof Error ? error.message : String(error)
197
- if (error !== null && typeof error === 'object' && typeof error.code === 'string') return error.code
198
- if (/abort|aborted|timeout/i.test(message)) return 'timeout'
199
- if (/auth|401|403|api key|key not/i.test(message)) return 'no-api-key'
200
- 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'
201
274
  return 'call-failed'
202
275
  }
203
276
 
204
- /** Local calendar day key for the daily auto budget. */
205
- function dayKey(now = Date.now()) {
206
- const date = new Date(now)
207
- const pad = (value) => String(value).padStart(2, '0')
208
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
209
- }
210
-
211
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
+
212
294
  const inFlight = new Map()
213
295
  /** Turns already handed to automatic analysis (sessionId:turn). */
214
296
  const autoSeen = new Set()
@@ -220,6 +302,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
220
302
  const rewriteRecords = new Map()
221
303
  /** sessionId → FIFO [{rewriteId, baseline}] (one per applied rewrite). */
222
304
  const pendingVerifications = new Map()
305
+ /** sessionId → ids of the directives in that session's frozen steering (bounded; insertion order = age). */
306
+ const steeringIdsBySession = new Map()
307
+ const MAX_STEERING_SESSIONS = 500
223
308
  let distillInFlight = false
224
309
  let rewriteSeq = 0
225
310
 
@@ -235,7 +320,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
235
320
  return 'd' + Date.now().toString(36) + '-' + directiveSeq.toString(36)
236
321
  }
237
322
  let directivesInFlight = false
238
- /** Steering text frozen per live session (keeps the model's prefix cache stable within a session). */
323
+ /** Steering `{ text, ids }` frozen per live session object (keeps the model's prefix cache stable within a session). */
239
324
  const steeringFrozen = new WeakMap()
240
325
 
241
326
  const nextRewriteId = () => {
@@ -272,7 +357,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
272
357
  profile.patterns = profile.patterns.slice(0, config.maxPatterns)
273
358
  profile.styleRules = profile.styleRules.slice(-MAX_STYLE_RULES)
274
359
  profile.feedbackLog = profile.feedbackLog.slice(-MAX_FEEDBACK_LOG)
275
- profile.directives = profile.directives.slice(0, MAX_DIRECTIVES)
360
+ profile.directives = capDirectives(profile.directives)
276
361
  profile.updatedAt = Date.now()
277
362
  const validated = profileSchema.parse(profile)
278
363
  store.saveProfile(validated)
@@ -288,11 +373,15 @@ export function createCoachService(ctx, store, effectiveConfig) {
288
373
  */
289
374
  const maybeDistill = async (profile, provider, sessionId) => {
290
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 })
291
381
  distillInFlight = true
292
382
  try {
293
- const config = effectiveConfig()
294
383
  const reasons = lastDownReasons(profile, 3)
295
- const text = await callCoachModel(ctx, {
384
+ const text = await callCoachModel(ctx, metered(runId, { op: 'style-distillation', sessionId }, {
296
385
  provider,
297
386
  model: config.model,
298
387
  system: DISTILL_SYSTEM_PROMPT,
@@ -301,7 +390,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
301
390
  timeoutMs: DISTILL_TIMEOUT_MS,
302
391
  tool: DISTILL_TOOL,
303
392
  sessionId,
304
- })
393
+ }))
305
394
  const rules = normalizeDistillRules(text)
306
395
  if (rules.length === 0) return profile
307
396
  const fresh = safeProfile()
@@ -311,6 +400,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
311
400
  } catch {
312
401
  return profile
313
402
  } finally {
403
+ closeRun(runId)
314
404
  distillInFlight = false
315
405
  }
316
406
  }
@@ -354,10 +444,12 @@ export function createCoachService(ctx, store, effectiveConfig) {
354
444
 
355
445
  /**
356
446
  * Directive trials ride the same free feed: every NEW finished turn counts
357
- * toward each candidate; after `directiveTrialTurns` the candidate is
358
- * activated, or retired when the messy rate rose past the baseline by more
359
- * than `directiveWorseBy`. Steering text is frozen per session, so a verdict
360
- * reaches new sessions only by design.
447
+ * toward each candidate that was actually in that session's frozen steering
448
+ * text; after `directiveTrialTurns` such turns the candidate is activated,
449
+ * or retired when the messy rate rose past the baseline by more than
450
+ * `directiveWorseBy`. A session whose steering was never assembled here
451
+ * (started before the candidate existed, or before a restart) counts toward
452
+ * nobody — its turns say nothing about the candidate.
361
453
  */
362
454
  const recordTrialTurns = (sessionId, turns) => {
363
455
  const fresh = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object'
@@ -365,8 +457,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
365
457
  && !seenFinished.has(sessionId + ':' + turn.turn))
366
458
  if (fresh.length === 0) return
367
459
  for (const turn of fresh) seenFinished.add(sessionId + ':' + turn.turn)
460
+ const steered = steeringIdsBySession.get(sessionId)
461
+ if (steered === undefined || steered.length === 0) return
368
462
  const profile = safeProfile()
369
- const candidates = profile.directives.filter((entry) => entry.status === 'candidate' && entry.trial !== undefined)
463
+ const candidates = profile.directives.filter((entry) => entry.status === 'candidate' && entry.trial !== undefined && steered.includes(entry.id))
370
464
  if (candidates.length === 0) return
371
465
  const config = effectiveConfig()
372
466
  const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
@@ -425,13 +519,18 @@ export function createCoachService(ctx, store, effectiveConfig) {
425
519
  return earlier.length > 0 ? earlier[earlier.length - 1] : null
426
520
  }
427
521
 
428
- const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null } = {}) => {
522
+ const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null, runId = '' } = {}) => {
429
523
  const profile = safeProfile()
430
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 === ''
431
529
  const exclusive = runExclusive(key, async () => {
432
530
  const svc = serviceOf(ctx)
433
531
  const { session, turns } = turnsOf(svc, sessionId)
434
532
  if (session === undefined) return { ok: false, report: null, profile, code: 'no-session', detail: '' }
533
+ const cwd = cwdOf(session)
435
534
  // The change feed already carries the digest; a manual click re-reads the snapshot.
436
535
  const record = digest !== null && typeof digest === 'object' ? digest : turns.find((candidate) => candidate?.turn === turn)
437
536
  if (record === undefined) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
@@ -444,11 +543,52 @@ export function createCoachService(ctx, store, effectiveConfig) {
444
543
  const userText = buildAnalysisUserText(record, { followUp, previous })
445
544
  if (userText === null) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
446
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
447
555
  try {
448
- const provider = typeof record.provider === 'string' && record.provider.length > 0
449
- ? record.provider
450
- : COACH_PROVIDER
451
- let text = await callCoachModel(ctx, {
556
+ if (trigger === 'good') {
557
+ // One attempt, no repair retry: a recovery lesson is a bonus, not a diagnosis.
558
+ const goodText = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis', sessionId, turn }, {
559
+ provider,
560
+ model: config.model,
561
+ system: GOOD_SYSTEM_PROMPT,
562
+ userText,
563
+ maxTokens: ANALYZE_MAX_TOKENS,
564
+ timeoutMs: ANALYZE_TIMEOUT_MS,
565
+ tool: GOOD_TOOL,
566
+ sessionId,
567
+ }))
568
+ const goodParsed = goodText.trim() === '' ? null : parseJsonObject(goodText)
569
+ if (goodParsed === null) return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
570
+ const goodReport = {
571
+ ...normalizeGoodReport(goodParsed, { turn, time: Date.now(), model: config.model, prompt: record.prompt }),
572
+ ...(typeof record.prompt === 'string' && record.prompt.length > 0 ? { promptExcerpt: clipSafe(record.prompt, 200) } : {}),
573
+ trigger,
574
+ ...(cwd !== undefined ? { cwd } : {}),
575
+ }
576
+ if (goodReport.lesson === '' && goodReport.strengths.length === 0) {
577
+ return { ok: false, report: null, profile, code: 'nothing-learned', detail: '' }
578
+ }
579
+ const isNew = store.report(sessionId, turn) === null
580
+ store.saveReport(sessionId, turn, goodReport)
581
+ const grown = aggregateProfile(store.profile(), goodReport, config.maxPatterns, { countNew: isNew })
582
+ if (isNew) grown.analysesSinceDirectives = (grown.analysesSinceDirectives ?? 0) + 1
583
+ store.saveProfile(grown)
584
+ if (isNew && !bootstrapState.running) {
585
+ const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
586
+ autoRunning.add(task)
587
+ }
588
+ ok = true
589
+ return { ok: true, report: goodReport, profile: grown, code: '', detail: '' }
590
+ }
591
+ let text = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis', sessionId, turn }, {
452
592
  provider,
453
593
  model: config.model,
454
594
  system: ANALYSIS_SYSTEM_PROMPT,
@@ -457,14 +597,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
457
597
  timeoutMs: ANALYZE_TIMEOUT_MS,
458
598
  tool: ANALYSIS_TOOL,
459
599
  sessionId,
460
- })
600
+ }))
461
601
  if (text.trim() === '') {
462
602
  return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
463
603
  }
464
604
  let parsed = parseJsonObject(text)
465
605
  if (parsed === null) {
466
606
  // One-shot repair: the model answered in prose; re-ask for strict JSON.
467
- const repaired = await callCoachModel(ctx, {
607
+ const repaired = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis-repair', sessionId, turn }, {
468
608
  provider,
469
609
  model: config.model,
470
610
  system: ANALYSIS_REPAIR_SYSTEM_PROMPT,
@@ -473,7 +613,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
473
613
  timeoutMs: ANALYZE_TIMEOUT_MS,
474
614
  tool: ANALYSIS_TOOL,
475
615
  sessionId,
476
- })
616
+ }))
477
617
  if (repaired.trim() !== '') {
478
618
  const reparsed = parseJsonObject(repaired)
479
619
  if (reparsed !== null) {
@@ -494,6 +634,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
494
634
  : {}),
495
635
  trigger,
496
636
  ...(followUp.length > 0 ? { followUp: clipSafe(followUp, 300) } : {}),
637
+ ...(cwd !== undefined ? { cwd } : {}),
497
638
  }
498
639
  const countNew = store.report(sessionId, turn) === null
499
640
  store.saveReport(sessionId, turn, report)
@@ -504,33 +645,48 @@ export function createCoachService(ctx, store, effectiveConfig) {
504
645
  const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
505
646
  autoRunning.add(task)
506
647
  }
648
+ ok = true
507
649
  return { ok: true, report, profile: nextProfile, code: '', detail: '' }
508
650
  } catch (error) {
509
651
  const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
510
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 })
511
656
  }
512
657
  })
513
- if (exclusive === null) return Promise.resolve({ ok: false, report: null, profile, code: 'busy', detail: '' })
514
- 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
+ }))
515
663
  }
516
664
 
517
- const steeringStatus = () => {
665
+ const steeringStatus = (cwd) => {
518
666
  const config = effectiveConfig()
519
- return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile()) : '' }
667
+ return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile(), { cwd }) : '' }
520
668
  }
521
669
 
670
+ /** What a session in `cwd` assembling its system prompt right now would get. */
671
+ const steeringNow = (cwd) => (effectiveConfig().steerAgent ? buildSteeringSection(safeProfile(), { cwd }) : { text: '', ids: [] })
672
+
522
673
  /** The system-prompt section provider (sync; frozen per session). */
523
674
  const steeringText = (assemble) => {
524
675
  const session = assemble !== null && typeof assemble === 'object' && assemble.agent !== null && typeof assemble.agent === 'object'
525
676
  ? assemble.agent.session
526
677
  : undefined
527
- if (session === null || session === undefined || typeof session !== 'object') return steeringStatus().text
678
+ if (session === null || session === undefined || typeof session !== 'object') return steeringNow().text
528
679
  let frozen = steeringFrozen.get(session)
529
680
  if (frozen === undefined) {
530
- frozen = steeringStatus().text
681
+ frozen = steeringNow(cwdOf(session))
531
682
  steeringFrozen.set(session, frozen)
683
+ if (typeof session.id === 'string' && session.id.length > 0) {
684
+ steeringIdsBySession.delete(session.id)
685
+ steeringIdsBySession.set(session.id, frozen.ids)
686
+ while (steeringIdsBySession.size > MAX_STEERING_SESSIONS) steeringIdsBySession.delete(steeringIdsBySession.keys().next().value)
687
+ }
532
688
  }
533
- return frozen
689
+ return frozen.text
534
690
  }
535
691
 
536
692
  /**
@@ -538,48 +694,82 @@ export function createCoachService(ctx, store, effectiveConfig) {
538
694
  * entries are untouched, and a re-emitted directive keeps the enabled flag
539
695
  * the user gave its identical text. Capped at MAX_DIRECTIVES overall.
540
696
  */
541
- const mergeDirectives = (profile, texts) => {
542
- const previous = new Map(profile.directives.filter((entry) => entry.source !== 'user').map((entry) => [entry.text.trim().toLowerCase(), entry]))
697
+ const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : '')
698
+ const directiveKey = (scope, text) => scope + '\n' + text.trim().toLowerCase()
699
+
700
+ /** Messy-turn baseline for a new candidate: the workspace's own turns when there are enough, else everything. */
701
+ const baselineRateFor = (cwd) => {
702
+ const scoped = cwd !== undefined ? allFinishedTurns({ cwd }) : []
703
+ const turns = scoped.length >= 20 ? scoped : allFinishedTurns()
704
+ return computeTrend(turns, { window: 20 }).recent.messyRate
705
+ }
706
+
707
+ /**
708
+ * Merge the model's new complete set of directives ({ text, workspace? }):
709
+ * user entries are untouched; the global distilled set and the distilled set
710
+ * of every workspace the model mentioned are replaced; distilled entries of
711
+ * other workspaces are kept (their evidence was not in this batch). A
712
+ * re-emitted directive keeps its identity, state and enabled flag.
713
+ */
714
+ const mergeDirectives = (profile, items) => {
543
715
  const users = profile.directives.filter((entry) => entry.source === 'user')
544
- const userKeys = new Set(users.map((entry) => entry.text.trim().toLowerCase()))
716
+ const userKeys = new Set(users.map((entry) => directiveKey(scopeOf(entry), entry.text)))
717
+ const prior = profile.directives.filter((entry) => entry.source !== 'user')
718
+ const previous = new Map(prior.map((entry) => [directiveKey(scopeOf(entry), entry.text), entry]))
719
+ const mentioned = new Set([''])
720
+ for (const item of items) mentioned.add(scopeOf(item))
721
+ const untouched = prior.filter((entry) => !mentioned.has(scopeOf(entry)))
545
722
  const distilled = []
546
723
  const seen = new Set()
547
- let baselineRate = null
548
- for (const text of texts) {
549
- const key = text.trim().toLowerCase()
724
+ const baselines = new Map()
725
+ for (const item of items) {
726
+ const scope = scopeOf(item)
727
+ const key = directiveKey(scope, item.text)
550
728
  if (seen.has(key) || userKeys.has(key)) continue
551
729
  seen.add(key)
552
730
  const kept = previous.get(key)
553
731
  if (kept !== undefined) {
554
- distilled.push({ ...kept, text })
732
+ distilled.push({ ...kept, text: item.text })
555
733
  continue
556
734
  }
557
735
  // A new distilled directive goes on trial against the current messy-turn rate.
558
- if (baselineRate === null) baselineRate = computeTrend(allFinishedTurns(), { window: 20 }).recent.messyRate
736
+ if (!baselines.has(scope)) baselines.set(scope, baselineRateFor(scope === '' ? undefined : scope))
559
737
  distilled.push({
560
738
  id: nextDirectiveId(),
561
- text,
739
+ text: item.text,
562
740
  enabled: true,
563
741
  source: 'distilled',
564
742
  createdAt: Date.now(),
565
743
  status: 'candidate',
566
- trial: { turns: 0, messy: 0, baselineRate, startedAt: Date.now() },
744
+ trial: { turns: 0, messy: 0, baselineRate: baselines.get(scope), startedAt: Date.now() },
745
+ ...(scope === '' ? {} : { workspace: scope }),
567
746
  })
568
747
  }
569
- profile.directives = [...users, ...distilled].slice(0, MAX_DIRECTIVES)
748
+ profile.directives = capDirectives([...users, ...distilled, ...untouched])
570
749
  return profile
571
750
  }
572
751
 
573
752
  /** ONE small call every `directiveEvery` new analyses (or forced). Soft-fails; never throws. */
574
- const maybeDistillDirectives = async (sessionId, provider, { force = false } = {}) => {
753
+ const maybeDistillDirectives = async (sessionId, provider, { force = false, runId = '' } = {}) => {
575
754
  if (directivesInFlight) return
576
755
  const config = effectiveConfig()
577
756
  let profile = safeProfile()
578
757
  if (!force && profile.analysesSinceDirectives < config.directiveEvery) return
579
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
580
763
  try {
581
764
  const recent = store.listAllReports(20).map((entry) => store.report(entry.sessionId, entry.turn)).filter((report) => report !== null)
582
- const text = await callCoachModel(ctx, {
765
+ // The model sees workspace names only; map them back to the directories they stand for.
766
+ const workspaces = new Map()
767
+ for (const report of recent) {
768
+ if (typeof report.cwd !== 'string' || report.cwd.length === 0) continue
769
+ const label = workspaceLabel(report.cwd)
770
+ if (label.length > 0 && !workspaces.has(label)) workspaces.set(label, report.cwd)
771
+ }
772
+ const text = await callCoachModel(ctx, metered(usageRunId, { op: 'directive-distillation', sessionId }, {
583
773
  provider,
584
774
  model: config.model,
585
775
  system: DIRECTIVE_SYSTEM_PROMPT,
@@ -588,21 +778,26 @@ export function createCoachService(ctx, store, effectiveConfig) {
588
778
  timeoutMs: DIRECTIVE_TIMEOUT_MS,
589
779
  tool: DIRECTIVE_TOOL,
590
780
  sessionId,
591
- })
592
- const { kept: texts, rejected } = classifyDirectives(text)
781
+ }))
782
+ const { kept, rejected } = classifyDirectives(text)
593
783
  for (const dropped of rejected) console.warn('[tacit] dropped directive (it asks the user instead of compensating):', dropped)
594
- if (texts.length === 0) {
784
+ if (kept.length === 0) {
595
785
  console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
596
786
  return
597
787
  }
598
- profile = mergeDirectives(safeProfile(), texts)
788
+ const items = kept.map((item) => (item.workspace !== undefined && workspaces.has(item.workspace)
789
+ ? { text: item.text, workspace: workspaces.get(item.workspace) }
790
+ : { text: item.text }))
791
+ profile = mergeDirectives(safeProfile(), items)
599
792
  profile.analysesSinceDirectives = 0
600
793
  capAndSaveProfile(profile)
601
- console.info('[tacit] distilled ' + texts.length + ' directive(s) into the steering section')
794
+ const scoped = items.filter((item) => item.workspace !== undefined).length
795
+ console.info('[tacit] distilled ' + items.length + ' directive(s) into the steering section' + (scoped > 0 ? ' (' + scoped + ' workspace-specific)' : ''))
602
796
  } catch (error) {
603
797
  // Soft: the counter stays and the next analysis retries.
604
798
  console.warn('[tacit] directive distillation failed (will retry):', error instanceof Error ? error.message : String(error))
605
799
  } finally {
800
+ if (ownRun) closeRun(usageRunId)
606
801
  directivesInFlight = false
607
802
  }
608
803
  }
@@ -614,6 +809,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
614
809
  * empty note, or a later step leaves the step exactly as it was.
615
810
  */
616
811
  const preStep = async (payload, next) => {
812
+ /** '' until the enrichment call is actually about to happen. */
813
+ let usageRunId = ''
617
814
  try {
618
815
  const config = effectiveConfig()
619
816
  if (!config.enrichPrompts) return next()
@@ -624,8 +821,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
624
821
  if (draft.length < ENRICH_MIN_DRAFT_CHARS || draft.length > ENRICH_MAX_DRAFT_CHARS) return next()
625
822
  const sessionId = typeof payload.agent?.session?.id === 'string' ? payload.agent.session.id : (typeof payload.agent?.id === 'string' ? payload.agent.id : '')
626
823
  const { turns } = sessionId.length > 0 ? turnsOf(serviceOf(ctx), sessionId) : { turns: [] }
627
- const text = await callCoachModel(ctx, {
628
- 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,
629
828
  model: config.model,
630
829
  system: ENRICH_SYSTEM_PROMPT,
631
830
  userText: buildEnrichUserText({ draft, profile: safeProfile(), recentContext: recentContextOf(turns) }),
@@ -633,7 +832,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
633
832
  timeoutMs: ENRICH_TIMEOUT_MS,
634
833
  tool: ENRICH_TOOL,
635
834
  sessionId,
636
- })
835
+ }))
637
836
  const note = normalizeEnrichNote(text)
638
837
  if (note.length === 0) return next()
639
838
  const base = await next()
@@ -645,37 +844,54 @@ export function createCoachService(ctx, store, effectiveConfig) {
645
844
  return { kind: 'enter', messages: [...base.messages, added] }
646
845
  } catch {
647
846
  return next()
847
+ } finally {
848
+ if (usageRunId !== '') closeRun(usageRunId)
648
849
  }
649
850
  }
650
851
 
651
- /** Every live session's finished turns, for the measured trend. */
652
- const allFinishedTurns = () => {
852
+ /** Every live session's finished turns (optionally only sessions in one workspace), for the measured trend. */
853
+ const allFinishedTurns = ({ cwd } = {}) => {
653
854
  const svc = serviceOf(ctx)
654
855
  const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
655
856
  const out = []
656
857
  for (const session of Array.isArray(sessions) ? sessions : []) {
657
858
  if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
859
+ if (cwd !== undefined && cwdOf(session) !== cwd) continue
658
860
  const { turns } = turnsOf(svc, session.id)
659
861
  for (const turn of turns) if (turn?.finished === true) out.push(turn)
660
862
  }
661
863
  return out
662
864
  }
663
865
 
664
- /** One bootstrap at a time; progress is exposed through /state. */
665
- 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
+ }
666
880
 
667
881
  /**
668
- * "Learn from my last N turns now": explicit user action, so it ignores the
669
- * daily auto budget. Skips continuations, tiny prompts and turns that already
670
- * have a report; 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.
671
888
  */
672
- const runBootstrap = async ({ sessionId, limit }) => {
673
- if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '' }
889
+ const bootstrapCandidates = ({ sessionId, limit }) => {
674
890
  const svc = serviceOf(ctx)
675
891
  const pool = []
676
892
  if (typeof sessionId === 'string' && sessionId.length > 0) {
677
893
  const { session, turns } = turnsOf(svc, sessionId)
678
- 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' }
679
895
  for (const turn of turns) if (turn?.finished === true) pool.push({ sessionId, turn, turns })
680
896
  } else {
681
897
  const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
@@ -697,29 +913,165 @@ export function createCoachService(ctx, store, effectiveConfig) {
697
913
  eligible.push(item)
698
914
  if (eligible.length >= limit) break
699
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
+ })
700
983
  bootstrapState.running = true
701
984
  bootstrapState.done = 0
702
985
  bootstrapState.total = eligible.length
703
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
704
992
  let analyzed = 0
705
- let lastProvider = COACH_PROVIDER
706
993
  try {
707
- for (const item of eligible) {
708
- const previous = previousFinishedOf(item.turns, item.turn.turn)
709
- const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous })
710
- if (result !== null && typeof result === 'object' && result.ok === true) analyzed += 1
711
- else console.warn('[tacit] bootstrap: ' + item.sessionId + ':' + item.turn.turn + ' skipped: ' + (result?.code ?? 'unknown'))
712
- lastProvider = providerForSession(item.sessionId)
713
- bootstrapState.done += 1
994
+ // A small worker pool: each worker pulls the next eligible turn until the
995
+ // list is drained. Analyses for different turns never share an in-flight
996
+ // key, and the profile read-modify-write inside runAnalysis has no await,
997
+ // so concurrent analyses cannot lose each other's counts.
998
+ let next = 0
999
+ const worker = async () => {
1000
+ while (next < eligible.length) {
1001
+ const item = eligible[next]
1002
+ next += 1
1003
+ const previous = previousFinishedOf(item.turns, item.turn.turn)
1004
+ const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous, runId })
1005
+ if (result !== null && typeof result === 'object' && result.ok === true) analyzed += 1
1006
+ else console.warn('[tacit] bootstrap: ' + item.sessionId + ':' + item.turn.turn + ' skipped: ' + (result?.code ?? 'unknown'))
1007
+ bootstrapState.done += 1
1008
+ refreshBootstrapUsage()
1009
+ }
714
1010
  }
1011
+ const concurrency = Math.min(config.bootstrapConcurrency, Math.max(1, eligible.length))
1012
+ await Promise.all(Array.from({ length: concurrency }, () => worker()))
715
1013
  if (analyzed > 0) {
716
1014
  await service.flushAuto() // let any scheduled distillation settle before forcing one
717
- await maybeDistillDirectives(eligible[0].sessionId, lastProvider, { force: true })
1015
+ // The forced distillation is attributed to the newest eligible turn's session.
1016
+ await maybeDistillDirectives(eligible[0].sessionId, providerForSession(eligible[0].sessionId), { force: true, runId })
718
1017
  }
719
1018
  } finally {
720
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)
721
1073
  }
722
- return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '' }
1074
+ return { ok: true, results, profile: safeProfile(), run: usage.runSummary(runId), code: '', detail: '' }
723
1075
  }
724
1076
 
725
1077
  const autoStatus = () => {
@@ -755,12 +1107,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
755
1107
  }
756
1108
 
757
1109
  /**
758
- * Zero-click learning. Two triggers, both free (no model call to decide):
1110
+ * Zero-click learning. Three triggers, all free (no model call to decide):
759
1111
  * - the newest FINISHED turn is messy (retries / tool errors / compactions /
760
1112
  * rejection / long step run);
761
1113
  * - the newest (possibly unfinished) turn's prompt reads as a correction of
762
1114
  * the previous answer → the PREVIOUS turn is analyzed with that
763
- * follow-up attached as evidence.
1115
+ * follow-up attached as evidence;
1116
+ * - (learnFromGood) the newest finished turn is clean right after a messy
1117
+ * one → a small "what did the user include this time" call.
764
1118
  * Turns finished before the plugin started are ignored (cold restore).
765
1119
  */
766
1120
  const maybeAutoAnalyze = (sessionId, turns) => {
@@ -776,6 +1130,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
776
1130
  // the conversation is its context. Heavy work after it is not a prompt fault.
777
1131
  if (fresh(newest) && isMessyTurn(newest, { minSteps: config.autoMinSteps }) && !looksLikeContinuation(newest.prompt)) {
778
1132
  scheduleAuto(sessionId, newest.turn, { trigger: 'auto', digest: newest, previousDigest: previous })
1133
+ return
1134
+ }
1135
+ // A recovery: clean now, messy just before, with a real prompt in between.
1136
+ const recovery = config.learnFromGood && fresh(newest) && !looksLikeContinuation(newest.prompt)
1137
+ && typeof newest.prompt === 'string' && newest.prompt.trim().length >= ENRICH_MIN_DRAFT_CHARS
1138
+ && previous !== null && previous.finished === true && isMessyTurn(previous, { minSteps: config.autoMinSteps })
1139
+ if (recovery) {
1140
+ scheduleAuto(sessionId, newest.turn, { trigger: 'good', digest: newest, previousDigest: previous })
779
1141
  }
780
1142
  return
781
1143
  }
@@ -787,18 +1149,29 @@ export function createCoachService(ctx, store, effectiveConfig) {
787
1149
  }
788
1150
 
789
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
+
790
1157
  /** Await every in-flight automatic analysis (tests / orderly shutdown). */
791
1158
  async flushAuto() {
792
1159
  await Promise.all([...autoRunning])
793
1160
  },
794
1161
 
795
- async getState() {
1162
+ /** Optional `{ sessionId }`: the steering preview is then rendered for that conversation's workspace. */
1163
+ async getState(args) {
1164
+ const svc = serviceOf(ctx)
1165
+ const sessionId = args !== null && typeof args === 'object' && typeof args.sessionId === 'string' && args.sessionId.length > 0 ? args.sessionId : null
1166
+ const session = sessionId !== null && typeof svc.sessions?.get === 'function' ? svc.sessions.get(sessionId) : undefined
1167
+ refreshBootstrapUsage()
796
1168
  return {
797
1169
  ok: true,
798
1170
  config: effectiveConfig(),
799
1171
  profile: safeProfile(),
800
1172
  auto: autoStatus(),
801
- steering: steeringStatus(),
1173
+ steering: steeringStatus(cwdOf(session)),
1174
+ workspaces: listWorkspaces(svc),
802
1175
  bootstrap: { ...bootstrapState },
803
1176
  message: '',
804
1177
  }
@@ -826,21 +1199,30 @@ export function createCoachService(ctx, store, effectiveConfig) {
826
1199
  async analyzeTurn(args) {
827
1200
  const parsed = analyzeArgSchema.safeParse(args)
828
1201
  if (!parsed.success) {
829
- 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 }
830
1203
  }
831
1204
  return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual' })
832
1205
  },
833
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
+
834
1216
  async improveDraft(args) {
835
1217
  const parsed = improveArgSchema.safeParse(args)
836
1218
  if (!parsed.success) {
837
- 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 }
838
1220
  }
839
1221
  const { sessionId, draft } = parsed.data
840
1222
  const config = effectiveConfig()
841
1223
  const profile = safeProfile()
842
1224
  const svc = serviceOf(ctx)
843
- const { turns } = turnsOf(svc, sessionId)
1225
+ const { session, turns } = turnsOf(svc, sessionId)
844
1226
  const recentContext = recentContextOf(turns)
845
1227
  // Distillation also fires on user-triggered improve calls (soft, in-flight
846
1228
  // deduped, never awaited: an improve call is never blocked by it).
@@ -858,12 +1240,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
858
1240
  styleRules: profile.styleRules,
859
1241
  negativeFeedback: lastDownReasons(profile, 3),
860
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 })
861
1248
  try {
862
- // Provider follows the session's own route (latest known), so proxy
863
- // or custom provider setups keep working; the shipped DeepSeek
864
- // adapter id is the fallback.
865
- const provider = providerForSession(sessionId)
866
- let text = await callCoachModel(ctx, {
1249
+ let text = await callCoachModel(ctx, metered(runId, { op: 'improve', sessionId }, {
867
1250
  provider,
868
1251
  model: config.model,
869
1252
  system: IMPROVE_SYSTEM_PROMPT,
@@ -872,13 +1255,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
872
1255
  timeoutMs: IMPROVE_TIMEOUT_MS,
873
1256
  tool: IMPROVE_TOOL,
874
1257
  sessionId,
875
- })
1258
+ }))
876
1259
  if (text.trim() === '') {
877
- 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) }
878
1261
  }
879
1262
  let parsed = parseJsonObject(text)
880
1263
  if (parsed === null) {
881
- const repaired = await callCoachModel(ctx, {
1264
+ const repaired = await callCoachModel(ctx, metered(runId, { op: 'improve-repair', sessionId }, {
882
1265
  provider,
883
1266
  model: config.model,
884
1267
  system: IMPROVE_REPAIR_SYSTEM_PROMPT,
@@ -887,7 +1270,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
887
1270
  timeoutMs: IMPROVE_TIMEOUT_MS,
888
1271
  tool: IMPROVE_TOOL,
889
1272
  sessionId,
890
- })
1273
+ }))
891
1274
  if (repaired.trim() !== '') {
892
1275
  const reparsed = parseJsonObject(repaired)
893
1276
  if (reparsed !== null) parsed = reparsed
@@ -903,10 +1286,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
903
1286
  draft: draft.trim().slice(0, 1000),
904
1287
  improved: result.improved.slice(0, 2000),
905
1288
  })
906
- return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '' }
1289
+ return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '', run: closeRun(runId) }
907
1290
  } catch (error) {
908
1291
  const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
909
- 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)
910
1296
  }
911
1297
  },
912
1298
 
@@ -999,7 +1385,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
999
1385
  } else if (input.action === 'add') {
1000
1386
  const text = clipSafe(input.text.trim(), 220)
1001
1387
  if (text.length === 0) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'text' }
1002
- profile.directives.push({ id: nextDirectiveId(), text, enabled: true, source: 'user', createdAt: Date.now() })
1388
+ const workspace = typeof input.workspace === 'string' && input.workspace.trim().length > 0 ? input.workspace.trim() : undefined
1389
+ profile.directives.push({ id: nextDirectiveId(), text, enabled: true, source: 'user', createdAt: Date.now(), ...(workspace === undefined ? {} : { workspace }) })
1003
1390
  } else {
1004
1391
  profile.directives = profile.directives.filter((entry) => entry.id !== input.id)
1005
1392
  }
@@ -1015,10 +1402,29 @@ export function createCoachService(ctx, store, effectiveConfig) {
1015
1402
 
1016
1403
  async bootstrap(args) {
1017
1404
  const parsed = bootstrapArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1018
- 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 }
1019
1406
  return runBootstrap({ sessionId: parsed.data.sessionId, limit: parsed.data.limit ?? 20 })
1020
1407
  },
1021
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
+
1022
1428
  async stats(args) {
1023
1429
  const parsed = statsArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1024
1430
  const window = parsed.success && typeof parsed.data.window === 'number' ? parsed.data.window : 20
@@ -1040,6 +1446,39 @@ export function createCoachService(ctx, store, effectiveConfig) {
1040
1446
  const removed = store.clearReports()
1041
1447
  return { ok: true, removed, code: '', detail: '' }
1042
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
+ },
1043
1482
  }
1044
1483
 
1045
1484
  return service