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/README.md +3 -3
- package/client/client.js +1739 -91
- package/docs/README.zh.md +3 -3
- package/lib/analyze.js +114 -30
- package/lib/index.js +3 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +14 -1
- package/lib/schema.js +170 -0
- package/lib/service.js +349 -60
- package/lib/store.js +163 -3
- package/lib/usage.js +708 -0
- package/package.json +1 -1
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
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
if (/
|
|
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
|
|
750
|
-
|
|
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
|
|
788
|
-
|
|
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
|
-
*
|
|
792
|
-
*
|
|
793
|
-
* have a report
|
|
794
|
-
*
|
|
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
|
|
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 {
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|