dsh-tacit 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -3
- package/client/client.js +1822 -108
- package/docs/README.md +3 -3
- package/docs/README.zh.md +7 -6
- package/lib/analyze.js +289 -49
- package/lib/index.js +16 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +15 -2
- package/lib/schema.js +187 -1
- package/lib/service.js +542 -103
- package/lib/store.js +164 -3
- package/lib/usage.js +708 -0
- package/package.json +11 -5
package/lib/service.js
CHANGED
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
Config,
|
|
18
|
+
COACH_ERROR_CODES,
|
|
18
19
|
COACH_MODELS,
|
|
19
20
|
COACH_PROVIDER,
|
|
20
21
|
analyzeArgSchema,
|
|
22
|
+
analyzeBatchArgSchema,
|
|
21
23
|
appliedArgSchema,
|
|
22
24
|
configArgSchema,
|
|
23
25
|
feedbackArgSchema,
|
|
@@ -28,7 +30,13 @@ import {
|
|
|
28
30
|
directivesArgSchema,
|
|
29
31
|
statsArgSchema,
|
|
30
32
|
bootstrapArgSchema,
|
|
33
|
+
usageArgSchema,
|
|
34
|
+
usageRunArgSchema,
|
|
31
35
|
} from './schema.js'
|
|
36
|
+
import { dayKey } from './store.js'
|
|
37
|
+
import { createUsageTracker, totalTokens } from './usage.js'
|
|
38
|
+
import { createPricingSource } from './pricing-source.js'
|
|
39
|
+
import { withService } from './routes.js'
|
|
32
40
|
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
|
33
41
|
import { textOfBlocks } from './fold.js'
|
|
34
42
|
import {
|
|
@@ -44,6 +52,9 @@ import {
|
|
|
44
52
|
normalizeReport,
|
|
45
53
|
parseJsonObject,
|
|
46
54
|
ANALYSIS_SYSTEM_PROMPT,
|
|
55
|
+
GOOD_SYSTEM_PROMPT,
|
|
56
|
+
GOOD_TOOL,
|
|
57
|
+
normalizeGoodReport,
|
|
47
58
|
ANALYSIS_REPAIR_SYSTEM_PROMPT,
|
|
48
59
|
IMPROVE_SYSTEM_PROMPT,
|
|
49
60
|
IMPROVE_REPAIR_SYSTEM_PROMPT,
|
|
@@ -71,7 +82,10 @@ import {
|
|
|
71
82
|
DIRECTIVE_TIMEOUT_MS,
|
|
72
83
|
MAX_DIRECTIVES,
|
|
73
84
|
buildDirectiveUserText,
|
|
85
|
+
buildSteeringSection,
|
|
74
86
|
renderSteeringSection,
|
|
87
|
+
workspaceLabel,
|
|
88
|
+
MAX_WORKSPACE_DIRECTIVES,
|
|
75
89
|
ENRICH_SYSTEM_PROMPT,
|
|
76
90
|
ENRICH_TOOL,
|
|
77
91
|
ENRICH_MAX_TOKENS,
|
|
@@ -86,6 +100,13 @@ import {
|
|
|
86
100
|
|
|
87
101
|
/** In-memory rewrite ledger bounds (never persisted). */
|
|
88
102
|
const MAX_REWRITE_RECORDS = 50
|
|
103
|
+
/**
|
|
104
|
+
* What one analysis costs per model when the ledger cannot say yet: the docs'
|
|
105
|
+
* $0.02–0.05 per 20 bootstrapped turns, scaled to a single analysis.
|
|
106
|
+
*/
|
|
107
|
+
const DOC_ANALYSIS_USD = { 'deepseek-v4-flash': 0.0025, 'deepseek-v4-pro': 0.0075 }
|
|
108
|
+
/** Priced analysis attempts the ledger needs before an estimate follows it instead of the docs. */
|
|
109
|
+
const MEASURED_MIN_SAMPLES = 3
|
|
89
110
|
/** Pending outcome verifications kept per session (FIFO, oldest dropped). */
|
|
90
111
|
const MAX_PENDING_VERIFICATIONS = 20
|
|
91
112
|
|
|
@@ -111,6 +132,11 @@ export function mergeConfig(base, patch) {
|
|
|
111
132
|
merged.enrichPrompts = merged.enrichPrompts === true
|
|
112
133
|
merged.directiveTrialTurns = Math.max(1, Math.min(500, Math.round(Number(merged.directiveTrialTurns) || 10)))
|
|
113
134
|
merged.directiveWorseBy = Math.max(0, Math.min(1, Number.isFinite(Number(merged.directiveWorseBy)) ? Number(merged.directiveWorseBy) : 0.15))
|
|
135
|
+
merged.bootstrapConcurrency = Math.max(1, Math.min(4, Math.round(Number(merged.bootstrapConcurrency) || 1)))
|
|
136
|
+
merged.learnFromGood = merged.learnFromGood !== false
|
|
137
|
+
merged.costHistoryDays = Math.max(7, Math.min(365, Math.round(Number(merged.costHistoryDays) || 30)))
|
|
138
|
+
merged.costWarnDailyUsd = Math.max(0, Math.min(10000, Number.isFinite(Number(merged.costWarnDailyUsd ?? 0)) ? Number(merged.costWarnDailyUsd ?? 0) : 0))
|
|
139
|
+
merged.costWarnMonthlyUsd = Math.max(0, Math.min(10000, Number.isFinite(Number(merged.costWarnMonthlyUsd ?? 0)) ? Number(merged.costWarnMonthlyUsd ?? 0) : 0))
|
|
114
140
|
return merged
|
|
115
141
|
}
|
|
116
142
|
|
|
@@ -134,15 +160,44 @@ function turnsOf(service, sessionId) {
|
|
|
134
160
|
}
|
|
135
161
|
}
|
|
136
162
|
|
|
137
|
-
/**
|
|
138
|
-
function
|
|
139
|
-
const session = typeof service.sessions?.get === 'function' ? service.sessions.get(sessionId) : undefined
|
|
163
|
+
/** The absolute workspace directory a session was created in, else undefined. */
|
|
164
|
+
function cwdOf(session) {
|
|
140
165
|
const cwd = session !== null && typeof session === 'object' && session.header !== null && typeof session.header === 'object'
|
|
141
166
|
? session.header.cwd
|
|
142
167
|
: undefined
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
168
|
+
return typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A human label for a session: the workspace directory's basename, else ''. */
|
|
172
|
+
function sessionLabelOf(service, sessionId) {
|
|
173
|
+
const session = typeof service.sessions?.get === 'function' ? service.sessions.get(sessionId) : undefined
|
|
174
|
+
return workspaceLabel(cwdOf(session))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Every distinct workspace among the live sessions, labelled for the UI. */
|
|
178
|
+
function listWorkspaces(service) {
|
|
179
|
+
const sessions = typeof service.sessions?.list === 'function' ? service.sessions.list() : []
|
|
180
|
+
const seen = new Map()
|
|
181
|
+
for (const session of Array.isArray(sessions) ? sessions : []) {
|
|
182
|
+
const cwd = cwdOf(session)
|
|
183
|
+
if (cwd !== undefined && !seen.has(cwd)) seen.set(cwd, { cwd, label: workspaceLabel(cwd) })
|
|
184
|
+
}
|
|
185
|
+
return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** At most MAX_DIRECTIVES global directives and MAX_WORKSPACE_DIRECTIVES per workspace, order kept. */
|
|
189
|
+
function capDirectives(list) {
|
|
190
|
+
const counts = new Map()
|
|
191
|
+
const out = []
|
|
192
|
+
for (const entry of list) {
|
|
193
|
+
const scope = typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : ''
|
|
194
|
+
const limit = scope === '' ? MAX_DIRECTIVES : MAX_WORKSPACE_DIRECTIVES
|
|
195
|
+
const n = counts.get(scope) ?? 0
|
|
196
|
+
if (n >= limit) continue
|
|
197
|
+
counts.set(scope, n + 1)
|
|
198
|
+
out.push(entry)
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
146
201
|
}
|
|
147
202
|
|
|
148
203
|
/** Short, secret-free context digest of a session's last two finished turns. */
|
|
@@ -192,23 +247,50 @@ function lastFinishedTurnOf(turns) {
|
|
|
192
247
|
return finished.length > 0 ? finished[finished.length - 1] : null
|
|
193
248
|
}
|
|
194
249
|
|
|
250
|
+
/**
|
|
251
|
+
* One of {@link COACH_ERROR_CODES} for whatever a model call threw.
|
|
252
|
+
*
|
|
253
|
+
* A code is passed through only when it is already one of Tacit's own: the
|
|
254
|
+
* runtime turns an adapter failure into a `finish {kind:'error'|'aborted'}`
|
|
255
|
+
* chunk and `callCoachModel` rethrows it carrying the provider's own code
|
|
256
|
+
* (or a synthesized `ERROR`/`ABORTED`), and the client renders `err.<code>`
|
|
257
|
+
* verbatim — so passing those through paints a literal `err.ABORTED` banner.
|
|
258
|
+
* Anything else is folded, code and message together, through the ladder.
|
|
259
|
+
* The attempt record keeps the raw code; only the envelope is normalized.
|
|
260
|
+
*/
|
|
195
261
|
function coachErrorCode(error) {
|
|
196
262
|
const message = error instanceof Error ? error.message : String(error)
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
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'
|
|
201
274
|
return 'call-failed'
|
|
202
275
|
}
|
|
203
276
|
|
|
204
|
-
/** Local calendar day key for the daily auto budget. */
|
|
205
|
-
function dayKey(now = Date.now()) {
|
|
206
|
-
const date = new Date(now)
|
|
207
|
-
const pad = (value) => String(value).padStart(2, '0')
|
|
208
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
209
|
-
}
|
|
210
|
-
|
|
211
277
|
export function createCoachService(ctx, store, effectiveConfig) {
|
|
278
|
+
/** Prices behind the ledger: the optional costMeter sibling when it appears, bundled list prices otherwise. */
|
|
279
|
+
const pricing = createPricingSource(ctx)
|
|
280
|
+
/** The run/attempt ledger. Synchronous by contract — never awaited in a model-call path. */
|
|
281
|
+
const usage = createUsageTracker({ store, config: effectiveConfig, pricing })
|
|
282
|
+
// Fire and forget, twice: once now, once if the sibling shows up later.
|
|
283
|
+
// No model call ever waits on it, and a failure only means bundled prices.
|
|
284
|
+
const refreshPrices = () => { pricing.refresh().catch(() => {}) }
|
|
285
|
+
refreshPrices()
|
|
286
|
+
withService(ctx, 'costMeter', refreshPrices)
|
|
287
|
+
|
|
288
|
+
/** One metered model call: the caller's options plus this run's usage sink. */
|
|
289
|
+
const metered = (runId, tag, options) => ({ ...options, onUsage: usage.attemptSink(runId, tag) })
|
|
290
|
+
|
|
291
|
+
/** End a run (idempotent) and hand back the summary its response envelope carries. */
|
|
292
|
+
const closeRun = (runId, results = {}, status) => usage.endRun(runId, { results, status }) ?? usage.runSummary(runId)
|
|
293
|
+
|
|
212
294
|
const inFlight = new Map()
|
|
213
295
|
/** Turns already handed to automatic analysis (sessionId:turn). */
|
|
214
296
|
const autoSeen = new Set()
|
|
@@ -220,6 +302,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
220
302
|
const rewriteRecords = new Map()
|
|
221
303
|
/** sessionId → FIFO [{rewriteId, baseline}] (one per applied rewrite). */
|
|
222
304
|
const pendingVerifications = new Map()
|
|
305
|
+
/** sessionId → ids of the directives in that session's frozen steering (bounded; insertion order = age). */
|
|
306
|
+
const steeringIdsBySession = new Map()
|
|
307
|
+
const MAX_STEERING_SESSIONS = 500
|
|
223
308
|
let distillInFlight = false
|
|
224
309
|
let rewriteSeq = 0
|
|
225
310
|
|
|
@@ -235,7 +320,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
235
320
|
return 'd' + Date.now().toString(36) + '-' + directiveSeq.toString(36)
|
|
236
321
|
}
|
|
237
322
|
let directivesInFlight = false
|
|
238
|
-
/** Steering text frozen per live session (keeps the model's prefix cache stable within a session). */
|
|
323
|
+
/** Steering `{ text, ids }` frozen per live session object (keeps the model's prefix cache stable within a session). */
|
|
239
324
|
const steeringFrozen = new WeakMap()
|
|
240
325
|
|
|
241
326
|
const nextRewriteId = () => {
|
|
@@ -272,7 +357,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
272
357
|
profile.patterns = profile.patterns.slice(0, config.maxPatterns)
|
|
273
358
|
profile.styleRules = profile.styleRules.slice(-MAX_STYLE_RULES)
|
|
274
359
|
profile.feedbackLog = profile.feedbackLog.slice(-MAX_FEEDBACK_LOG)
|
|
275
|
-
profile.directives = profile.directives
|
|
360
|
+
profile.directives = capDirectives(profile.directives)
|
|
276
361
|
profile.updatedAt = Date.now()
|
|
277
362
|
const validated = profileSchema.parse(profile)
|
|
278
363
|
store.saveProfile(validated)
|
|
@@ -288,11 +373,15 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
288
373
|
*/
|
|
289
374
|
const maybeDistill = async (profile, provider, sessionId) => {
|
|
290
375
|
if (profile.pendingDistill < 3 || distillInFlight) return profile
|
|
376
|
+
// Read the config BEFORE claiming the in-flight slot: a throw between the
|
|
377
|
+
// two would leave `distillInFlight` stuck true and kill every later
|
|
378
|
+
// distillation for the lifetime of the process.
|
|
379
|
+
const config = effectiveConfig()
|
|
380
|
+
const runId = usage.beginRun({ type: 'style-distillation', trigger: 'feedback', sessionId, model: config.model, provider })
|
|
291
381
|
distillInFlight = true
|
|
292
382
|
try {
|
|
293
|
-
const config = effectiveConfig()
|
|
294
383
|
const reasons = lastDownReasons(profile, 3)
|
|
295
|
-
const text = await callCoachModel(ctx, {
|
|
384
|
+
const text = await callCoachModel(ctx, metered(runId, { op: 'style-distillation', sessionId }, {
|
|
296
385
|
provider,
|
|
297
386
|
model: config.model,
|
|
298
387
|
system: DISTILL_SYSTEM_PROMPT,
|
|
@@ -301,7 +390,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
301
390
|
timeoutMs: DISTILL_TIMEOUT_MS,
|
|
302
391
|
tool: DISTILL_TOOL,
|
|
303
392
|
sessionId,
|
|
304
|
-
})
|
|
393
|
+
}))
|
|
305
394
|
const rules = normalizeDistillRules(text)
|
|
306
395
|
if (rules.length === 0) return profile
|
|
307
396
|
const fresh = safeProfile()
|
|
@@ -311,6 +400,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
311
400
|
} catch {
|
|
312
401
|
return profile
|
|
313
402
|
} finally {
|
|
403
|
+
closeRun(runId)
|
|
314
404
|
distillInFlight = false
|
|
315
405
|
}
|
|
316
406
|
}
|
|
@@ -354,10 +444,12 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
354
444
|
|
|
355
445
|
/**
|
|
356
446
|
* Directive trials ride the same free feed: every NEW finished turn counts
|
|
357
|
-
* toward each candidate
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
447
|
+
* toward each candidate that was actually in that session's frozen steering
|
|
448
|
+
* text; after `directiveTrialTurns` such turns the candidate is activated,
|
|
449
|
+
* or retired when the messy rate rose past the baseline by more than
|
|
450
|
+
* `directiveWorseBy`. A session whose steering was never assembled here
|
|
451
|
+
* (started before the candidate existed, or before a restart) counts toward
|
|
452
|
+
* nobody — its turns say nothing about the candidate.
|
|
361
453
|
*/
|
|
362
454
|
const recordTrialTurns = (sessionId, turns) => {
|
|
363
455
|
const fresh = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object'
|
|
@@ -365,8 +457,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
365
457
|
&& !seenFinished.has(sessionId + ':' + turn.turn))
|
|
366
458
|
if (fresh.length === 0) return
|
|
367
459
|
for (const turn of fresh) seenFinished.add(sessionId + ':' + turn.turn)
|
|
460
|
+
const steered = steeringIdsBySession.get(sessionId)
|
|
461
|
+
if (steered === undefined || steered.length === 0) return
|
|
368
462
|
const profile = safeProfile()
|
|
369
|
-
const candidates = profile.directives.filter((entry) => entry.status === 'candidate' && entry.trial !== undefined)
|
|
463
|
+
const candidates = profile.directives.filter((entry) => entry.status === 'candidate' && entry.trial !== undefined && steered.includes(entry.id))
|
|
370
464
|
if (candidates.length === 0) return
|
|
371
465
|
const config = effectiveConfig()
|
|
372
466
|
const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
|
|
@@ -425,13 +519,18 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
425
519
|
return earlier.length > 0 ? earlier[earlier.length - 1] : null
|
|
426
520
|
}
|
|
427
521
|
|
|
428
|
-
const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null } = {}) => {
|
|
522
|
+
const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null, runId = '' } = {}) => {
|
|
429
523
|
const profile = safeProfile()
|
|
430
524
|
const key = `${sessionId}:${turn}`
|
|
525
|
+
/** The run this analysis is billed to: the caller's batch run, or one of its own. Stays '' on a soft refusal. */
|
|
526
|
+
let usageRunId = ''
|
|
527
|
+
/** True when this analysis owns its run; a child of a batch reports `run: null` and lets the batch carry it. */
|
|
528
|
+
const ownRun = runId === ''
|
|
431
529
|
const exclusive = runExclusive(key, async () => {
|
|
432
530
|
const svc = serviceOf(ctx)
|
|
433
531
|
const { session, turns } = turnsOf(svc, sessionId)
|
|
434
532
|
if (session === undefined) return { ok: false, report: null, profile, code: 'no-session', detail: '' }
|
|
533
|
+
const cwd = cwdOf(session)
|
|
435
534
|
// The change feed already carries the digest; a manual click re-reads the snapshot.
|
|
436
535
|
const record = digest !== null && typeof digest === 'object' ? digest : turns.find((candidate) => candidate?.turn === turn)
|
|
437
536
|
if (record === undefined) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
|
|
@@ -444,11 +543,52 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
444
543
|
const userText = buildAnalysisUserText(record, { followUp, previous })
|
|
445
544
|
if (userText === null) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
|
|
446
545
|
const config = effectiveConfig()
|
|
546
|
+
const provider = typeof record.provider === 'string' && record.provider.length > 0
|
|
547
|
+
? record.provider
|
|
548
|
+
: COACH_PROVIDER
|
|
549
|
+
// Only now, with a paid call actually about to happen, does a run start:
|
|
550
|
+
// every refusal above this line costs nothing and is not worth a row.
|
|
551
|
+
usageRunId = ownRun
|
|
552
|
+
? usage.beginRun({ type: 'analysis', trigger, sessionId, turn, workspace: workspaceLabel(cwd), model: config.model, provider })
|
|
553
|
+
: runId
|
|
554
|
+
let ok = false
|
|
447
555
|
try {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
:
|
|
451
|
-
|
|
556
|
+
if (trigger === 'good') {
|
|
557
|
+
// One attempt, no repair retry: a recovery lesson is a bonus, not a diagnosis.
|
|
558
|
+
const goodText = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis', sessionId, turn }, {
|
|
559
|
+
provider,
|
|
560
|
+
model: config.model,
|
|
561
|
+
system: GOOD_SYSTEM_PROMPT,
|
|
562
|
+
userText,
|
|
563
|
+
maxTokens: ANALYZE_MAX_TOKENS,
|
|
564
|
+
timeoutMs: ANALYZE_TIMEOUT_MS,
|
|
565
|
+
tool: GOOD_TOOL,
|
|
566
|
+
sessionId,
|
|
567
|
+
}))
|
|
568
|
+
const goodParsed = goodText.trim() === '' ? null : parseJsonObject(goodText)
|
|
569
|
+
if (goodParsed === null) return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
|
|
570
|
+
const goodReport = {
|
|
571
|
+
...normalizeGoodReport(goodParsed, { turn, time: Date.now(), model: config.model, prompt: record.prompt }),
|
|
572
|
+
...(typeof record.prompt === 'string' && record.prompt.length > 0 ? { promptExcerpt: clipSafe(record.prompt, 200) } : {}),
|
|
573
|
+
trigger,
|
|
574
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
575
|
+
}
|
|
576
|
+
if (goodReport.lesson === '' && goodReport.strengths.length === 0) {
|
|
577
|
+
return { ok: false, report: null, profile, code: 'nothing-learned', detail: '' }
|
|
578
|
+
}
|
|
579
|
+
const isNew = store.report(sessionId, turn) === null
|
|
580
|
+
store.saveReport(sessionId, turn, goodReport)
|
|
581
|
+
const grown = aggregateProfile(store.profile(), goodReport, config.maxPatterns, { countNew: isNew })
|
|
582
|
+
if (isNew) grown.analysesSinceDirectives = (grown.analysesSinceDirectives ?? 0) + 1
|
|
583
|
+
store.saveProfile(grown)
|
|
584
|
+
if (isNew && !bootstrapState.running) {
|
|
585
|
+
const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
|
|
586
|
+
autoRunning.add(task)
|
|
587
|
+
}
|
|
588
|
+
ok = true
|
|
589
|
+
return { ok: true, report: goodReport, profile: grown, code: '', detail: '' }
|
|
590
|
+
}
|
|
591
|
+
let text = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis', sessionId, turn }, {
|
|
452
592
|
provider,
|
|
453
593
|
model: config.model,
|
|
454
594
|
system: ANALYSIS_SYSTEM_PROMPT,
|
|
@@ -457,14 +597,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
457
597
|
timeoutMs: ANALYZE_TIMEOUT_MS,
|
|
458
598
|
tool: ANALYSIS_TOOL,
|
|
459
599
|
sessionId,
|
|
460
|
-
})
|
|
600
|
+
}))
|
|
461
601
|
if (text.trim() === '') {
|
|
462
602
|
return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
|
|
463
603
|
}
|
|
464
604
|
let parsed = parseJsonObject(text)
|
|
465
605
|
if (parsed === null) {
|
|
466
606
|
// One-shot repair: the model answered in prose; re-ask for strict JSON.
|
|
467
|
-
const repaired = await callCoachModel(ctx, {
|
|
607
|
+
const repaired = await callCoachModel(ctx, metered(usageRunId, { op: 'analysis-repair', sessionId, turn }, {
|
|
468
608
|
provider,
|
|
469
609
|
model: config.model,
|
|
470
610
|
system: ANALYSIS_REPAIR_SYSTEM_PROMPT,
|
|
@@ -473,7 +613,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
473
613
|
timeoutMs: ANALYZE_TIMEOUT_MS,
|
|
474
614
|
tool: ANALYSIS_TOOL,
|
|
475
615
|
sessionId,
|
|
476
|
-
})
|
|
616
|
+
}))
|
|
477
617
|
if (repaired.trim() !== '') {
|
|
478
618
|
const reparsed = parseJsonObject(repaired)
|
|
479
619
|
if (reparsed !== null) {
|
|
@@ -494,6 +634,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
494
634
|
: {}),
|
|
495
635
|
trigger,
|
|
496
636
|
...(followUp.length > 0 ? { followUp: clipSafe(followUp, 300) } : {}),
|
|
637
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
497
638
|
}
|
|
498
639
|
const countNew = store.report(sessionId, turn) === null
|
|
499
640
|
store.saveReport(sessionId, turn, report)
|
|
@@ -504,33 +645,48 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
504
645
|
const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
|
|
505
646
|
autoRunning.add(task)
|
|
506
647
|
}
|
|
648
|
+
ok = true
|
|
507
649
|
return { ok: true, report, profile: nextProfile, code: '', detail: '' }
|
|
508
650
|
} catch (error) {
|
|
509
651
|
const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
|
|
510
652
|
return { ok: false, report: null, profile, code: coachErrorCode(error), detail }
|
|
653
|
+
} finally {
|
|
654
|
+
// A batch run is closed by its owner; an own run always ends here.
|
|
655
|
+
if (ownRun) closeRun(usageRunId, { ok: ok ? 1 : 0 })
|
|
511
656
|
}
|
|
512
657
|
})
|
|
513
|
-
if (exclusive === null) return Promise.resolve({ ok: false, report: null, profile, code: 'busy', detail: '' })
|
|
514
|
-
return exclusive
|
|
658
|
+
if (exclusive === null) return Promise.resolve({ ok: false, report: null, profile, code: 'busy', detail: '', run: null })
|
|
659
|
+
return exclusive.then((result) => ({
|
|
660
|
+
...result,
|
|
661
|
+
run: ownRun && usageRunId !== '' ? usage.runSummary(usageRunId) : null,
|
|
662
|
+
}))
|
|
515
663
|
}
|
|
516
664
|
|
|
517
|
-
const steeringStatus = () => {
|
|
665
|
+
const steeringStatus = (cwd) => {
|
|
518
666
|
const config = effectiveConfig()
|
|
519
|
-
return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile()) : '' }
|
|
667
|
+
return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile(), { cwd }) : '' }
|
|
520
668
|
}
|
|
521
669
|
|
|
670
|
+
/** What a session in `cwd` assembling its system prompt right now would get. */
|
|
671
|
+
const steeringNow = (cwd) => (effectiveConfig().steerAgent ? buildSteeringSection(safeProfile(), { cwd }) : { text: '', ids: [] })
|
|
672
|
+
|
|
522
673
|
/** The system-prompt section provider (sync; frozen per session). */
|
|
523
674
|
const steeringText = (assemble) => {
|
|
524
675
|
const session = assemble !== null && typeof assemble === 'object' && assemble.agent !== null && typeof assemble.agent === 'object'
|
|
525
676
|
? assemble.agent.session
|
|
526
677
|
: undefined
|
|
527
|
-
if (session === null || session === undefined || typeof session !== 'object') return
|
|
678
|
+
if (session === null || session === undefined || typeof session !== 'object') return steeringNow().text
|
|
528
679
|
let frozen = steeringFrozen.get(session)
|
|
529
680
|
if (frozen === undefined) {
|
|
530
|
-
frozen =
|
|
681
|
+
frozen = steeringNow(cwdOf(session))
|
|
531
682
|
steeringFrozen.set(session, frozen)
|
|
683
|
+
if (typeof session.id === 'string' && session.id.length > 0) {
|
|
684
|
+
steeringIdsBySession.delete(session.id)
|
|
685
|
+
steeringIdsBySession.set(session.id, frozen.ids)
|
|
686
|
+
while (steeringIdsBySession.size > MAX_STEERING_SESSIONS) steeringIdsBySession.delete(steeringIdsBySession.keys().next().value)
|
|
687
|
+
}
|
|
532
688
|
}
|
|
533
|
-
return frozen
|
|
689
|
+
return frozen.text
|
|
534
690
|
}
|
|
535
691
|
|
|
536
692
|
/**
|
|
@@ -538,48 +694,82 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
538
694
|
* entries are untouched, and a re-emitted directive keeps the enabled flag
|
|
539
695
|
* the user gave its identical text. Capped at MAX_DIRECTIVES overall.
|
|
540
696
|
*/
|
|
541
|
-
const
|
|
542
|
-
|
|
697
|
+
const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : '')
|
|
698
|
+
const directiveKey = (scope, text) => scope + '\n' + text.trim().toLowerCase()
|
|
699
|
+
|
|
700
|
+
/** Messy-turn baseline for a new candidate: the workspace's own turns when there are enough, else everything. */
|
|
701
|
+
const baselineRateFor = (cwd) => {
|
|
702
|
+
const scoped = cwd !== undefined ? allFinishedTurns({ cwd }) : []
|
|
703
|
+
const turns = scoped.length >= 20 ? scoped : allFinishedTurns()
|
|
704
|
+
return computeTrend(turns, { window: 20 }).recent.messyRate
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Merge the model's new complete set of directives ({ text, workspace? }):
|
|
709
|
+
* user entries are untouched; the global distilled set and the distilled set
|
|
710
|
+
* of every workspace the model mentioned are replaced; distilled entries of
|
|
711
|
+
* other workspaces are kept (their evidence was not in this batch). A
|
|
712
|
+
* re-emitted directive keeps its identity, state and enabled flag.
|
|
713
|
+
*/
|
|
714
|
+
const mergeDirectives = (profile, items) => {
|
|
543
715
|
const users = profile.directives.filter((entry) => entry.source === 'user')
|
|
544
|
-
const userKeys = new Set(users.map((entry) => entry.text
|
|
716
|
+
const userKeys = new Set(users.map((entry) => directiveKey(scopeOf(entry), entry.text)))
|
|
717
|
+
const prior = profile.directives.filter((entry) => entry.source !== 'user')
|
|
718
|
+
const previous = new Map(prior.map((entry) => [directiveKey(scopeOf(entry), entry.text), entry]))
|
|
719
|
+
const mentioned = new Set([''])
|
|
720
|
+
for (const item of items) mentioned.add(scopeOf(item))
|
|
721
|
+
const untouched = prior.filter((entry) => !mentioned.has(scopeOf(entry)))
|
|
545
722
|
const distilled = []
|
|
546
723
|
const seen = new Set()
|
|
547
|
-
|
|
548
|
-
for (const
|
|
549
|
-
const
|
|
724
|
+
const baselines = new Map()
|
|
725
|
+
for (const item of items) {
|
|
726
|
+
const scope = scopeOf(item)
|
|
727
|
+
const key = directiveKey(scope, item.text)
|
|
550
728
|
if (seen.has(key) || userKeys.has(key)) continue
|
|
551
729
|
seen.add(key)
|
|
552
730
|
const kept = previous.get(key)
|
|
553
731
|
if (kept !== undefined) {
|
|
554
|
-
distilled.push({ ...kept, text })
|
|
732
|
+
distilled.push({ ...kept, text: item.text })
|
|
555
733
|
continue
|
|
556
734
|
}
|
|
557
735
|
// A new distilled directive goes on trial against the current messy-turn rate.
|
|
558
|
-
if (
|
|
736
|
+
if (!baselines.has(scope)) baselines.set(scope, baselineRateFor(scope === '' ? undefined : scope))
|
|
559
737
|
distilled.push({
|
|
560
738
|
id: nextDirectiveId(),
|
|
561
|
-
text,
|
|
739
|
+
text: item.text,
|
|
562
740
|
enabled: true,
|
|
563
741
|
source: 'distilled',
|
|
564
742
|
createdAt: Date.now(),
|
|
565
743
|
status: 'candidate',
|
|
566
|
-
trial: { turns: 0, messy: 0, baselineRate, startedAt: Date.now() },
|
|
744
|
+
trial: { turns: 0, messy: 0, baselineRate: baselines.get(scope), startedAt: Date.now() },
|
|
745
|
+
...(scope === '' ? {} : { workspace: scope }),
|
|
567
746
|
})
|
|
568
747
|
}
|
|
569
|
-
profile.directives = [...users, ...distilled
|
|
748
|
+
profile.directives = capDirectives([...users, ...distilled, ...untouched])
|
|
570
749
|
return profile
|
|
571
750
|
}
|
|
572
751
|
|
|
573
752
|
/** ONE small call every `directiveEvery` new analyses (or forced). Soft-fails; never throws. */
|
|
574
|
-
const maybeDistillDirectives = async (sessionId, provider, { force = false } = {}) => {
|
|
753
|
+
const maybeDistillDirectives = async (sessionId, provider, { force = false, runId = '' } = {}) => {
|
|
575
754
|
if (directivesInFlight) return
|
|
576
755
|
const config = effectiveConfig()
|
|
577
756
|
let profile = safeProfile()
|
|
578
757
|
if (!force && profile.analysesSinceDirectives < config.directiveEvery) return
|
|
579
758
|
directivesInFlight = true
|
|
759
|
+
const ownRun = runId === ''
|
|
760
|
+
const usageRunId = ownRun
|
|
761
|
+
? usage.beginRun({ type: 'directive-distillation', trigger: 'auto', sessionId, model: config.model, provider })
|
|
762
|
+
: runId
|
|
580
763
|
try {
|
|
581
764
|
const recent = store.listAllReports(20).map((entry) => store.report(entry.sessionId, entry.turn)).filter((report) => report !== null)
|
|
582
|
-
|
|
765
|
+
// The model sees workspace names only; map them back to the directories they stand for.
|
|
766
|
+
const workspaces = new Map()
|
|
767
|
+
for (const report of recent) {
|
|
768
|
+
if (typeof report.cwd !== 'string' || report.cwd.length === 0) continue
|
|
769
|
+
const label = workspaceLabel(report.cwd)
|
|
770
|
+
if (label.length > 0 && !workspaces.has(label)) workspaces.set(label, report.cwd)
|
|
771
|
+
}
|
|
772
|
+
const text = await callCoachModel(ctx, metered(usageRunId, { op: 'directive-distillation', sessionId }, {
|
|
583
773
|
provider,
|
|
584
774
|
model: config.model,
|
|
585
775
|
system: DIRECTIVE_SYSTEM_PROMPT,
|
|
@@ -588,21 +778,26 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
588
778
|
timeoutMs: DIRECTIVE_TIMEOUT_MS,
|
|
589
779
|
tool: DIRECTIVE_TOOL,
|
|
590
780
|
sessionId,
|
|
591
|
-
})
|
|
592
|
-
const { kept
|
|
781
|
+
}))
|
|
782
|
+
const { kept, rejected } = classifyDirectives(text)
|
|
593
783
|
for (const dropped of rejected) console.warn('[tacit] dropped directive (it asks the user instead of compensating):', dropped)
|
|
594
|
-
if (
|
|
784
|
+
if (kept.length === 0) {
|
|
595
785
|
console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
|
|
596
786
|
return
|
|
597
787
|
}
|
|
598
|
-
|
|
788
|
+
const items = kept.map((item) => (item.workspace !== undefined && workspaces.has(item.workspace)
|
|
789
|
+
? { text: item.text, workspace: workspaces.get(item.workspace) }
|
|
790
|
+
: { text: item.text }))
|
|
791
|
+
profile = mergeDirectives(safeProfile(), items)
|
|
599
792
|
profile.analysesSinceDirectives = 0
|
|
600
793
|
capAndSaveProfile(profile)
|
|
601
|
-
|
|
794
|
+
const scoped = items.filter((item) => item.workspace !== undefined).length
|
|
795
|
+
console.info('[tacit] distilled ' + items.length + ' directive(s) into the steering section' + (scoped > 0 ? ' (' + scoped + ' workspace-specific)' : ''))
|
|
602
796
|
} catch (error) {
|
|
603
797
|
// Soft: the counter stays and the next analysis retries.
|
|
604
798
|
console.warn('[tacit] directive distillation failed (will retry):', error instanceof Error ? error.message : String(error))
|
|
605
799
|
} finally {
|
|
800
|
+
if (ownRun) closeRun(usageRunId)
|
|
606
801
|
directivesInFlight = false
|
|
607
802
|
}
|
|
608
803
|
}
|
|
@@ -614,6 +809,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
614
809
|
* empty note, or a later step leaves the step exactly as it was.
|
|
615
810
|
*/
|
|
616
811
|
const preStep = async (payload, next) => {
|
|
812
|
+
/** '' until the enrichment call is actually about to happen. */
|
|
813
|
+
let usageRunId = ''
|
|
617
814
|
try {
|
|
618
815
|
const config = effectiveConfig()
|
|
619
816
|
if (!config.enrichPrompts) return next()
|
|
@@ -624,8 +821,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
624
821
|
if (draft.length < ENRICH_MIN_DRAFT_CHARS || draft.length > ENRICH_MAX_DRAFT_CHARS) return next()
|
|
625
822
|
const sessionId = typeof payload.agent?.session?.id === 'string' ? payload.agent.session.id : (typeof payload.agent?.id === 'string' ? payload.agent.id : '')
|
|
626
823
|
const { turns } = sessionId.length > 0 ? turnsOf(serviceOf(ctx), sessionId) : { turns: [] }
|
|
627
|
-
const
|
|
628
|
-
|
|
824
|
+
const provider = sessionId.length > 0 ? providerForSession(sessionId) : COACH_PROVIDER
|
|
825
|
+
usageRunId = usage.beginRun({ type: 'prompt-enrichment', trigger: 'send', sessionId, model: config.model, provider })
|
|
826
|
+
const text = await callCoachModel(ctx, metered(usageRunId, { op: 'enrichment', sessionId }, {
|
|
827
|
+
provider,
|
|
629
828
|
model: config.model,
|
|
630
829
|
system: ENRICH_SYSTEM_PROMPT,
|
|
631
830
|
userText: buildEnrichUserText({ draft, profile: safeProfile(), recentContext: recentContextOf(turns) }),
|
|
@@ -633,7 +832,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
633
832
|
timeoutMs: ENRICH_TIMEOUT_MS,
|
|
634
833
|
tool: ENRICH_TOOL,
|
|
635
834
|
sessionId,
|
|
636
|
-
})
|
|
835
|
+
}))
|
|
637
836
|
const note = normalizeEnrichNote(text)
|
|
638
837
|
if (note.length === 0) return next()
|
|
639
838
|
const base = await next()
|
|
@@ -645,37 +844,54 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
645
844
|
return { kind: 'enter', messages: [...base.messages, added] }
|
|
646
845
|
} catch {
|
|
647
846
|
return next()
|
|
847
|
+
} finally {
|
|
848
|
+
if (usageRunId !== '') closeRun(usageRunId)
|
|
648
849
|
}
|
|
649
850
|
}
|
|
650
851
|
|
|
651
|
-
/** Every live session's finished turns, for the measured trend. */
|
|
652
|
-
const allFinishedTurns = () => {
|
|
852
|
+
/** Every live session's finished turns (optionally only sessions in one workspace), for the measured trend. */
|
|
853
|
+
const allFinishedTurns = ({ cwd } = {}) => {
|
|
653
854
|
const svc = serviceOf(ctx)
|
|
654
855
|
const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
|
|
655
856
|
const out = []
|
|
656
857
|
for (const session of Array.isArray(sessions) ? sessions : []) {
|
|
657
858
|
if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
|
|
859
|
+
if (cwd !== undefined && cwdOf(session) !== cwd) continue
|
|
658
860
|
const { turns } = turnsOf(svc, session.id)
|
|
659
861
|
for (const turn of turns) if (turn?.finished === true) out.push(turn)
|
|
660
862
|
}
|
|
661
863
|
return out
|
|
662
864
|
}
|
|
663
865
|
|
|
664
|
-
/** One bootstrap at a time; progress
|
|
665
|
-
|
|
866
|
+
/** One bootstrap at a time; progress AND its running cost are exposed through /state. */
|
|
867
|
+
/** Live bootstrap progress for `/state`. `tokensTotal` is a single billed-token count —
|
|
868
|
+
* deliberately not named `tokens`, which everywhere else in the ledger is the five-bucket object. */
|
|
869
|
+
const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0, runId: '', billedCalls: 0, unpricedCalls: 0, usdKnown: 0, tokensTotal: 0 }
|
|
870
|
+
|
|
871
|
+
/** Mirror the bootstrap run's live counters into the state the panel polls. */
|
|
872
|
+
const refreshBootstrapUsage = () => {
|
|
873
|
+
const summary = bootstrapState.runId === '' ? null : usage.runSummary(bootstrapState.runId)
|
|
874
|
+
if (summary === null) return
|
|
875
|
+
bootstrapState.billedCalls = summary.billedCalls
|
|
876
|
+
bootstrapState.unpricedCalls = summary.unpricedCalls
|
|
877
|
+
bootstrapState.usdKnown = summary.usdKnown
|
|
878
|
+
bootstrapState.tokensTotal = totalTokens(summary.tokens)
|
|
879
|
+
}
|
|
666
880
|
|
|
667
881
|
/**
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
* have a report
|
|
882
|
+
* Which turns a bootstrap would analyze, newest first: every finished turn of
|
|
883
|
+
* one session (or of every live session), minus continuations, tiny prompts
|
|
884
|
+
* and turns that already have a report, capped at `limit`. Pure selection —
|
|
885
|
+
* no model call, no run, no state touched — so the preview and the batch it
|
|
886
|
+
* previews always answer from the same rule. `code` is `'no-session'` when a
|
|
887
|
+
* requested session is not live.
|
|
671
888
|
*/
|
|
672
|
-
const
|
|
673
|
-
if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '' }
|
|
889
|
+
const bootstrapCandidates = ({ sessionId, limit }) => {
|
|
674
890
|
const svc = serviceOf(ctx)
|
|
675
891
|
const pool = []
|
|
676
892
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
677
893
|
const { session, turns } = turnsOf(svc, sessionId)
|
|
678
|
-
if (session === undefined) return {
|
|
894
|
+
if (session === undefined) return { eligible: [], skipped: 0, code: 'no-session' }
|
|
679
895
|
for (const turn of turns) if (turn?.finished === true) pool.push({ sessionId, turn, turns })
|
|
680
896
|
} else {
|
|
681
897
|
const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
|
|
@@ -697,29 +913,165 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
697
913
|
eligible.push(item)
|
|
698
914
|
if (eligible.length >= limit) break
|
|
699
915
|
}
|
|
916
|
+
return { eligible, skipped, code: '' }
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* What a bootstrap of `count` turns is likely to cost. The ledger wins as
|
|
921
|
+
* soon as it holds enough priced analyses (median, plus the one directive
|
|
922
|
+
* distillation a batch also pays for); until then the documented per-analysis
|
|
923
|
+
* figure for the configured model does.
|
|
924
|
+
*/
|
|
925
|
+
const estimateBootstrap = (count, model) => {
|
|
926
|
+
const sample = usage.analysisCostSample()
|
|
927
|
+
if (sample.samples >= MEASURED_MIN_SAMPLES && typeof sample.perAnalysisUsd === 'number') {
|
|
928
|
+
const perAnalysisUsd = sample.perAnalysisUsd
|
|
929
|
+
const distillationUsd = typeof sample.distillationUsd === 'number' ? sample.distillationUsd : 0
|
|
930
|
+
return { usd: perAnalysisUsd * count + (count > 0 ? distillationUsd : 0), basis: 'measured', samples: sample.samples, perAnalysisUsd }
|
|
931
|
+
}
|
|
932
|
+
const perAnalysisUsd = DOC_ANALYSIS_USD[model] ?? DOC_ANALYSIS_USD['deepseek-v4-flash']
|
|
933
|
+
return { usd: perAnalysisUsd * count, basis: 'doc', samples: sample.samples, perAnalysisUsd }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* "What would ⚡ Bootstrap do, and what would it cost?" — the same selection
|
|
938
|
+
* the run itself uses, priced from the ledger. Read-only: no model call, no
|
|
939
|
+
* run, and deliberately NOT blocked by a bootstrap already running.
|
|
940
|
+
*/
|
|
941
|
+
const bootstrapPreview = ({ sessionId, limit }) => {
|
|
942
|
+
const config = effectiveConfig()
|
|
943
|
+
const { eligible, skipped, code } = bootstrapCandidates({ sessionId, limit })
|
|
944
|
+
const count = code === '' ? eligible.length : 0
|
|
945
|
+
return {
|
|
946
|
+
ok: code === '',
|
|
947
|
+
eligible: count,
|
|
948
|
+
skipped: code === '' ? skipped : 0,
|
|
949
|
+
limit,
|
|
950
|
+
model: config.model,
|
|
951
|
+
estimate: estimateBootstrap(count, config.model),
|
|
952
|
+
code,
|
|
953
|
+
detail: '',
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* "Learn from my last N turns now": explicit user action, so it ignores the
|
|
959
|
+
* daily auto budget. Skips continuations, tiny prompts and turns that already
|
|
960
|
+
* have a report; runs up to `bootstrapConcurrency` analyses at once (same
|
|
961
|
+
* number of calls either way); then forces one directive distillation.
|
|
962
|
+
*/
|
|
963
|
+
const runBootstrap = async ({ sessionId, limit }) => {
|
|
964
|
+
if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '', run: null }
|
|
965
|
+
const { eligible, skipped, code } = bootstrapCandidates({ sessionId, limit })
|
|
966
|
+
if (code !== '') return { ok: false, analyzed: 0, skipped: 0, directives: 0, code, detail: '', run: null }
|
|
967
|
+
// Nothing to analyze is a no-op, not a run: an empty run would be written
|
|
968
|
+
// to the ledger as `failed` (no attempts) and read as a broken bootstrap.
|
|
969
|
+
if (eligible.length === 0) {
|
|
970
|
+
return { ok: true, analyzed: 0, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: null }
|
|
971
|
+
}
|
|
972
|
+
const config = effectiveConfig()
|
|
973
|
+
const scopedToSession = typeof sessionId === 'string' && sessionId.length > 0
|
|
974
|
+
// ONE parent run for the whole batch: every analysis and the forced
|
|
975
|
+
// distillation are attempts of it, so the panel shows one line, one price.
|
|
976
|
+
const runId = usage.beginRun({
|
|
977
|
+
type: 'bootstrap',
|
|
978
|
+
trigger: 'bootstrap',
|
|
979
|
+
sessionId: sessionId ?? '',
|
|
980
|
+
model: config.model,
|
|
981
|
+
provider: scopedToSession ? providerForSession(sessionId) : COACH_PROVIDER,
|
|
982
|
+
})
|
|
700
983
|
bootstrapState.running = true
|
|
701
984
|
bootstrapState.done = 0
|
|
702
985
|
bootstrapState.total = eligible.length
|
|
703
986
|
bootstrapState.startedAt = Date.now()
|
|
987
|
+
bootstrapState.runId = runId
|
|
988
|
+
bootstrapState.billedCalls = 0
|
|
989
|
+
bootstrapState.unpricedCalls = 0
|
|
990
|
+
bootstrapState.usdKnown = 0
|
|
991
|
+
bootstrapState.tokensTotal = 0
|
|
704
992
|
let analyzed = 0
|
|
705
|
-
let lastProvider = COACH_PROVIDER
|
|
706
993
|
try {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
994
|
+
// A small worker pool: each worker pulls the next eligible turn until the
|
|
995
|
+
// list is drained. Analyses for different turns never share an in-flight
|
|
996
|
+
// key, and the profile read-modify-write inside runAnalysis has no await,
|
|
997
|
+
// so concurrent analyses cannot lose each other's counts.
|
|
998
|
+
let next = 0
|
|
999
|
+
const worker = async () => {
|
|
1000
|
+
while (next < eligible.length) {
|
|
1001
|
+
const item = eligible[next]
|
|
1002
|
+
next += 1
|
|
1003
|
+
const previous = previousFinishedOf(item.turns, item.turn.turn)
|
|
1004
|
+
const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous, runId })
|
|
1005
|
+
if (result !== null && typeof result === 'object' && result.ok === true) analyzed += 1
|
|
1006
|
+
else console.warn('[tacit] bootstrap: ' + item.sessionId + ':' + item.turn.turn + ' skipped: ' + (result?.code ?? 'unknown'))
|
|
1007
|
+
bootstrapState.done += 1
|
|
1008
|
+
refreshBootstrapUsage()
|
|
1009
|
+
}
|
|
714
1010
|
}
|
|
1011
|
+
const concurrency = Math.min(config.bootstrapConcurrency, Math.max(1, eligible.length))
|
|
1012
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
|
715
1013
|
if (analyzed > 0) {
|
|
716
1014
|
await service.flushAuto() // let any scheduled distillation settle before forcing one
|
|
717
|
-
|
|
1015
|
+
// The forced distillation is attributed to the newest eligible turn's session.
|
|
1016
|
+
await maybeDistillDirectives(eligible[0].sessionId, providerForSession(eligible[0].sessionId), { force: true, runId })
|
|
718
1017
|
}
|
|
719
1018
|
} finally {
|
|
720
1019
|
bootstrapState.running = false
|
|
1020
|
+
closeRun(runId, { requested: limit, eligible: eligible.length, analyzed, skipped, directives: safeProfile().directives.length })
|
|
1021
|
+
refreshBootstrapUsage()
|
|
1022
|
+
}
|
|
1023
|
+
return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: usage.runSummary(runId) }
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* "Analyze exactly these turns": the user picked them, so nothing is filtered
|
|
1028
|
+
* out and the daily auto budget does not apply. One parent run covers the
|
|
1029
|
+
* whole batch (the analyses are its attempts); the same `runExclusive` key as
|
|
1030
|
+
* every other analysis means a turn already being analyzed — by a bootstrap,
|
|
1031
|
+
* an auto trigger or another batch — reports `busy` and costs nothing. A
|
|
1032
|
+
* bootstrap running elsewhere does NOT block the batch.
|
|
1033
|
+
*/
|
|
1034
|
+
const analyzeBatch = async ({ sessionId, turns }) => {
|
|
1035
|
+
const { session } = turnsOf(serviceOf(ctx), sessionId)
|
|
1036
|
+
if (session === undefined) return { ok: false, results: [], profile: safeProfile(), run: null, code: 'no-session', detail: '' }
|
|
1037
|
+
const wanted = [...new Set(turns)].sort((a, b) => a - b)
|
|
1038
|
+
const config = effectiveConfig()
|
|
1039
|
+
const runId = usage.beginRun({
|
|
1040
|
+
type: 'analysis-batch',
|
|
1041
|
+
trigger: 'manual',
|
|
1042
|
+
sessionId,
|
|
1043
|
+
workspace: workspaceLabel(cwdOf(session)),
|
|
1044
|
+
model: config.model,
|
|
1045
|
+
provider: providerForSession(sessionId),
|
|
1046
|
+
})
|
|
1047
|
+
const results = new Array(wanted.length)
|
|
1048
|
+
let analyzed = 0
|
|
1049
|
+
try {
|
|
1050
|
+
// The same worker pool as the bootstrap: different turns never share an
|
|
1051
|
+
// in-flight key, so the pool is the only thing bounding concurrency.
|
|
1052
|
+
let next = 0
|
|
1053
|
+
const worker = async () => {
|
|
1054
|
+
while (next < wanted.length) {
|
|
1055
|
+
const at = next
|
|
1056
|
+
next += 1
|
|
1057
|
+
const turn = wanted[at]
|
|
1058
|
+
const result = await runAnalysis(sessionId, turn, { trigger: 'manual', runId })
|
|
1059
|
+
const ok = result !== null && typeof result === 'object' && result.ok === true
|
|
1060
|
+
if (ok) analyzed += 1
|
|
1061
|
+
results[at] = { turn, ok, code: result?.code ?? 'call-failed', report: ok ? result.report : null }
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const concurrency = Math.min(config.bootstrapConcurrency, Math.max(1, wanted.length))
|
|
1065
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
|
1066
|
+
} finally {
|
|
1067
|
+
// Every requested turn was already being analyzed elsewhere: no call was
|
|
1068
|
+
// made and nothing failed, so this is a real request that succeeded —
|
|
1069
|
+
// not the zero-attempt `failed` run the default derivation would write.
|
|
1070
|
+
const entries = results.filter((entry) => entry !== null && entry !== undefined)
|
|
1071
|
+
const allBusy = analyzed === 0 && entries.length === wanted.length && entries.every((entry) => entry.code === 'busy')
|
|
1072
|
+
closeRun(runId, { requested: wanted.length, analyzed, skipped: wanted.length - analyzed }, allBusy ? 'success' : undefined)
|
|
721
1073
|
}
|
|
722
|
-
return { ok: true,
|
|
1074
|
+
return { ok: true, results, profile: safeProfile(), run: usage.runSummary(runId), code: '', detail: '' }
|
|
723
1075
|
}
|
|
724
1076
|
|
|
725
1077
|
const autoStatus = () => {
|
|
@@ -755,12 +1107,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
755
1107
|
}
|
|
756
1108
|
|
|
757
1109
|
/**
|
|
758
|
-
* Zero-click learning.
|
|
1110
|
+
* Zero-click learning. Three triggers, all free (no model call to decide):
|
|
759
1111
|
* - the newest FINISHED turn is messy (retries / tool errors / compactions /
|
|
760
1112
|
* rejection / long step run);
|
|
761
1113
|
* - the newest (possibly unfinished) turn's prompt reads as a correction of
|
|
762
1114
|
* the previous answer → the PREVIOUS turn is analyzed with that
|
|
763
|
-
* follow-up attached as evidence
|
|
1115
|
+
* follow-up attached as evidence;
|
|
1116
|
+
* - (learnFromGood) the newest finished turn is clean right after a messy
|
|
1117
|
+
* one → a small "what did the user include this time" call.
|
|
764
1118
|
* Turns finished before the plugin started are ignored (cold restore).
|
|
765
1119
|
*/
|
|
766
1120
|
const maybeAutoAnalyze = (sessionId, turns) => {
|
|
@@ -776,6 +1130,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
776
1130
|
// the conversation is its context. Heavy work after it is not a prompt fault.
|
|
777
1131
|
if (fresh(newest) && isMessyTurn(newest, { minSteps: config.autoMinSteps }) && !looksLikeContinuation(newest.prompt)) {
|
|
778
1132
|
scheduleAuto(sessionId, newest.turn, { trigger: 'auto', digest: newest, previousDigest: previous })
|
|
1133
|
+
return
|
|
1134
|
+
}
|
|
1135
|
+
// A recovery: clean now, messy just before, with a real prompt in between.
|
|
1136
|
+
const recovery = config.learnFromGood && fresh(newest) && !looksLikeContinuation(newest.prompt)
|
|
1137
|
+
&& typeof newest.prompt === 'string' && newest.prompt.trim().length >= ENRICH_MIN_DRAFT_CHARS
|
|
1138
|
+
&& previous !== null && previous.finished === true && isMessyTurn(previous, { minSteps: config.autoMinSteps })
|
|
1139
|
+
if (recovery) {
|
|
1140
|
+
scheduleAuto(sessionId, newest.turn, { trigger: 'good', digest: newest, previousDigest: previous })
|
|
779
1141
|
}
|
|
780
1142
|
return
|
|
781
1143
|
}
|
|
@@ -787,18 +1149,29 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
787
1149
|
}
|
|
788
1150
|
|
|
789
1151
|
const service = {
|
|
1152
|
+
/** The usage/cost ledger (flushed on dispose; read by the cost routes). */
|
|
1153
|
+
usage,
|
|
1154
|
+
/** The price source behind the ledger. */
|
|
1155
|
+
pricing,
|
|
1156
|
+
|
|
790
1157
|
/** Await every in-flight automatic analysis (tests / orderly shutdown). */
|
|
791
1158
|
async flushAuto() {
|
|
792
1159
|
await Promise.all([...autoRunning])
|
|
793
1160
|
},
|
|
794
1161
|
|
|
795
|
-
|
|
1162
|
+
/** Optional `{ sessionId }`: the steering preview is then rendered for that conversation's workspace. */
|
|
1163
|
+
async getState(args) {
|
|
1164
|
+
const svc = serviceOf(ctx)
|
|
1165
|
+
const sessionId = args !== null && typeof args === 'object' && typeof args.sessionId === 'string' && args.sessionId.length > 0 ? args.sessionId : null
|
|
1166
|
+
const session = sessionId !== null && typeof svc.sessions?.get === 'function' ? svc.sessions.get(sessionId) : undefined
|
|
1167
|
+
refreshBootstrapUsage()
|
|
796
1168
|
return {
|
|
797
1169
|
ok: true,
|
|
798
1170
|
config: effectiveConfig(),
|
|
799
1171
|
profile: safeProfile(),
|
|
800
1172
|
auto: autoStatus(),
|
|
801
|
-
steering: steeringStatus(),
|
|
1173
|
+
steering: steeringStatus(cwdOf(session)),
|
|
1174
|
+
workspaces: listWorkspaces(svc),
|
|
802
1175
|
bootstrap: { ...bootstrapState },
|
|
803
1176
|
message: '',
|
|
804
1177
|
}
|
|
@@ -826,21 +1199,30 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
826
1199
|
async analyzeTurn(args) {
|
|
827
1200
|
const parsed = analyzeArgSchema.safeParse(args)
|
|
828
1201
|
if (!parsed.success) {
|
|
829
|
-
return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '' }
|
|
1202
|
+
return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '', run: null }
|
|
830
1203
|
}
|
|
831
1204
|
return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual' })
|
|
832
1205
|
},
|
|
833
1206
|
|
|
1207
|
+
/** Analyze a hand-picked set of turns of one session under a single run. */
|
|
1208
|
+
async analyzeBatch(args) {
|
|
1209
|
+
const parsed = analyzeBatchArgSchema.safeParse(args)
|
|
1210
|
+
if (!parsed.success) {
|
|
1211
|
+
return { ok: false, results: [], profile: safeProfile(), run: null, code: 'bad-request', detail: '' }
|
|
1212
|
+
}
|
|
1213
|
+
return analyzeBatch(parsed.data)
|
|
1214
|
+
},
|
|
1215
|
+
|
|
834
1216
|
async improveDraft(args) {
|
|
835
1217
|
const parsed = improveArgSchema.safeParse(args)
|
|
836
1218
|
if (!parsed.success) {
|
|
837
|
-
return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'bad-request', detail: '' }
|
|
1219
|
+
return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'bad-request', detail: '', run: null }
|
|
838
1220
|
}
|
|
839
1221
|
const { sessionId, draft } = parsed.data
|
|
840
1222
|
const config = effectiveConfig()
|
|
841
1223
|
const profile = safeProfile()
|
|
842
1224
|
const svc = serviceOf(ctx)
|
|
843
|
-
const { turns } = turnsOf(svc, sessionId)
|
|
1225
|
+
const { session, turns } = turnsOf(svc, sessionId)
|
|
844
1226
|
const recentContext = recentContextOf(turns)
|
|
845
1227
|
// Distillation also fires on user-triggered improve calls (soft, in-flight
|
|
846
1228
|
// deduped, never awaited: an improve call is never blocked by it).
|
|
@@ -858,12 +1240,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
858
1240
|
styleRules: profile.styleRules,
|
|
859
1241
|
negativeFeedback: lastDownReasons(profile, 3),
|
|
860
1242
|
})
|
|
1243
|
+
// Provider follows the session's own route (latest known), so proxy
|
|
1244
|
+
// or custom provider setups keep working; the shipped DeepSeek
|
|
1245
|
+
// adapter id is the fallback.
|
|
1246
|
+
const provider = providerForSession(sessionId)
|
|
1247
|
+
const runId = usage.beginRun({ type: 'improve', trigger: 'manual', sessionId, workspace: workspaceLabel(cwdOf(session)), model: config.model, provider })
|
|
861
1248
|
try {
|
|
862
|
-
|
|
863
|
-
// or custom provider setups keep working; the shipped DeepSeek
|
|
864
|
-
// adapter id is the fallback.
|
|
865
|
-
const provider = providerForSession(sessionId)
|
|
866
|
-
let text = await callCoachModel(ctx, {
|
|
1249
|
+
let text = await callCoachModel(ctx, metered(runId, { op: 'improve', sessionId }, {
|
|
867
1250
|
provider,
|
|
868
1251
|
model: config.model,
|
|
869
1252
|
system: IMPROVE_SYSTEM_PROMPT,
|
|
@@ -872,13 +1255,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
872
1255
|
timeoutMs: IMPROVE_TIMEOUT_MS,
|
|
873
1256
|
tool: IMPROVE_TOOL,
|
|
874
1257
|
sessionId,
|
|
875
|
-
})
|
|
1258
|
+
}))
|
|
876
1259
|
if (text.trim() === '') {
|
|
877
|
-
return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'empty-response', detail: '' }
|
|
1260
|
+
return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'empty-response', detail: '', run: closeRun(runId) }
|
|
878
1261
|
}
|
|
879
1262
|
let parsed = parseJsonObject(text)
|
|
880
1263
|
if (parsed === null) {
|
|
881
|
-
const repaired = await callCoachModel(ctx, {
|
|
1264
|
+
const repaired = await callCoachModel(ctx, metered(runId, { op: 'improve-repair', sessionId }, {
|
|
882
1265
|
provider,
|
|
883
1266
|
model: config.model,
|
|
884
1267
|
system: IMPROVE_REPAIR_SYSTEM_PROMPT,
|
|
@@ -887,7 +1270,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
887
1270
|
timeoutMs: IMPROVE_TIMEOUT_MS,
|
|
888
1271
|
tool: IMPROVE_TOOL,
|
|
889
1272
|
sessionId,
|
|
890
|
-
})
|
|
1273
|
+
}))
|
|
891
1274
|
if (repaired.trim() !== '') {
|
|
892
1275
|
const reparsed = parseJsonObject(repaired)
|
|
893
1276
|
if (reparsed !== null) parsed = reparsed
|
|
@@ -903,10 +1286,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
903
1286
|
draft: draft.trim().slice(0, 1000),
|
|
904
1287
|
improved: result.improved.slice(0, 2000),
|
|
905
1288
|
})
|
|
906
|
-
return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '' }
|
|
1289
|
+
return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '', run: closeRun(runId) }
|
|
907
1290
|
} catch (error) {
|
|
908
1291
|
const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
|
|
909
|
-
return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: coachErrorCode(error), detail }
|
|
1292
|
+
return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: coachErrorCode(error), detail, run: closeRun(runId) }
|
|
1293
|
+
} finally {
|
|
1294
|
+
// Idempotent: the returns above already closed it; this catches a throw.
|
|
1295
|
+
closeRun(runId)
|
|
910
1296
|
}
|
|
911
1297
|
},
|
|
912
1298
|
|
|
@@ -999,7 +1385,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
999
1385
|
} else if (input.action === 'add') {
|
|
1000
1386
|
const text = clipSafe(input.text.trim(), 220)
|
|
1001
1387
|
if (text.length === 0) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'text' }
|
|
1002
|
-
|
|
1388
|
+
const workspace = typeof input.workspace === 'string' && input.workspace.trim().length > 0 ? input.workspace.trim() : undefined
|
|
1389
|
+
profile.directives.push({ id: nextDirectiveId(), text, enabled: true, source: 'user', createdAt: Date.now(), ...(workspace === undefined ? {} : { workspace }) })
|
|
1003
1390
|
} else {
|
|
1004
1391
|
profile.directives = profile.directives.filter((entry) => entry.id !== input.id)
|
|
1005
1392
|
}
|
|
@@ -1015,10 +1402,29 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1015
1402
|
|
|
1016
1403
|
async bootstrap(args) {
|
|
1017
1404
|
const parsed = bootstrapArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
|
|
1018
|
-
if (!parsed.success) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'bad-request', detail: '' }
|
|
1405
|
+
if (!parsed.success) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'bad-request', detail: '', run: null }
|
|
1019
1406
|
return runBootstrap({ sessionId: parsed.data.sessionId, limit: parsed.data.limit ?? 20 })
|
|
1020
1407
|
},
|
|
1021
1408
|
|
|
1409
|
+
/** What a bootstrap would analyze and what it would cost. Free: no model call, no run. */
|
|
1410
|
+
async bootstrapPreview(args) {
|
|
1411
|
+
const parsed = bootstrapArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
|
|
1412
|
+
if (!parsed.success) {
|
|
1413
|
+
const config = effectiveConfig()
|
|
1414
|
+
return {
|
|
1415
|
+
ok: false,
|
|
1416
|
+
eligible: 0,
|
|
1417
|
+
skipped: 0,
|
|
1418
|
+
limit: 20,
|
|
1419
|
+
model: config.model,
|
|
1420
|
+
estimate: estimateBootstrap(0, config.model),
|
|
1421
|
+
code: 'bad-request',
|
|
1422
|
+
detail: '',
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return bootstrapPreview({ sessionId: parsed.data.sessionId, limit: parsed.data.limit ?? 20 })
|
|
1426
|
+
},
|
|
1427
|
+
|
|
1022
1428
|
async stats(args) {
|
|
1023
1429
|
const parsed = statsArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
|
|
1024
1430
|
const window = parsed.success && typeof parsed.data.window === 'number' ? parsed.data.window : 20
|
|
@@ -1040,6 +1446,39 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1040
1446
|
const removed = store.clearReports()
|
|
1041
1447
|
return { ok: true, removed, code: '', detail: '' }
|
|
1042
1448
|
},
|
|
1449
|
+
|
|
1450
|
+
/** The whole cost panel: period cards, series, breakdowns, warnings and one page of runs. */
|
|
1451
|
+
async usageReport(args) {
|
|
1452
|
+
const parsed = usageArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
|
|
1453
|
+
if (!parsed.success) return { ok: false, code: 'bad-request', detail: '' }
|
|
1454
|
+
return usage.report({
|
|
1455
|
+
config: effectiveConfig(),
|
|
1456
|
+
pricingStatus: pricing.status(),
|
|
1457
|
+
pricingRates: pricing.rates(),
|
|
1458
|
+
filters: parsed.data,
|
|
1459
|
+
})
|
|
1460
|
+
},
|
|
1461
|
+
|
|
1462
|
+
/** One run with its attempt rows (a live run included); expired ids are a soft `unknown-run`. */
|
|
1463
|
+
async usageRun(args) {
|
|
1464
|
+
const parsed = usageRunArgSchema.safeParse(args)
|
|
1465
|
+
if (!parsed.success) return { ok: false, run: null, code: 'bad-request', detail: '' }
|
|
1466
|
+
const run = usage.run(parsed.data.runId)
|
|
1467
|
+
if (run === null) return { ok: false, run: null, code: 'unknown-run', detail: '' }
|
|
1468
|
+
return { ok: true, run, code: '', detail: '' }
|
|
1469
|
+
},
|
|
1470
|
+
|
|
1471
|
+
/** Delete the ledger and restart the tracking window (live runs keep recording into it). */
|
|
1472
|
+
async usageClear() {
|
|
1473
|
+
const { removed, trackingSince } = usage.clear()
|
|
1474
|
+
return { ok: true, removed, trackingSince, code: '', detail: '' }
|
|
1475
|
+
},
|
|
1476
|
+
|
|
1477
|
+
/** Re-read the optional costMeter sibling; `refresh()` never throws, so this never fails. */
|
|
1478
|
+
async pricingRefresh() {
|
|
1479
|
+
await pricing.refresh()
|
|
1480
|
+
return { ok: true, pricing: { ...pricing.status(), rates: pricing.rates() }, code: '', detail: '' }
|
|
1481
|
+
},
|
|
1043
1482
|
}
|
|
1044
1483
|
|
|
1045
1484
|
return service
|