dsh-tacit 0.3.0 → 0.5.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 +7 -5
- package/client/client.js +367 -74
- package/docs/README.zh.md +2 -1
- package/lib/analyze.js +215 -24
- package/lib/fold.js +11 -5
- package/lib/index.js +1 -1
- package/lib/redact.js +53 -0
- package/lib/routes.js +1 -0
- package/lib/schema.js +98 -25
- package/lib/service.js +282 -134
- package/lib/store.js +66 -5
- package/lib/usage.js +139 -41
- package/lib/workspace.js +62 -0
- package/package.json +1 -1
package/lib/service.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
bootstrapArgSchema,
|
|
33
33
|
usageArgSchema,
|
|
34
34
|
usageRunArgSchema,
|
|
35
|
+
directiveReceiptArgSchema,
|
|
35
36
|
} from './schema.js'
|
|
36
37
|
import { dayKey } from './store.js'
|
|
37
38
|
import { createUsageTracker, totalTokens } from './usage.js'
|
|
@@ -71,21 +72,26 @@ import {
|
|
|
71
72
|
ANALYSIS_TOOL,
|
|
72
73
|
IMPROVE_TOOL,
|
|
73
74
|
DISTILL_TOOL,
|
|
75
|
+
POLICY_RE,
|
|
74
76
|
clipSafe,
|
|
75
77
|
isMessyTurn,
|
|
76
78
|
looksLikeCorrection,
|
|
77
79
|
looksLikeContinuation,
|
|
80
|
+
looksLikeJobNotification,
|
|
78
81
|
classifyDirectives,
|
|
79
82
|
DIRECTIVE_SYSTEM_PROMPT,
|
|
80
83
|
DIRECTIVE_TOOL,
|
|
81
84
|
DIRECTIVE_MAX_TOKENS,
|
|
82
85
|
DIRECTIVE_TIMEOUT_MS,
|
|
83
|
-
MAX_DIRECTIVES,
|
|
84
86
|
buildDirectiveUserText,
|
|
87
|
+
directiveLabels,
|
|
85
88
|
buildSteeringSection,
|
|
86
89
|
renderSteeringSection,
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
scopeOf,
|
|
91
|
+
capDirectives,
|
|
92
|
+
mergeDirectives,
|
|
93
|
+
isDeadDirective,
|
|
94
|
+
MAX_DIRECTIVE_EVIDENCE,
|
|
89
95
|
ENRICH_SYSTEM_PROMPT,
|
|
90
96
|
ENRICH_TOOL,
|
|
91
97
|
ENRICH_MAX_TOKENS,
|
|
@@ -96,7 +102,9 @@ import {
|
|
|
96
102
|
buildEnrichUserText,
|
|
97
103
|
normalizeEnrichNote,
|
|
98
104
|
computeTrend,
|
|
105
|
+
markCorrections,
|
|
99
106
|
} from './analyze.js'
|
|
107
|
+
import { normalizeWorkspace, workspaceContains, workspaceLabel, workspaceLabels } from './workspace.js'
|
|
100
108
|
|
|
101
109
|
/** In-memory rewrite ledger bounds (never persisted). */
|
|
102
110
|
const MAX_REWRITE_RECORDS = 50
|
|
@@ -160,12 +168,13 @@ function turnsOf(service, sessionId) {
|
|
|
160
168
|
}
|
|
161
169
|
}
|
|
162
170
|
|
|
163
|
-
/** The
|
|
171
|
+
/** The normalised workspace directory a session was created in, else undefined. */
|
|
164
172
|
function cwdOf(session) {
|
|
165
173
|
const cwd = session !== null && typeof session === 'object' && session.header !== null && typeof session.header === 'object'
|
|
166
174
|
? session.header.cwd
|
|
167
175
|
: undefined
|
|
168
|
-
|
|
176
|
+
const workspace = normalizeWorkspace(cwd)
|
|
177
|
+
return workspace.length > 0 ? workspace : undefined
|
|
169
178
|
}
|
|
170
179
|
|
|
171
180
|
/** A human label for a session: the workspace directory's basename, else ''. */
|
|
@@ -177,32 +186,30 @@ function sessionLabelOf(service, sessionId) {
|
|
|
177
186
|
/** Every distinct workspace among the live sessions, labelled for the UI. */
|
|
178
187
|
function listWorkspaces(service) {
|
|
179
188
|
const sessions = typeof service.sessions?.list === 'function' ? service.sessions.list() : []
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label))
|
|
189
|
+
const cwds = (Array.isArray(sessions) ? sessions : []).map(cwdOf).filter((cwd) => cwd !== undefined)
|
|
190
|
+
return [...workspaceLabels(cwds)]
|
|
191
|
+
.map(([cwd, label]) => ({ cwd, label }))
|
|
192
|
+
.sort((a, b) => a.label.localeCompare(b.label))
|
|
186
193
|
}
|
|
187
194
|
|
|
188
|
-
/**
|
|
189
|
-
function
|
|
190
|
-
const
|
|
191
|
-
|
|
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
|
|
195
|
+
/** The `workspaceSeenAt` map narrowed to the workspaces a directive still points at. */
|
|
196
|
+
function pruneSeenAt(seenAt, directives) {
|
|
197
|
+
const scopes = new Set(directives.map(scopeOf))
|
|
198
|
+
return Object.fromEntries(Object.entries(seenAt).filter(([scope]) => scopes.has(scope)))
|
|
201
199
|
}
|
|
202
200
|
|
|
203
|
-
/**
|
|
201
|
+
/**
|
|
202
|
+
* Bounded context of the last two turns the user actually wrote, read from the
|
|
203
|
+
* digest. Background-job notifications and bare continuations are finished
|
|
204
|
+
* turns too, and one of them landing after the last real exchange would
|
|
205
|
+
* otherwise hide the facts the rewrite needs.
|
|
206
|
+
*/
|
|
204
207
|
function recentContextOf(turns) {
|
|
205
|
-
const
|
|
208
|
+
const written = (turn) => {
|
|
209
|
+
const prompt = typeof turn.prompt === 'string' ? turn.prompt.trim() : ''
|
|
210
|
+
return prompt.length > 0 && !looksLikeJobNotification(prompt) && !looksLikeContinuation(prompt)
|
|
211
|
+
}
|
|
212
|
+
const finished = (Array.isArray(turns) ? turns : []).filter((turn) => turn?.finished === true && written(turn)).slice(-2)
|
|
206
213
|
if (finished.length === 0) return ''
|
|
207
214
|
return finished.map((turn) => {
|
|
208
215
|
const prompt = typeof turn.prompt === 'string' ? turn.prompt.slice(0, 600) : ''
|
|
@@ -265,6 +272,9 @@ function coachErrorCode(error) {
|
|
|
265
272
|
const text = raw + ' ' + message
|
|
266
273
|
if (/abort|timeout/i.test(text)) return 'timeout'
|
|
267
274
|
if (/auth|401|403|api[ _-]?key|key not/i.test(text)) return 'no-api-key'
|
|
275
|
+
// Ahead of the rate-limit rule: an exhausted balance says "quota" too, but
|
|
276
|
+
// it will not clear by waiting, so it must not read as "try again shortly".
|
|
277
|
+
if (/\binsufficient[ _-]?(quota|balance|credit)|\bexceeded your current quota/i.test(text)) return 'no-credit'
|
|
268
278
|
// Word-bounded: a bare /rate/ matches the "rate" inside "generate", and the
|
|
269
279
|
// ladder now sees the raw code and message of every provider failure. A
|
|
270
280
|
// trailing \b after "quota" would not do here — `_` is a word character, so
|
|
@@ -310,9 +320,15 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
310
320
|
|
|
311
321
|
const safeProfile = () => {
|
|
312
322
|
const parsed = profileSchema.safeParse(store.profile())
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
323
|
+
if (!parsed.success) {
|
|
324
|
+
return { analyzedCount: 0, patterns: [], updatedAt: 0, styleRules: [], feedbackLog: [], pendingDistill: 0, directives: [], analysesSinceDirectives: 0, workspaceSeenAt: {} }
|
|
325
|
+
}
|
|
326
|
+
for (const entry of parsed.data.directives) {
|
|
327
|
+
const workspace = normalizeWorkspace(entry.workspace)
|
|
328
|
+
if (workspace.length > 0) entry.workspace = workspace
|
|
329
|
+
else delete entry.workspace
|
|
330
|
+
}
|
|
331
|
+
return parsed.data
|
|
316
332
|
}
|
|
317
333
|
let directiveSeq = 0
|
|
318
334
|
const nextDirectiveId = () => {
|
|
@@ -321,7 +337,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
321
337
|
}
|
|
322
338
|
let directivesInFlight = false
|
|
323
339
|
/** Steering `{ text, ids }` frozen per live session object (keeps the model's prefix cache stable within a session). */
|
|
324
|
-
|
|
340
|
+
let steeringFrozen = new WeakMap()
|
|
341
|
+
/** Forget every open session's frozen steering: the next assembly re-freezes from the profile as it is now. */
|
|
342
|
+
const invalidateSteering = () => { steeringFrozen = new WeakMap() }
|
|
325
343
|
|
|
326
344
|
const nextRewriteId = () => {
|
|
327
345
|
rewriteSeq += 1
|
|
@@ -351,13 +369,24 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
351
369
|
return profile
|
|
352
370
|
}
|
|
353
371
|
|
|
372
|
+
/** The normalised workspace of every session loaded right now. */
|
|
373
|
+
const liveCwds = () => listWorkspaces(serviceOf(ctx)).map((entry) => entry.cwd)
|
|
374
|
+
|
|
354
375
|
/** Bound every v2 field, validate, persist; returns the stored profile. */
|
|
355
376
|
const capAndSaveProfile = (profile) => {
|
|
356
377
|
const config = effectiveConfig()
|
|
357
378
|
profile.patterns = profile.patterns.slice(0, config.maxPatterns)
|
|
358
379
|
profile.styleRules = profile.styleRules.slice(-MAX_STYLE_RULES)
|
|
359
380
|
profile.feedbackLog = profile.feedbackLog.slice(-MAX_FEEDBACK_LOG)
|
|
360
|
-
|
|
381
|
+
const cwds = liveCwds()
|
|
382
|
+
const seenAt = profile.workspaceSeenAt ?? {}
|
|
383
|
+
for (const entry of profile.directives) {
|
|
384
|
+
const scope = scopeOf(entry)
|
|
385
|
+
if (scope !== '' && cwds.some((cwd) => workspaceContains(scope, cwd))) seenAt[scope] = Date.now()
|
|
386
|
+
}
|
|
387
|
+
profile.workspaceSeenAt = pruneSeenAt(seenAt, profile.directives)
|
|
388
|
+
profile.directives = capDirectives(profile.directives, { seenAt: profile.workspaceSeenAt })
|
|
389
|
+
profile.workspaceSeenAt = pruneSeenAt(profile.workspaceSeenAt, profile.directives)
|
|
361
390
|
profile.updatedAt = Date.now()
|
|
362
391
|
const validated = profileSchema.parse(profile)
|
|
363
392
|
store.saveProfile(validated)
|
|
@@ -437,26 +466,32 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
437
466
|
handleVerification(sessionId, turns)
|
|
438
467
|
}
|
|
439
468
|
|
|
440
|
-
/** Finished turns already counted toward directive trials (sessionId:turn). */
|
|
469
|
+
/** Finished turns already counted toward directive trials, and turns already counted as corrected (sessionId:turn). */
|
|
441
470
|
const seenFinished = new Set()
|
|
471
|
+
const seenCorrected = new Set()
|
|
442
472
|
|
|
443
473
|
const pct = (rate) => String(Math.round(rate * 100)) + '%'
|
|
444
474
|
|
|
445
475
|
/**
|
|
446
476
|
* Directive trials ride the same free feed: every NEW finished turn counts
|
|
447
477
|
* toward each candidate that was actually in that session's frozen steering
|
|
448
|
-
* text
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
478
|
+
* text, and so does every correction of such a turn (the session's next
|
|
479
|
+
* prompt, known the moment it starts). After `directiveTrialTurns` turns the
|
|
480
|
+
* candidate is retired when its correction rate rose past the baseline by
|
|
481
|
+
* more than `directiveWorseBy` (or its messy rate by twice that), otherwise
|
|
482
|
+
* activated. A session whose steering was never assembled here (started
|
|
483
|
+
* before the candidate existed, or before a restart) counts toward nobody —
|
|
484
|
+
* its turns say nothing about the candidate.
|
|
453
485
|
*/
|
|
454
486
|
const recordTrialTurns = (sessionId, turns) => {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
&&
|
|
458
|
-
|
|
459
|
-
|
|
487
|
+
const key = (turn) => sessionId + ':' + turn.turn
|
|
488
|
+
const counted = markCorrections(turns).filter((turn) => typeof turn.turn === 'number' && turn.finished === true
|
|
489
|
+
&& typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt)
|
|
490
|
+
const fresh = counted.filter((turn) => !seenFinished.has(key(turn)))
|
|
491
|
+
const corrected = counted.filter((turn) => turn.corrected && !seenCorrected.has(key(turn)))
|
|
492
|
+
for (const turn of fresh) seenFinished.add(key(turn))
|
|
493
|
+
for (const turn of corrected) seenCorrected.add(key(turn))
|
|
494
|
+
if (fresh.length === 0 && corrected.length === 0) return
|
|
460
495
|
const steered = steeringIdsBySession.get(sessionId)
|
|
461
496
|
if (steered === undefined || steered.length === 0) return
|
|
462
497
|
const profile = safeProfile()
|
|
@@ -464,21 +499,35 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
464
499
|
if (candidates.length === 0) return
|
|
465
500
|
const config = effectiveConfig()
|
|
466
501
|
const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
|
|
502
|
+
let verdicts = 0
|
|
467
503
|
for (const entry of candidates) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
504
|
+
const trial = entry.trial
|
|
505
|
+
if (trial.baselineCorrectionRate < 0) trial.baselineCorrectionRate = baselinesFor(scopeOf(entry)).baselineCorrectionRate
|
|
506
|
+
trial.turns += fresh.length
|
|
507
|
+
trial.messy += messyCount
|
|
508
|
+
trial.corrected += corrected.length
|
|
509
|
+
if (trial.turns < config.directiveTrialTurns) continue
|
|
510
|
+
const correctionRate = trial.corrected / trial.turns
|
|
511
|
+
const messyRate = trial.messy / trial.turns
|
|
512
|
+
const worse = correctionRate > trial.baselineCorrectionRate + config.directiveWorseBy
|
|
513
|
+
? 'corrections rose ' + pct(trial.baselineCorrectionRate) + ' → ' + pct(correctionRate)
|
|
514
|
+
: messyRate > trial.baselineMessyRate + 2 * config.directiveWorseBy
|
|
515
|
+
? 'messy turns rose ' + pct(trial.baselineMessyRate) + ' → ' + pct(messyRate)
|
|
516
|
+
: null
|
|
517
|
+
verdicts += 1
|
|
518
|
+
entry.evaluatedAt = Date.now()
|
|
519
|
+
entry.updatedAt = entry.evaluatedAt
|
|
520
|
+
if (worse !== null) {
|
|
473
521
|
entry.status = 'retired'
|
|
474
522
|
entry.enabled = false
|
|
475
|
-
entry.retiredReason =
|
|
523
|
+
entry.retiredReason = worse + ' during its trial'
|
|
476
524
|
console.info('[tacit] retired directive (' + entry.retiredReason + '): ' + entry.text)
|
|
477
525
|
} else {
|
|
478
526
|
entry.status = 'active'
|
|
479
|
-
console.info('[tacit] activated directive (
|
|
527
|
+
console.info('[tacit] activated directive (corrections ' + pct(trial.baselineCorrectionRate) + ' → ' + pct(correctionRate) + '): ' + entry.text)
|
|
480
528
|
}
|
|
481
529
|
}
|
|
530
|
+
if (verdicts > 0) startNextTrial(profile)
|
|
482
531
|
capAndSaveProfile(profile)
|
|
483
532
|
}
|
|
484
533
|
|
|
@@ -519,7 +568,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
519
568
|
return earlier.length > 0 ? earlier[earlier.length - 1] : null
|
|
520
569
|
}
|
|
521
570
|
|
|
522
|
-
const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null, runId = '' } = {}) => {
|
|
571
|
+
const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null, runId = '', force = false } = {}) => {
|
|
523
572
|
const profile = safeProfile()
|
|
524
573
|
const key = `${sessionId}:${turn}`
|
|
525
574
|
/** The run this analysis is billed to: the caller's batch run, or one of its own. Stays '' on a soft refusal. */
|
|
@@ -539,6 +588,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
539
588
|
if (trigger === 'manual' && looksLikeContinuation(record.prompt)) {
|
|
540
589
|
return { ok: false, report: null, profile, code: 'continuation', detail: '' }
|
|
541
590
|
}
|
|
591
|
+
// A report already exists: a second paid look at the same turn only on request.
|
|
592
|
+
if (trigger === 'manual' && !force && store.report(sessionId, turn) !== null) {
|
|
593
|
+
return { ok: false, report: null, profile, code: 'already-analyzed', detail: '' }
|
|
594
|
+
}
|
|
542
595
|
const previous = previousDigest !== null && typeof previousDigest === 'object' ? previousDigest : previousFinishedOf(turns, turn)
|
|
543
596
|
const userText = buildAnalysisUserText(record, { followUp, previous })
|
|
544
597
|
if (userText === null) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
|
|
@@ -678,6 +731,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
678
731
|
if (session === null || session === undefined || typeof session !== 'object') return steeringNow().text
|
|
679
732
|
let frozen = steeringFrozen.get(session)
|
|
680
733
|
if (frozen === undefined) {
|
|
734
|
+
// A workspace seen for the first time may revive a paused trial.
|
|
735
|
+
const profile = safeProfile()
|
|
736
|
+
if (startNextTrial(profile)) capAndSaveProfile(profile)
|
|
681
737
|
frozen = steeringNow(cwdOf(session))
|
|
682
738
|
steeringFrozen.set(session, frozen)
|
|
683
739
|
if (typeof session.id === 'string' && session.id.length > 0) {
|
|
@@ -689,64 +745,48 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
689
745
|
return frozen.text
|
|
690
746
|
}
|
|
691
747
|
|
|
692
|
-
/**
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
* the user gave its identical text. Capped at MAX_DIRECTIVES overall.
|
|
696
|
-
*/
|
|
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 }) : []
|
|
748
|
+
/** Baselines for a new trial: the workspace's own turns when there are enough, else everything. */
|
|
749
|
+
const baselinesFor = (scope) => {
|
|
750
|
+
const scoped = scope !== '' ? allFinishedTurns({ cwd: scope }) : []
|
|
703
751
|
const turns = scoped.length >= 20 ? scoped : allFinishedTurns()
|
|
704
|
-
|
|
752
|
+
const { messyRate, correctionRate } = computeTrend(turns, { window: 20 }).recent
|
|
753
|
+
return { baselineMessyRate: messyRate, baselineCorrectionRate: correctionRate }
|
|
705
754
|
}
|
|
706
755
|
|
|
707
756
|
/**
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
757
|
+
* One trial per scope at a time, and only in a workspace some session is
|
|
758
|
+
* loaded in: a candidate whose workspace is gone goes back to the queue and
|
|
759
|
+
* frees the slot, then the oldest enabled queued directive of every free
|
|
760
|
+
* seen scope starts its trial now, with baselines measured at this moment.
|
|
761
|
+
* Idempotent; returns whether any status changed.
|
|
713
762
|
*/
|
|
714
|
-
const
|
|
715
|
-
const
|
|
716
|
-
const
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
for (const item of items) mentioned.add(scopeOf(item))
|
|
721
|
-
const untouched = prior.filter((entry) => !mentioned.has(scopeOf(entry)))
|
|
722
|
-
const distilled = []
|
|
723
|
-
const seen = new Set()
|
|
724
|
-
const baselines = new Map()
|
|
725
|
-
for (const item of items) {
|
|
726
|
-
const scope = scopeOf(item)
|
|
727
|
-
const key = directiveKey(scope, item.text)
|
|
728
|
-
if (seen.has(key) || userKeys.has(key)) continue
|
|
729
|
-
seen.add(key)
|
|
730
|
-
const kept = previous.get(key)
|
|
731
|
-
if (kept !== undefined) {
|
|
732
|
-
distilled.push({ ...kept, text: item.text })
|
|
733
|
-
continue
|
|
734
|
-
}
|
|
735
|
-
// A new distilled directive goes on trial against the current messy-turn rate.
|
|
736
|
-
if (!baselines.has(scope)) baselines.set(scope, baselineRateFor(scope === '' ? undefined : scope))
|
|
737
|
-
distilled.push({
|
|
738
|
-
id: nextDirectiveId(),
|
|
739
|
-
text: item.text,
|
|
740
|
-
enabled: true,
|
|
741
|
-
source: 'distilled',
|
|
742
|
-
createdAt: Date.now(),
|
|
743
|
-
status: 'candidate',
|
|
744
|
-
trial: { turns: 0, messy: 0, baselineRate: baselines.get(scope), startedAt: Date.now() },
|
|
745
|
-
...(scope === '' ? {} : { workspace: scope }),
|
|
746
|
-
})
|
|
763
|
+
const startNextTrial = (profile) => {
|
|
764
|
+
const cwds = liveCwds()
|
|
765
|
+
const seen = new Set([''])
|
|
766
|
+
for (const entry of profile.directives) {
|
|
767
|
+
const scope = scopeOf(entry)
|
|
768
|
+
if (scope !== '' && cwds.some((cwd) => workspaceContains(scope, cwd))) seen.add(scope)
|
|
747
769
|
}
|
|
748
|
-
|
|
749
|
-
|
|
770
|
+
let changed = false
|
|
771
|
+
for (const entry of profile.directives) {
|
|
772
|
+
if (entry.status !== 'candidate' || seen.has(scopeOf(entry))) continue
|
|
773
|
+
entry.status = 'queued'
|
|
774
|
+
delete entry.trial
|
|
775
|
+
changed = true
|
|
776
|
+
console.info('[tacit] trial paused, workspace not loaded: ' + entry.text)
|
|
777
|
+
}
|
|
778
|
+
const review = effectiveConfig().reviewCandidates === true
|
|
779
|
+
const busy = new Set(profile.directives.filter((entry) => entry.status === 'candidate').map(scopeOf))
|
|
780
|
+
for (const entry of profile.directives) {
|
|
781
|
+
if (entry.status !== 'queued' || entry.enabled === false || busy.has(scopeOf(entry)) || !seen.has(scopeOf(entry))) continue
|
|
782
|
+
if (review && entry.source !== 'user' && !(entry.approvedAt > 0)) continue
|
|
783
|
+
busy.add(scopeOf(entry))
|
|
784
|
+
entry.status = 'candidate'
|
|
785
|
+
entry.trial = { turns: 0, messy: 0, corrected: 0, ...baselinesFor(scopeOf(entry)), startedAt: Date.now() }
|
|
786
|
+
changed = true
|
|
787
|
+
console.info('[tacit] directive on trial: ' + entry.text)
|
|
788
|
+
}
|
|
789
|
+
return changed
|
|
750
790
|
}
|
|
751
791
|
|
|
752
792
|
/** ONE small call every `directiveEvery` new analyses (or forced). Soft-fails; never throws. */
|
|
@@ -761,19 +801,19 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
761
801
|
? usage.beginRun({ type: 'directive-distillation', trigger: 'auto', sessionId, model: config.model, provider })
|
|
762
802
|
: runId
|
|
763
803
|
try {
|
|
764
|
-
const recent = store.listAllReports(20)
|
|
804
|
+
const recent = store.listAllReports(20)
|
|
805
|
+
.map((entry) => ({ ...store.report(entry.sessionId, entry.turn), sessionId: entry.sessionId, turn: entry.turn }))
|
|
806
|
+
.filter((report) => report.ok !== undefined || typeof report.problems === 'object')
|
|
807
|
+
// Newest first, before the prompt builder reverses the list for the model.
|
|
808
|
+
const evidence = recent.slice(0, MAX_DIRECTIVE_EVIDENCE).map((report) => ({ sessionId: report.sessionId, turn: report.turn, trigger: typeof report.trigger === 'string' ? report.trigger : 'manual' }))
|
|
765
809
|
// The model sees workspace names only; map them back to the directories they stand for.
|
|
766
|
-
const
|
|
767
|
-
|
|
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
|
-
}
|
|
810
|
+
const labels = directiveLabels(profile, recent)
|
|
811
|
+
const workspaces = new Map([...labels].map(([cwd, label]) => [label, cwd]))
|
|
772
812
|
const text = await callCoachModel(ctx, metered(usageRunId, { op: 'directive-distillation', sessionId }, {
|
|
773
813
|
provider,
|
|
774
814
|
model: config.model,
|
|
775
815
|
system: DIRECTIVE_SYSTEM_PROMPT,
|
|
776
|
-
userText: buildDirectiveUserText(profile, recent.reverse()),
|
|
816
|
+
userText: buildDirectiveUserText(profile, recent.reverse(), { labels }),
|
|
777
817
|
maxTokens: DIRECTIVE_MAX_TOKENS,
|
|
778
818
|
timeoutMs: DIRECTIVE_TIMEOUT_MS,
|
|
779
819
|
tool: DIRECTIVE_TOOL,
|
|
@@ -785,10 +825,32 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
785
825
|
console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
|
|
786
826
|
return
|
|
787
827
|
}
|
|
788
|
-
const items =
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
828
|
+
const items = []
|
|
829
|
+
for (const item of kept) {
|
|
830
|
+
if (item.workspace !== undefined && !workspaces.has(item.workspace)) {
|
|
831
|
+
console.info('[tacit] dropped directive (its workspace "' + item.workspace + '" is not one the evidence came from):', item.text)
|
|
832
|
+
continue
|
|
833
|
+
}
|
|
834
|
+
items.push({
|
|
835
|
+
text: item.text,
|
|
836
|
+
...(item.id === undefined ? {} : { id: item.id }),
|
|
837
|
+
...(item.workspace === undefined ? {} : { workspace: workspaces.get(item.workspace) }),
|
|
838
|
+
})
|
|
839
|
+
}
|
|
840
|
+
if (items.length === 0) return
|
|
841
|
+
const before = new Map(safeProfile().directives.map((entry) => [entry.id, entry.text]))
|
|
842
|
+
profile = mergeDirectives(safeProfile(), items, { nextId: nextDirectiveId })
|
|
843
|
+
const now = Date.now()
|
|
844
|
+
for (const entry of profile.directives) {
|
|
845
|
+
if (entry.source === 'user' || isDeadDirective(entry)) continue
|
|
846
|
+
const previous = before.get(entry.id)
|
|
847
|
+
if (previous === entry.text) continue
|
|
848
|
+
if (previous !== undefined) entry.version = (entry.version ?? 1) + 1
|
|
849
|
+
entry.updatedAt = now
|
|
850
|
+
entry.evidence = evidence
|
|
851
|
+
entry.distillationRunId = usageRunId
|
|
852
|
+
}
|
|
853
|
+
startNextTrial(profile)
|
|
792
854
|
profile.analysesSinceDirectives = 0
|
|
793
855
|
capAndSaveProfile(profile)
|
|
794
856
|
const scoped = items.filter((item) => item.workspace !== undefined).length
|
|
@@ -858,7 +920,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
858
920
|
if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
|
|
859
921
|
if (cwd !== undefined && cwdOf(session) !== cwd) continue
|
|
860
922
|
const { turns } = turnsOf(svc, session.id)
|
|
861
|
-
for (const turn of turns) if (turn
|
|
923
|
+
for (const turn of markCorrections(turns)) if (turn.finished === true) out.push(turn)
|
|
862
924
|
}
|
|
863
925
|
return out
|
|
864
926
|
}
|
|
@@ -868,6 +930,19 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
868
930
|
* deliberately not named `tokens`, which everywhere else in the ledger is the five-bucket object. */
|
|
869
931
|
const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0, runId: '', billedCalls: 0, unpricedCalls: 0, usdKnown: 0, tokensTotal: 0 }
|
|
870
932
|
|
|
933
|
+
/** Back to "no bootstrap has run": every field, so a no-op never shows the previous batch's figures. */
|
|
934
|
+
const resetBootstrapState = () => {
|
|
935
|
+
bootstrapState.running = false
|
|
936
|
+
bootstrapState.done = 0
|
|
937
|
+
bootstrapState.total = 0
|
|
938
|
+
bootstrapState.startedAt = 0
|
|
939
|
+
bootstrapState.runId = ''
|
|
940
|
+
bootstrapState.billedCalls = 0
|
|
941
|
+
bootstrapState.unpricedCalls = 0
|
|
942
|
+
bootstrapState.usdKnown = 0
|
|
943
|
+
bootstrapState.tokensTotal = 0
|
|
944
|
+
}
|
|
945
|
+
|
|
871
946
|
/** Mirror the bootstrap run's live counters into the state the panel polls. */
|
|
872
947
|
const refreshBootstrapUsage = () => {
|
|
873
948
|
const summary = bootstrapState.runId === '' ? null : usage.runSummary(bootstrapState.runId)
|
|
@@ -967,6 +1042,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
967
1042
|
// Nothing to analyze is a no-op, not a run: an empty run would be written
|
|
968
1043
|
// to the ledger as `failed` (no attempts) and read as a broken bootstrap.
|
|
969
1044
|
if (eligible.length === 0) {
|
|
1045
|
+
resetBootstrapState()
|
|
970
1046
|
return { ok: true, analyzed: 0, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: null }
|
|
971
1047
|
}
|
|
972
1048
|
const config = effectiveConfig()
|
|
@@ -980,15 +1056,11 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
980
1056
|
model: config.model,
|
|
981
1057
|
provider: scopedToSession ? providerForSession(sessionId) : COACH_PROVIDER,
|
|
982
1058
|
})
|
|
1059
|
+
resetBootstrapState()
|
|
983
1060
|
bootstrapState.running = true
|
|
984
|
-
bootstrapState.done = 0
|
|
985
1061
|
bootstrapState.total = eligible.length
|
|
986
1062
|
bootstrapState.startedAt = Date.now()
|
|
987
1063
|
bootstrapState.runId = runId
|
|
988
|
-
bootstrapState.billedCalls = 0
|
|
989
|
-
bootstrapState.unpricedCalls = 0
|
|
990
|
-
bootstrapState.usdKnown = 0
|
|
991
|
-
bootstrapState.tokensTotal = 0
|
|
992
1064
|
let analyzed = 0
|
|
993
1065
|
try {
|
|
994
1066
|
// A small worker pool: each worker pulls the next eligible turn until the
|
|
@@ -1019,6 +1091,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1019
1091
|
bootstrapState.running = false
|
|
1020
1092
|
closeRun(runId, { requested: limit, eligible: eligible.length, analyzed, skipped, directives: safeProfile().directives.length })
|
|
1021
1093
|
refreshBootstrapUsage()
|
|
1094
|
+
// Mirror the final counters first, then drop the id: the tile keeps the
|
|
1095
|
+
// finished run's figures, but nothing may still call it the live run.
|
|
1096
|
+
bootstrapState.runId = ''
|
|
1022
1097
|
}
|
|
1023
1098
|
return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: usage.runSummary(runId) }
|
|
1024
1099
|
}
|
|
@@ -1031,7 +1106,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1031
1106
|
* an auto trigger or another batch — reports `busy` and costs nothing. A
|
|
1032
1107
|
* bootstrap running elsewhere does NOT block the batch.
|
|
1033
1108
|
*/
|
|
1034
|
-
const analyzeBatch = async ({ sessionId, turns }) => {
|
|
1109
|
+
const analyzeBatch = async ({ sessionId, turns, force = false }) => {
|
|
1035
1110
|
const { session } = turnsOf(serviceOf(ctx), sessionId)
|
|
1036
1111
|
if (session === undefined) return { ok: false, results: [], profile: safeProfile(), run: null, code: 'no-session', detail: '' }
|
|
1037
1112
|
const wanted = [...new Set(turns)].sort((a, b) => a - b)
|
|
@@ -1055,7 +1130,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1055
1130
|
const at = next
|
|
1056
1131
|
next += 1
|
|
1057
1132
|
const turn = wanted[at]
|
|
1058
|
-
const result = await runAnalysis(sessionId, turn, { trigger: 'manual', runId })
|
|
1133
|
+
const result = await runAnalysis(sessionId, turn, { trigger: 'manual', runId, force })
|
|
1059
1134
|
const ok = result !== null && typeof result === 'object' && result.ok === true
|
|
1060
1135
|
if (ok) analyzed += 1
|
|
1061
1136
|
results[at] = { turn, ok, code: result?.code ?? 'call-failed', report: ok ? result.report : null }
|
|
@@ -1068,8 +1143,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1068
1143
|
// made and nothing failed, so this is a real request that succeeded —
|
|
1069
1144
|
// not the zero-attempt `failed` run the default derivation would write.
|
|
1070
1145
|
const entries = results.filter((entry) => entry !== null && entry !== undefined)
|
|
1071
|
-
const
|
|
1072
|
-
closeRun(runId, { requested: wanted.length, analyzed, skipped: wanted.length - analyzed },
|
|
1146
|
+
const allSkipped = analyzed === 0 && entries.length === wanted.length && entries.every((entry) => entry.code === 'busy' || entry.code === 'already-analyzed')
|
|
1147
|
+
closeRun(runId, { requested: wanted.length, analyzed, skipped: wanted.length - analyzed }, allSkipped ? 'success' : undefined)
|
|
1073
1148
|
}
|
|
1074
1149
|
return { ok: true, results, profile: safeProfile(), run: usage.runSummary(runId), code: '', detail: '' }
|
|
1075
1150
|
}
|
|
@@ -1201,7 +1276,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1201
1276
|
if (!parsed.success) {
|
|
1202
1277
|
return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '', run: null }
|
|
1203
1278
|
}
|
|
1204
|
-
return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual' })
|
|
1279
|
+
return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual', force: parsed.data.force === true })
|
|
1205
1280
|
},
|
|
1206
1281
|
|
|
1207
1282
|
/** Analyze a hand-picked set of turns of one session under a single run. */
|
|
@@ -1377,19 +1452,53 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1377
1452
|
const found = profile.directives.find((entry) => entry.id === input.id)
|
|
1378
1453
|
if (found === undefined) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'id' }
|
|
1379
1454
|
found.enabled = input.enabled
|
|
1455
|
+
found.updatedAt = Date.now()
|
|
1380
1456
|
// Re-enabling a retired directive is an explicit override.
|
|
1381
1457
|
if (input.enabled && found.status === 'retired') {
|
|
1382
1458
|
found.status = 'active'
|
|
1383
1459
|
delete found.retiredReason
|
|
1384
1460
|
}
|
|
1461
|
+
// A candidate is always enabled: switched off, it leaves the trial slot and queues again.
|
|
1462
|
+
if (!input.enabled && found.status === 'candidate') {
|
|
1463
|
+
found.status = 'queued'
|
|
1464
|
+
delete found.trial
|
|
1465
|
+
}
|
|
1466
|
+
if (!input.enabled) invalidateSteering()
|
|
1467
|
+
} else if (input.action === 'start-trial') {
|
|
1468
|
+
const found = profile.directives.find((entry) => entry.id === input.id)
|
|
1469
|
+
if (found === undefined || found.status !== 'queued') return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'id' }
|
|
1470
|
+
found.approvedAt = Date.now()
|
|
1471
|
+
found.updatedAt = found.approvedAt
|
|
1385
1472
|
} else if (input.action === 'add') {
|
|
1386
1473
|
const text = clipSafe(input.text.trim(), 220)
|
|
1387
1474
|
if (text.length === 0) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'text' }
|
|
1388
|
-
|
|
1389
|
-
|
|
1475
|
+
if (POLICY_RE.test(text)) return { ok: false, profile, steering: steeringStatus(), code: 'directive-policy', detail: '' }
|
|
1476
|
+
const workspace = normalizeWorkspace(typeof input.workspace === 'string' ? input.workspace.trim() : '')
|
|
1477
|
+
profile.directives.push({ id: nextDirectiveId(), text, enabled: true, source: 'user', createdAt: Date.now(), ...(workspace === '' ? {} : { workspace }) })
|
|
1478
|
+
} else if (input.action === 'rescope') {
|
|
1479
|
+
const found = profile.directives.find((entry) => entry.id === input.id)
|
|
1480
|
+
if (found === undefined) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'id' }
|
|
1481
|
+
const workspace = normalizeWorkspace(input.workspace.trim())
|
|
1482
|
+
if (workspace === '') delete found.workspace
|
|
1483
|
+
else found.workspace = workspace
|
|
1484
|
+
// A trial measured in one workspace says nothing about another.
|
|
1485
|
+
if (found.status === 'candidate') {
|
|
1486
|
+
found.status = 'queued'
|
|
1487
|
+
delete found.trial
|
|
1488
|
+
}
|
|
1390
1489
|
} else {
|
|
1391
|
-
|
|
1490
|
+
// A tombstone, not a delete: the distiller is told about it so it never
|
|
1491
|
+
// proposes the same directive (or a rewording) back.
|
|
1492
|
+
const found = profile.directives.find((entry) => entry.id === input.id)
|
|
1493
|
+
if (found !== undefined) {
|
|
1494
|
+
found.status = 'removed'
|
|
1495
|
+
found.enabled = false
|
|
1496
|
+
found.updatedAt = Date.now()
|
|
1497
|
+
delete found.trial
|
|
1498
|
+
invalidateSteering()
|
|
1499
|
+
}
|
|
1392
1500
|
}
|
|
1501
|
+
startNextTrial(profile)
|
|
1393
1502
|
const saved = capAndSaveProfile(profile)
|
|
1394
1503
|
return { ok: true, profile: saved, steering: steeringStatus(), code: '', detail: '' }
|
|
1395
1504
|
},
|
|
@@ -1451,15 +1560,54 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
1451
1560
|
async usageReport(args) {
|
|
1452
1561
|
const parsed = usageArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
|
|
1453
1562
|
if (!parsed.success) return { ok: false, code: 'bad-request', detail: '' }
|
|
1454
|
-
return
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1563
|
+
return {
|
|
1564
|
+
...usage.report({
|
|
1565
|
+
config: effectiveConfig(),
|
|
1566
|
+
pricingStatus: pricing.status(),
|
|
1567
|
+
pricingRates: pricing.rates(),
|
|
1568
|
+
filters: parsed.data,
|
|
1569
|
+
}),
|
|
1570
|
+
auto: autoStatus(),
|
|
1571
|
+
}
|
|
1460
1572
|
},
|
|
1461
1573
|
|
|
1462
1574
|
/** One run with its attempt rows (a live run included); expired ids are a soft `unknown-run`. */
|
|
1575
|
+
/** One directive's provenance and cost: ids, counts and money, never prompt text. */
|
|
1576
|
+
async directiveReceipt(args) {
|
|
1577
|
+
const parsed = directiveReceiptArgSchema.safeParse(args)
|
|
1578
|
+
if (!parsed.success) return { ok: false, receipt: null, code: 'bad-request', detail: '' }
|
|
1579
|
+
const entry = safeProfile().directives.find((candidate) => candidate.id === parsed.data.id)
|
|
1580
|
+
if (entry === undefined) return { ok: false, receipt: null, code: 'unknown-directive', detail: '' }
|
|
1581
|
+
const triggers = {}
|
|
1582
|
+
const conversations = new Set()
|
|
1583
|
+
for (const item of entry.evidence) {
|
|
1584
|
+
triggers[item.trigger] = (triggers[item.trigger] ?? 0) + 1
|
|
1585
|
+
conversations.add(item.sessionId)
|
|
1586
|
+
}
|
|
1587
|
+
const run = entry.distillationRunId === '' ? null : usage.run(entry.distillationRunId)
|
|
1588
|
+
const attempts = run !== null && Array.isArray(run.attempts) ? run.attempts.filter((attempt) => attempt.op === 'directive-distillation') : []
|
|
1589
|
+
const priced = attempts.filter((attempt) => attempt.priced !== null && typeof attempt.priced === 'object' && typeof attempt.priced.usd === 'number')
|
|
1590
|
+
const receipt = {
|
|
1591
|
+
id: entry.id,
|
|
1592
|
+
text: entry.text,
|
|
1593
|
+
scope: scopeOf(entry),
|
|
1594
|
+
status: entry.status,
|
|
1595
|
+
source: entry.source,
|
|
1596
|
+
enabled: entry.enabled !== false,
|
|
1597
|
+
createdAt: entry.createdAt,
|
|
1598
|
+
updatedAt: entry.updatedAt,
|
|
1599
|
+
evaluatedAt: entry.evaluatedAt,
|
|
1600
|
+
approvedAt: entry.approvedAt,
|
|
1601
|
+
version: entry.version,
|
|
1602
|
+
trial: entry.trial ?? null,
|
|
1603
|
+
retiredReason: entry.retiredReason ?? '',
|
|
1604
|
+
triggers,
|
|
1605
|
+
evidence: { turns: entry.evidence.length, conversations: conversations.size, items: entry.evidence },
|
|
1606
|
+
cost: { runId: entry.distillationRunId, calls: attempts.length, usd: priced.length === 0 ? null : priced.reduce((sum, attempt) => sum + attempt.priced.usd, 0) },
|
|
1607
|
+
}
|
|
1608
|
+
return { ok: true, receipt, code: '', detail: '' }
|
|
1609
|
+
},
|
|
1610
|
+
|
|
1463
1611
|
async usageRun(args) {
|
|
1464
1612
|
const parsed = usageRunArgSchema.safeParse(args)
|
|
1465
1613
|
if (!parsed.success) return { ok: false, run: null, code: 'bad-request', detail: '' }
|