dsh-tacit 0.2.1 → 0.2.3
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 +36 -2
- package/client/client.js +94 -27
- package/docs/README.md +3 -3
- package/docs/README.zh.md +6 -5
- package/lib/analyze.js +177 -21
- package/lib/index.js +13 -0
- package/lib/routes.js +1 -1
- package/lib/schema.js +17 -1
- package/lib/service.js +207 -55
- package/lib/store.js +1 -0
- package/package.json +13 -7
package/lib/service.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* an `ok` flag and a stable error `code` the client localizes. Expected
|
|
9
9
|
* failures never throw; unexpected ones are mapped by the route layer.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Model calls are either user-triggered (analyzeTurn / improveDraft /
|
|
12
|
+
* bootstrap) or automatic analyses of messy and corrected turns, the latter
|
|
13
|
+
* capped by `autoDailyBudget`. There is no polling and no telemetry.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import {
|
|
@@ -43,6 +44,9 @@ import {
|
|
|
43
44
|
normalizeReport,
|
|
44
45
|
parseJsonObject,
|
|
45
46
|
ANALYSIS_SYSTEM_PROMPT,
|
|
47
|
+
GOOD_SYSTEM_PROMPT,
|
|
48
|
+
GOOD_TOOL,
|
|
49
|
+
normalizeGoodReport,
|
|
46
50
|
ANALYSIS_REPAIR_SYSTEM_PROMPT,
|
|
47
51
|
IMPROVE_SYSTEM_PROMPT,
|
|
48
52
|
IMPROVE_REPAIR_SYSTEM_PROMPT,
|
|
@@ -70,7 +74,10 @@ import {
|
|
|
70
74
|
DIRECTIVE_TIMEOUT_MS,
|
|
71
75
|
MAX_DIRECTIVES,
|
|
72
76
|
buildDirectiveUserText,
|
|
77
|
+
buildSteeringSection,
|
|
73
78
|
renderSteeringSection,
|
|
79
|
+
workspaceLabel,
|
|
80
|
+
MAX_WORKSPACE_DIRECTIVES,
|
|
74
81
|
ENRICH_SYSTEM_PROMPT,
|
|
75
82
|
ENRICH_TOOL,
|
|
76
83
|
ENRICH_MAX_TOKENS,
|
|
@@ -110,6 +117,8 @@ export function mergeConfig(base, patch) {
|
|
|
110
117
|
merged.enrichPrompts = merged.enrichPrompts === true
|
|
111
118
|
merged.directiveTrialTurns = Math.max(1, Math.min(500, Math.round(Number(merged.directiveTrialTurns) || 10)))
|
|
112
119
|
merged.directiveWorseBy = Math.max(0, Math.min(1, Number.isFinite(Number(merged.directiveWorseBy)) ? Number(merged.directiveWorseBy) : 0.15))
|
|
120
|
+
merged.bootstrapConcurrency = Math.max(1, Math.min(4, Math.round(Number(merged.bootstrapConcurrency) || 1)))
|
|
121
|
+
merged.learnFromGood = merged.learnFromGood !== false
|
|
113
122
|
return merged
|
|
114
123
|
}
|
|
115
124
|
|
|
@@ -133,15 +142,44 @@ function turnsOf(service, sessionId) {
|
|
|
133
142
|
}
|
|
134
143
|
}
|
|
135
144
|
|
|
136
|
-
/**
|
|
137
|
-
function
|
|
138
|
-
const session = typeof service.sessions?.get === 'function' ? service.sessions.get(sessionId) : undefined
|
|
145
|
+
/** The absolute workspace directory a session was created in, else undefined. */
|
|
146
|
+
function cwdOf(session) {
|
|
139
147
|
const cwd = session !== null && typeof session === 'object' && session.header !== null && typeof session.header === 'object'
|
|
140
148
|
? session.header.cwd
|
|
141
149
|
: undefined
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
150
|
+
return typeof cwd === 'string' && cwd.length > 0 ? cwd : undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A human label for a session: the workspace directory's basename, else ''. */
|
|
154
|
+
function sessionLabelOf(service, sessionId) {
|
|
155
|
+
const session = typeof service.sessions?.get === 'function' ? service.sessions.get(sessionId) : undefined
|
|
156
|
+
return workspaceLabel(cwdOf(session))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Every distinct workspace among the live sessions, labelled for the UI. */
|
|
160
|
+
function listWorkspaces(service) {
|
|
161
|
+
const sessions = typeof service.sessions?.list === 'function' ? service.sessions.list() : []
|
|
162
|
+
const seen = new Map()
|
|
163
|
+
for (const session of Array.isArray(sessions) ? sessions : []) {
|
|
164
|
+
const cwd = cwdOf(session)
|
|
165
|
+
if (cwd !== undefined && !seen.has(cwd)) seen.set(cwd, { cwd, label: workspaceLabel(cwd) })
|
|
166
|
+
}
|
|
167
|
+
return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** At most MAX_DIRECTIVES global directives and MAX_WORKSPACE_DIRECTIVES per workspace, order kept. */
|
|
171
|
+
function capDirectives(list) {
|
|
172
|
+
const counts = new Map()
|
|
173
|
+
const out = []
|
|
174
|
+
for (const entry of list) {
|
|
175
|
+
const scope = typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : ''
|
|
176
|
+
const limit = scope === '' ? MAX_DIRECTIVES : MAX_WORKSPACE_DIRECTIVES
|
|
177
|
+
const n = counts.get(scope) ?? 0
|
|
178
|
+
if (n >= limit) continue
|
|
179
|
+
counts.set(scope, n + 1)
|
|
180
|
+
out.push(entry)
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
145
183
|
}
|
|
146
184
|
|
|
147
185
|
/** Short, secret-free context digest of a session's last two finished turns. */
|
|
@@ -219,6 +257,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
219
257
|
const rewriteRecords = new Map()
|
|
220
258
|
/** sessionId → FIFO [{rewriteId, baseline}] (one per applied rewrite). */
|
|
221
259
|
const pendingVerifications = new Map()
|
|
260
|
+
/** sessionId → ids of the directives in that session's frozen steering (bounded; insertion order = age). */
|
|
261
|
+
const steeringIdsBySession = new Map()
|
|
262
|
+
const MAX_STEERING_SESSIONS = 500
|
|
222
263
|
let distillInFlight = false
|
|
223
264
|
let rewriteSeq = 0
|
|
224
265
|
|
|
@@ -234,7 +275,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
234
275
|
return 'd' + Date.now().toString(36) + '-' + directiveSeq.toString(36)
|
|
235
276
|
}
|
|
236
277
|
let directivesInFlight = false
|
|
237
|
-
/** Steering text frozen per live session (keeps the model's prefix cache stable within a session). */
|
|
278
|
+
/** Steering `{ text, ids }` frozen per live session object (keeps the model's prefix cache stable within a session). */
|
|
238
279
|
const steeringFrozen = new WeakMap()
|
|
239
280
|
|
|
240
281
|
const nextRewriteId = () => {
|
|
@@ -271,7 +312,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
271
312
|
profile.patterns = profile.patterns.slice(0, config.maxPatterns)
|
|
272
313
|
profile.styleRules = profile.styleRules.slice(-MAX_STYLE_RULES)
|
|
273
314
|
profile.feedbackLog = profile.feedbackLog.slice(-MAX_FEEDBACK_LOG)
|
|
274
|
-
profile.directives = profile.directives
|
|
315
|
+
profile.directives = capDirectives(profile.directives)
|
|
275
316
|
profile.updatedAt = Date.now()
|
|
276
317
|
const validated = profileSchema.parse(profile)
|
|
277
318
|
store.saveProfile(validated)
|
|
@@ -285,7 +326,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
285
326
|
* Returns the profile with fresh rules on success, the input profile on a
|
|
286
327
|
* soft no-op/failure.
|
|
287
328
|
*/
|
|
288
|
-
const maybeDistill = async (profile, provider) => {
|
|
329
|
+
const maybeDistill = async (profile, provider, sessionId) => {
|
|
289
330
|
if (profile.pendingDistill < 3 || distillInFlight) return profile
|
|
290
331
|
distillInFlight = true
|
|
291
332
|
try {
|
|
@@ -299,6 +340,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
299
340
|
maxTokens: DISTILL_MAX_TOKENS,
|
|
300
341
|
timeoutMs: DISTILL_TIMEOUT_MS,
|
|
301
342
|
tool: DISTILL_TOOL,
|
|
343
|
+
sessionId,
|
|
302
344
|
})
|
|
303
345
|
const rules = normalizeDistillRules(text)
|
|
304
346
|
if (rules.length === 0) return profile
|
|
@@ -352,10 +394,12 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
352
394
|
|
|
353
395
|
/**
|
|
354
396
|
* Directive trials ride the same free feed: every NEW finished turn counts
|
|
355
|
-
* toward each candidate
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
397
|
+
* toward each candidate that was actually in that session's frozen steering
|
|
398
|
+
* text; after `directiveTrialTurns` such turns the candidate is activated,
|
|
399
|
+
* or retired when the messy rate rose past the baseline by more than
|
|
400
|
+
* `directiveWorseBy`. A session whose steering was never assembled here
|
|
401
|
+
* (started before the candidate existed, or before a restart) counts toward
|
|
402
|
+
* nobody — its turns say nothing about the candidate.
|
|
359
403
|
*/
|
|
360
404
|
const recordTrialTurns = (sessionId, turns) => {
|
|
361
405
|
const fresh = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object'
|
|
@@ -363,8 +407,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
363
407
|
&& !seenFinished.has(sessionId + ':' + turn.turn))
|
|
364
408
|
if (fresh.length === 0) return
|
|
365
409
|
for (const turn of fresh) seenFinished.add(sessionId + ':' + turn.turn)
|
|
410
|
+
const steered = steeringIdsBySession.get(sessionId)
|
|
411
|
+
if (steered === undefined || steered.length === 0) return
|
|
366
412
|
const profile = safeProfile()
|
|
367
|
-
const candidates = profile.directives.filter((entry) => entry.status === 'candidate' && entry.trial !== undefined)
|
|
413
|
+
const candidates = profile.directives.filter((entry) => entry.status === 'candidate' && entry.trial !== undefined && steered.includes(entry.id))
|
|
368
414
|
if (candidates.length === 0) return
|
|
369
415
|
const config = effectiveConfig()
|
|
370
416
|
const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
|
|
@@ -430,6 +476,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
430
476
|
const svc = serviceOf(ctx)
|
|
431
477
|
const { session, turns } = turnsOf(svc, sessionId)
|
|
432
478
|
if (session === undefined) return { ok: false, report: null, profile, code: 'no-session', detail: '' }
|
|
479
|
+
const cwd = cwdOf(session)
|
|
433
480
|
// The change feed already carries the digest; a manual click re-reads the snapshot.
|
|
434
481
|
const record = digest !== null && typeof digest === 'object' ? digest : turns.find((candidate) => candidate?.turn === turn)
|
|
435
482
|
if (record === undefined) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
|
|
@@ -446,6 +493,40 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
446
493
|
const provider = typeof record.provider === 'string' && record.provider.length > 0
|
|
447
494
|
? record.provider
|
|
448
495
|
: COACH_PROVIDER
|
|
496
|
+
if (trigger === 'good') {
|
|
497
|
+
// One attempt, no repair retry: a recovery lesson is a bonus, not a diagnosis.
|
|
498
|
+
const goodText = await callCoachModel(ctx, {
|
|
499
|
+
provider,
|
|
500
|
+
model: config.model,
|
|
501
|
+
system: GOOD_SYSTEM_PROMPT,
|
|
502
|
+
userText,
|
|
503
|
+
maxTokens: ANALYZE_MAX_TOKENS,
|
|
504
|
+
timeoutMs: ANALYZE_TIMEOUT_MS,
|
|
505
|
+
tool: GOOD_TOOL,
|
|
506
|
+
sessionId,
|
|
507
|
+
})
|
|
508
|
+
const goodParsed = goodText.trim() === '' ? null : parseJsonObject(goodText)
|
|
509
|
+
if (goodParsed === null) return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
|
|
510
|
+
const goodReport = {
|
|
511
|
+
...normalizeGoodReport(goodParsed, { turn, time: Date.now(), model: config.model, prompt: record.prompt }),
|
|
512
|
+
...(typeof record.prompt === 'string' && record.prompt.length > 0 ? { promptExcerpt: clipSafe(record.prompt, 200) } : {}),
|
|
513
|
+
trigger,
|
|
514
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
515
|
+
}
|
|
516
|
+
if (goodReport.lesson === '' && goodReport.strengths.length === 0) {
|
|
517
|
+
return { ok: false, report: null, profile, code: 'nothing-learned', detail: '' }
|
|
518
|
+
}
|
|
519
|
+
const isNew = store.report(sessionId, turn) === null
|
|
520
|
+
store.saveReport(sessionId, turn, goodReport)
|
|
521
|
+
const grown = aggregateProfile(store.profile(), goodReport, config.maxPatterns, { countNew: isNew })
|
|
522
|
+
if (isNew) grown.analysesSinceDirectives = (grown.analysesSinceDirectives ?? 0) + 1
|
|
523
|
+
store.saveProfile(grown)
|
|
524
|
+
if (isNew && !bootstrapState.running) {
|
|
525
|
+
const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
|
|
526
|
+
autoRunning.add(task)
|
|
527
|
+
}
|
|
528
|
+
return { ok: true, report: goodReport, profile: grown, code: '', detail: '' }
|
|
529
|
+
}
|
|
449
530
|
let text = await callCoachModel(ctx, {
|
|
450
531
|
provider,
|
|
451
532
|
model: config.model,
|
|
@@ -492,6 +573,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
492
573
|
: {}),
|
|
493
574
|
trigger,
|
|
494
575
|
...(followUp.length > 0 ? { followUp: clipSafe(followUp, 300) } : {}),
|
|
576
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
495
577
|
}
|
|
496
578
|
const countNew = store.report(sessionId, turn) === null
|
|
497
579
|
store.saveReport(sessionId, turn, report)
|
|
@@ -512,23 +594,31 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
512
594
|
return exclusive
|
|
513
595
|
}
|
|
514
596
|
|
|
515
|
-
const steeringStatus = () => {
|
|
597
|
+
const steeringStatus = (cwd) => {
|
|
516
598
|
const config = effectiveConfig()
|
|
517
|
-
return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile()) : '' }
|
|
599
|
+
return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile(), { cwd }) : '' }
|
|
518
600
|
}
|
|
519
601
|
|
|
602
|
+
/** What a session in `cwd` assembling its system prompt right now would get. */
|
|
603
|
+
const steeringNow = (cwd) => (effectiveConfig().steerAgent ? buildSteeringSection(safeProfile(), { cwd }) : { text: '', ids: [] })
|
|
604
|
+
|
|
520
605
|
/** The system-prompt section provider (sync; frozen per session). */
|
|
521
606
|
const steeringText = (assemble) => {
|
|
522
607
|
const session = assemble !== null && typeof assemble === 'object' && assemble.agent !== null && typeof assemble.agent === 'object'
|
|
523
608
|
? assemble.agent.session
|
|
524
609
|
: undefined
|
|
525
|
-
if (session === null || session === undefined || typeof session !== 'object') return
|
|
610
|
+
if (session === null || session === undefined || typeof session !== 'object') return steeringNow().text
|
|
526
611
|
let frozen = steeringFrozen.get(session)
|
|
527
612
|
if (frozen === undefined) {
|
|
528
|
-
frozen =
|
|
613
|
+
frozen = steeringNow(cwdOf(session))
|
|
529
614
|
steeringFrozen.set(session, frozen)
|
|
615
|
+
if (typeof session.id === 'string' && session.id.length > 0) {
|
|
616
|
+
steeringIdsBySession.delete(session.id)
|
|
617
|
+
steeringIdsBySession.set(session.id, frozen.ids)
|
|
618
|
+
while (steeringIdsBySession.size > MAX_STEERING_SESSIONS) steeringIdsBySession.delete(steeringIdsBySession.keys().next().value)
|
|
619
|
+
}
|
|
530
620
|
}
|
|
531
|
-
return frozen
|
|
621
|
+
return frozen.text
|
|
532
622
|
}
|
|
533
623
|
|
|
534
624
|
/**
|
|
@@ -536,35 +626,58 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
536
626
|
* entries are untouched, and a re-emitted directive keeps the enabled flag
|
|
537
627
|
* the user gave its identical text. Capped at MAX_DIRECTIVES overall.
|
|
538
628
|
*/
|
|
539
|
-
const
|
|
540
|
-
|
|
629
|
+
const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : '')
|
|
630
|
+
const directiveKey = (scope, text) => scope + '\n' + text.trim().toLowerCase()
|
|
631
|
+
|
|
632
|
+
/** Messy-turn baseline for a new candidate: the workspace's own turns when there are enough, else everything. */
|
|
633
|
+
const baselineRateFor = (cwd) => {
|
|
634
|
+
const scoped = cwd !== undefined ? allFinishedTurns({ cwd }) : []
|
|
635
|
+
const turns = scoped.length >= 20 ? scoped : allFinishedTurns()
|
|
636
|
+
return computeTrend(turns, { window: 20 }).recent.messyRate
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Merge the model's new complete set of directives ({ text, workspace? }):
|
|
641
|
+
* user entries are untouched; the global distilled set and the distilled set
|
|
642
|
+
* of every workspace the model mentioned are replaced; distilled entries of
|
|
643
|
+
* other workspaces are kept (their evidence was not in this batch). A
|
|
644
|
+
* re-emitted directive keeps its identity, state and enabled flag.
|
|
645
|
+
*/
|
|
646
|
+
const mergeDirectives = (profile, items) => {
|
|
541
647
|
const users = profile.directives.filter((entry) => entry.source === 'user')
|
|
542
|
-
const userKeys = new Set(users.map((entry) => entry.text
|
|
648
|
+
const userKeys = new Set(users.map((entry) => directiveKey(scopeOf(entry), entry.text)))
|
|
649
|
+
const prior = profile.directives.filter((entry) => entry.source !== 'user')
|
|
650
|
+
const previous = new Map(prior.map((entry) => [directiveKey(scopeOf(entry), entry.text), entry]))
|
|
651
|
+
const mentioned = new Set([''])
|
|
652
|
+
for (const item of items) mentioned.add(scopeOf(item))
|
|
653
|
+
const untouched = prior.filter((entry) => !mentioned.has(scopeOf(entry)))
|
|
543
654
|
const distilled = []
|
|
544
655
|
const seen = new Set()
|
|
545
|
-
|
|
546
|
-
for (const
|
|
547
|
-
const
|
|
656
|
+
const baselines = new Map()
|
|
657
|
+
for (const item of items) {
|
|
658
|
+
const scope = scopeOf(item)
|
|
659
|
+
const key = directiveKey(scope, item.text)
|
|
548
660
|
if (seen.has(key) || userKeys.has(key)) continue
|
|
549
661
|
seen.add(key)
|
|
550
662
|
const kept = previous.get(key)
|
|
551
663
|
if (kept !== undefined) {
|
|
552
|
-
distilled.push({ ...kept, text })
|
|
664
|
+
distilled.push({ ...kept, text: item.text })
|
|
553
665
|
continue
|
|
554
666
|
}
|
|
555
667
|
// A new distilled directive goes on trial against the current messy-turn rate.
|
|
556
|
-
if (
|
|
668
|
+
if (!baselines.has(scope)) baselines.set(scope, baselineRateFor(scope === '' ? undefined : scope))
|
|
557
669
|
distilled.push({
|
|
558
670
|
id: nextDirectiveId(),
|
|
559
|
-
text,
|
|
671
|
+
text: item.text,
|
|
560
672
|
enabled: true,
|
|
561
673
|
source: 'distilled',
|
|
562
674
|
createdAt: Date.now(),
|
|
563
675
|
status: 'candidate',
|
|
564
|
-
trial: { turns: 0, messy: 0, baselineRate, startedAt: Date.now() },
|
|
676
|
+
trial: { turns: 0, messy: 0, baselineRate: baselines.get(scope), startedAt: Date.now() },
|
|
677
|
+
...(scope === '' ? {} : { workspace: scope }),
|
|
565
678
|
})
|
|
566
679
|
}
|
|
567
|
-
profile.directives = [...users, ...distilled
|
|
680
|
+
profile.directives = capDirectives([...users, ...distilled, ...untouched])
|
|
568
681
|
return profile
|
|
569
682
|
}
|
|
570
683
|
|
|
@@ -577,6 +690,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
577
690
|
directivesInFlight = true
|
|
578
691
|
try {
|
|
579
692
|
const recent = store.listAllReports(20).map((entry) => store.report(entry.sessionId, entry.turn)).filter((report) => report !== null)
|
|
693
|
+
// The model sees workspace names only; map them back to the directories they stand for.
|
|
694
|
+
const workspaces = new Map()
|
|
695
|
+
for (const report of recent) {
|
|
696
|
+
if (typeof report.cwd !== 'string' || report.cwd.length === 0) continue
|
|
697
|
+
const label = workspaceLabel(report.cwd)
|
|
698
|
+
if (label.length > 0 && !workspaces.has(label)) workspaces.set(label, report.cwd)
|
|
699
|
+
}
|
|
580
700
|
const text = await callCoachModel(ctx, {
|
|
581
701
|
provider,
|
|
582
702
|
model: config.model,
|
|
@@ -587,16 +707,20 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
587
707
|
tool: DIRECTIVE_TOOL,
|
|
588
708
|
sessionId,
|
|
589
709
|
})
|
|
590
|
-
const { kept
|
|
710
|
+
const { kept, rejected } = classifyDirectives(text)
|
|
591
711
|
for (const dropped of rejected) console.warn('[tacit] dropped directive (it asks the user instead of compensating):', dropped)
|
|
592
|
-
if (
|
|
712
|
+
if (kept.length === 0) {
|
|
593
713
|
console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
|
|
594
714
|
return
|
|
595
715
|
}
|
|
596
|
-
|
|
716
|
+
const items = kept.map((item) => (item.workspace !== undefined && workspaces.has(item.workspace)
|
|
717
|
+
? { text: item.text, workspace: workspaces.get(item.workspace) }
|
|
718
|
+
: { text: item.text }))
|
|
719
|
+
profile = mergeDirectives(safeProfile(), items)
|
|
597
720
|
profile.analysesSinceDirectives = 0
|
|
598
721
|
capAndSaveProfile(profile)
|
|
599
|
-
|
|
722
|
+
const scoped = items.filter((item) => item.workspace !== undefined).length
|
|
723
|
+
console.info('[tacit] distilled ' + items.length + ' directive(s) into the steering section' + (scoped > 0 ? ' (' + scoped + ' workspace-specific)' : ''))
|
|
600
724
|
} catch (error) {
|
|
601
725
|
// Soft: the counter stays and the next analysis retries.
|
|
602
726
|
console.warn('[tacit] directive distillation failed (will retry):', error instanceof Error ? error.message : String(error))
|
|
@@ -646,13 +770,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
646
770
|
}
|
|
647
771
|
}
|
|
648
772
|
|
|
649
|
-
/** Every live session's finished turns, for the measured trend. */
|
|
650
|
-
const allFinishedTurns = () => {
|
|
773
|
+
/** Every live session's finished turns (optionally only sessions in one workspace), for the measured trend. */
|
|
774
|
+
const allFinishedTurns = ({ cwd } = {}) => {
|
|
651
775
|
const svc = serviceOf(ctx)
|
|
652
776
|
const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
|
|
653
777
|
const out = []
|
|
654
778
|
for (const session of Array.isArray(sessions) ? sessions : []) {
|
|
655
779
|
if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
|
|
780
|
+
if (cwd !== undefined && cwdOf(session) !== cwd) continue
|
|
656
781
|
const { turns } = turnsOf(svc, session.id)
|
|
657
782
|
for (const turn of turns) if (turn?.finished === true) out.push(turn)
|
|
658
783
|
}
|
|
@@ -665,7 +790,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
665
790
|
/**
|
|
666
791
|
* "Learn from my last N turns now": explicit user action, so it ignores the
|
|
667
792
|
* daily auto budget. Skips continuations, tiny prompts and turns that already
|
|
668
|
-
* have a report;
|
|
793
|
+
* have a report; runs up to `bootstrapConcurrency` analyses at once (same
|
|
794
|
+
* number of calls either way); then forces one directive distillation.
|
|
669
795
|
*/
|
|
670
796
|
const runBootstrap = async ({ sessionId, limit }) => {
|
|
671
797
|
if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '' }
|
|
@@ -700,19 +826,29 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
700
826
|
bootstrapState.total = eligible.length
|
|
701
827
|
bootstrapState.startedAt = Date.now()
|
|
702
828
|
let analyzed = 0
|
|
703
|
-
let lastProvider = COACH_PROVIDER
|
|
704
829
|
try {
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
830
|
+
// A small worker pool: each worker pulls the next eligible turn until the
|
|
831
|
+
// list is drained. Analyses for different turns never share an in-flight
|
|
832
|
+
// key, and the profile read-modify-write inside runAnalysis has no await,
|
|
833
|
+
// so concurrent analyses cannot lose each other's counts.
|
|
834
|
+
let next = 0
|
|
835
|
+
const worker = async () => {
|
|
836
|
+
while (next < eligible.length) {
|
|
837
|
+
const item = eligible[next]
|
|
838
|
+
next += 1
|
|
839
|
+
const previous = previousFinishedOf(item.turns, item.turn.turn)
|
|
840
|
+
const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous })
|
|
841
|
+
if (result !== null && typeof result === 'object' && result.ok === true) analyzed += 1
|
|
842
|
+
else console.warn('[tacit] bootstrap: ' + item.sessionId + ':' + item.turn.turn + ' skipped: ' + (result?.code ?? 'unknown'))
|
|
843
|
+
bootstrapState.done += 1
|
|
844
|
+
}
|
|
712
845
|
}
|
|
846
|
+
const concurrency = Math.min(effectiveConfig().bootstrapConcurrency, Math.max(1, eligible.length))
|
|
847
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
|
713
848
|
if (analyzed > 0) {
|
|
714
849
|
await service.flushAuto() // let any scheduled distillation settle before forcing one
|
|
715
|
-
|
|
850
|
+
// The forced distillation is attributed to the newest eligible turn's session.
|
|
851
|
+
await maybeDistillDirectives(eligible[0].sessionId, providerForSession(eligible[0].sessionId), { force: true })
|
|
716
852
|
}
|
|
717
853
|
} finally {
|
|
718
854
|
bootstrapState.running = false
|
|
@@ -753,12 +889,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
753
889
|
}
|
|
754
890
|
|
|
755
891
|
/**
|
|
756
|
-
* Zero-click learning.
|
|
892
|
+
* Zero-click learning. Three triggers, all free (no model call to decide):
|
|
757
893
|
* - the newest FINISHED turn is messy (retries / tool errors / compactions /
|
|
758
894
|
* rejection / long step run);
|
|
759
895
|
* - the newest (possibly unfinished) turn's prompt reads as a correction of
|
|
760
896
|
* the previous answer → the PREVIOUS turn is analyzed with that
|
|
761
|
-
* follow-up attached as evidence
|
|
897
|
+
* follow-up attached as evidence;
|
|
898
|
+
* - (learnFromGood) the newest finished turn is clean right after a messy
|
|
899
|
+
* one → a small "what did the user include this time" call.
|
|
762
900
|
* Turns finished before the plugin started are ignored (cold restore).
|
|
763
901
|
*/
|
|
764
902
|
const maybeAutoAnalyze = (sessionId, turns) => {
|
|
@@ -774,6 +912,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
774
912
|
// the conversation is its context. Heavy work after it is not a prompt fault.
|
|
775
913
|
if (fresh(newest) && isMessyTurn(newest, { minSteps: config.autoMinSteps }) && !looksLikeContinuation(newest.prompt)) {
|
|
776
914
|
scheduleAuto(sessionId, newest.turn, { trigger: 'auto', digest: newest, previousDigest: previous })
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
// A recovery: clean now, messy just before, with a real prompt in between.
|
|
918
|
+
const recovery = config.learnFromGood && fresh(newest) && !looksLikeContinuation(newest.prompt)
|
|
919
|
+
&& typeof newest.prompt === 'string' && newest.prompt.trim().length >= ENRICH_MIN_DRAFT_CHARS
|
|
920
|
+
&& previous !== null && previous.finished === true && isMessyTurn(previous, { minSteps: config.autoMinSteps })
|
|
921
|
+
if (recovery) {
|
|
922
|
+
scheduleAuto(sessionId, newest.turn, { trigger: 'good', digest: newest, previousDigest: previous })
|
|
777
923
|
}
|
|
778
924
|
return
|
|
779
925
|
}
|
|
@@ -790,13 +936,18 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
790
936
|
await Promise.all([...autoRunning])
|
|
791
937
|
},
|
|
792
938
|
|
|
793
|
-
|
|
939
|
+
/** Optional `{ sessionId }`: the steering preview is then rendered for that conversation's workspace. */
|
|
940
|
+
async getState(args) {
|
|
941
|
+
const svc = serviceOf(ctx)
|
|
942
|
+
const sessionId = args !== null && typeof args === 'object' && typeof args.sessionId === 'string' && args.sessionId.length > 0 ? args.sessionId : null
|
|
943
|
+
const session = sessionId !== null && typeof svc.sessions?.get === 'function' ? svc.sessions.get(sessionId) : undefined
|
|
794
944
|
return {
|
|
795
945
|
ok: true,
|
|
796
946
|
config: effectiveConfig(),
|
|
797
947
|
profile: safeProfile(),
|
|
798
948
|
auto: autoStatus(),
|
|
799
|
-
steering: steeringStatus(),
|
|
949
|
+
steering: steeringStatus(cwdOf(session)),
|
|
950
|
+
workspaces: listWorkspaces(svc),
|
|
800
951
|
bootstrap: { ...bootstrapState },
|
|
801
952
|
message: '',
|
|
802
953
|
}
|
|
@@ -843,7 +994,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
843
994
|
// Distillation also fires on user-triggered improve calls (soft, in-flight
|
|
844
995
|
// deduped, never awaited: an improve call is never blocked by it).
|
|
845
996
|
if (profile.pendingDistill >= 3) {
|
|
846
|
-
maybeDistill(profile, providerForSession(sessionId)).catch(() => {})
|
|
997
|
+
maybeDistill(profile, providerForSession(sessionId), sessionId).catch(() => {})
|
|
847
998
|
}
|
|
848
999
|
// Only trusted (or still-inexperienced) patterns reach the prompt;
|
|
849
1000
|
// style rules + the last 3 verbatim down-reasons ride along for free
|
|
@@ -948,7 +1099,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
948
1099
|
// user action. Awaited so the returned profile already carries the
|
|
949
1100
|
// fresh style rules; a failure is soft-silent and retries later.
|
|
950
1101
|
if (profile.pendingDistill >= 3) {
|
|
951
|
-
profile = await maybeDistill(profile, providerForSession(record.sessionId))
|
|
1102
|
+
profile = await maybeDistill(profile, providerForSession(record.sessionId), record.sessionId)
|
|
952
1103
|
}
|
|
953
1104
|
return { ok: true, profile, code: '', detail: '' }
|
|
954
1105
|
},
|
|
@@ -997,7 +1148,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
997
1148
|
} else if (input.action === 'add') {
|
|
998
1149
|
const text = clipSafe(input.text.trim(), 220)
|
|
999
1150
|
if (text.length === 0) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'text' }
|
|
1000
|
-
|
|
1151
|
+
const workspace = typeof input.workspace === 'string' && input.workspace.trim().length > 0 ? input.workspace.trim() : undefined
|
|
1152
|
+
profile.directives.push({ id: nextDirectiveId(), text, enabled: true, source: 'user', createdAt: Date.now(), ...(workspace === undefined ? {} : { workspace }) })
|
|
1001
1153
|
} else {
|
|
1002
1154
|
profile.directives = profile.directives.filter((entry) => entry.id !== input.id)
|
|
1003
1155
|
}
|
package/lib/store.js
CHANGED
|
@@ -149,6 +149,7 @@ export class CoachStore {
|
|
|
149
149
|
promptExcerpt: typeof report.promptExcerpt === 'string' ? report.promptExcerpt : '',
|
|
150
150
|
improvedPrompt: typeof report.improvedPrompt === 'string' ? report.improvedPrompt : '',
|
|
151
151
|
trigger: typeof report.trigger === 'string' ? report.trigger : 'manual',
|
|
152
|
+
cwd: typeof report.cwd === 'string' ? report.cwd : '',
|
|
152
153
|
})
|
|
153
154
|
}
|
|
154
155
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tacit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Tacit learns what you leave unsaid in your prompts — from messy turns and your own corrections, with zero clicks — and tells the agent how to compensate, on every turn, via a system-prompt section you can read and edit.",
|
|
5
5
|
"author": "hackernotfound",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"lib",
|
|
32
|
-
"client",
|
|
32
|
+
"client/client.js",
|
|
33
33
|
"cordis.patch.yml",
|
|
34
34
|
"README.md",
|
|
35
35
|
"LICENSE",
|
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
39
|
"test": "node --test",
|
|
40
|
-
"
|
|
40
|
+
"build:client": "node scripts/build-client.mjs",
|
|
41
|
+
"check": "pnpm test && pnpm check:client && pnpm check:docs && pnpm check:package",
|
|
42
|
+
"check:client": "node scripts/build-client.mjs --check",
|
|
41
43
|
"check:docs": "node scripts/check-doc-links.mjs",
|
|
42
44
|
"check:package": "node scripts/check-package.mjs",
|
|
43
45
|
"smoke": "node scripts/smoke.mjs"
|
|
@@ -86,13 +88,17 @@
|
|
|
86
88
|
}
|
|
87
89
|
},
|
|
88
90
|
"dependencies": {
|
|
89
|
-
"@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
|
|
90
|
-
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
91
91
|
"zod": "^4.4.3"
|
|
92
92
|
},
|
|
93
|
+
"peerDependencies": {
|
|
94
|
+
"@deepseek-ai/dsh-home-paths": ">=0.1.1-rc.2 <0.2.0",
|
|
95
|
+
"@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.2.0"
|
|
96
|
+
},
|
|
93
97
|
"devDependencies": {
|
|
94
|
-
"
|
|
95
|
-
"
|
|
98
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
|
|
99
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
100
|
+
"react": "^19.2.8",
|
|
101
|
+
"react-dom": "^19.2.8"
|
|
96
102
|
},
|
|
97
103
|
"keywords": [
|
|
98
104
|
"deepseek",
|