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