dsh-tacit 0.2.2 → 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 +21 -0
- package/client/client.js +85 -19
- package/docs/README.md +3 -3
- package/docs/README.zh.md +4 -3
- package/lib/analyze.js +175 -19
- package/lib/index.js +13 -0
- package/lib/routes.js +1 -1
- package/lib/schema.js +17 -1
- package/lib/service.js +200 -50
- package/lib/store.js +1 -0
- package/package.json +11 -5
package/lib/service.js
CHANGED
|
@@ -44,6 +44,9 @@ import {
|
|
|
44
44
|
normalizeReport,
|
|
45
45
|
parseJsonObject,
|
|
46
46
|
ANALYSIS_SYSTEM_PROMPT,
|
|
47
|
+
GOOD_SYSTEM_PROMPT,
|
|
48
|
+
GOOD_TOOL,
|
|
49
|
+
normalizeGoodReport,
|
|
47
50
|
ANALYSIS_REPAIR_SYSTEM_PROMPT,
|
|
48
51
|
IMPROVE_SYSTEM_PROMPT,
|
|
49
52
|
IMPROVE_REPAIR_SYSTEM_PROMPT,
|
|
@@ -71,7 +74,10 @@ import {
|
|
|
71
74
|
DIRECTIVE_TIMEOUT_MS,
|
|
72
75
|
MAX_DIRECTIVES,
|
|
73
76
|
buildDirectiveUserText,
|
|
77
|
+
buildSteeringSection,
|
|
74
78
|
renderSteeringSection,
|
|
79
|
+
workspaceLabel,
|
|
80
|
+
MAX_WORKSPACE_DIRECTIVES,
|
|
75
81
|
ENRICH_SYSTEM_PROMPT,
|
|
76
82
|
ENRICH_TOOL,
|
|
77
83
|
ENRICH_MAX_TOKENS,
|
|
@@ -111,6 +117,8 @@ export function mergeConfig(base, patch) {
|
|
|
111
117
|
merged.enrichPrompts = merged.enrichPrompts === true
|
|
112
118
|
merged.directiveTrialTurns = Math.max(1, Math.min(500, Math.round(Number(merged.directiveTrialTurns) || 10)))
|
|
113
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
|
|
114
122
|
return merged
|
|
115
123
|
}
|
|
116
124
|
|
|
@@ -134,15 +142,44 @@ function turnsOf(service, sessionId) {
|
|
|
134
142
|
}
|
|
135
143
|
}
|
|
136
144
|
|
|
137
|
-
/**
|
|
138
|
-
function
|
|
139
|
-
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) {
|
|
140
147
|
const cwd = session !== null && typeof session === 'object' && session.header !== null && typeof session.header === 'object'
|
|
141
148
|
? session.header.cwd
|
|
142
149
|
: undefined
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
146
183
|
}
|
|
147
184
|
|
|
148
185
|
/** Short, secret-free context digest of a session's last two finished turns. */
|
|
@@ -220,6 +257,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
220
257
|
const rewriteRecords = new Map()
|
|
221
258
|
/** sessionId → FIFO [{rewriteId, baseline}] (one per applied rewrite). */
|
|
222
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
|
|
223
263
|
let distillInFlight = false
|
|
224
264
|
let rewriteSeq = 0
|
|
225
265
|
|
|
@@ -235,7 +275,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
235
275
|
return 'd' + Date.now().toString(36) + '-' + directiveSeq.toString(36)
|
|
236
276
|
}
|
|
237
277
|
let directivesInFlight = false
|
|
238
|
-
/** 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). */
|
|
239
279
|
const steeringFrozen = new WeakMap()
|
|
240
280
|
|
|
241
281
|
const nextRewriteId = () => {
|
|
@@ -272,7 +312,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
272
312
|
profile.patterns = profile.patterns.slice(0, config.maxPatterns)
|
|
273
313
|
profile.styleRules = profile.styleRules.slice(-MAX_STYLE_RULES)
|
|
274
314
|
profile.feedbackLog = profile.feedbackLog.slice(-MAX_FEEDBACK_LOG)
|
|
275
|
-
profile.directives = profile.directives
|
|
315
|
+
profile.directives = capDirectives(profile.directives)
|
|
276
316
|
profile.updatedAt = Date.now()
|
|
277
317
|
const validated = profileSchema.parse(profile)
|
|
278
318
|
store.saveProfile(validated)
|
|
@@ -354,10 +394,12 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
354
394
|
|
|
355
395
|
/**
|
|
356
396
|
* Directive trials ride the same free feed: every NEW finished turn counts
|
|
357
|
-
* toward each candidate
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
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.
|
|
361
403
|
*/
|
|
362
404
|
const recordTrialTurns = (sessionId, turns) => {
|
|
363
405
|
const fresh = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object'
|
|
@@ -365,8 +407,10 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
365
407
|
&& !seenFinished.has(sessionId + ':' + turn.turn))
|
|
366
408
|
if (fresh.length === 0) return
|
|
367
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
|
|
368
412
|
const profile = safeProfile()
|
|
369
|
-
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))
|
|
370
414
|
if (candidates.length === 0) return
|
|
371
415
|
const config = effectiveConfig()
|
|
372
416
|
const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
|
|
@@ -432,6 +476,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
432
476
|
const svc = serviceOf(ctx)
|
|
433
477
|
const { session, turns } = turnsOf(svc, sessionId)
|
|
434
478
|
if (session === undefined) return { ok: false, report: null, profile, code: 'no-session', detail: '' }
|
|
479
|
+
const cwd = cwdOf(session)
|
|
435
480
|
// The change feed already carries the digest; a manual click re-reads the snapshot.
|
|
436
481
|
const record = digest !== null && typeof digest === 'object' ? digest : turns.find((candidate) => candidate?.turn === turn)
|
|
437
482
|
if (record === undefined) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
|
|
@@ -448,6 +493,40 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
448
493
|
const provider = typeof record.provider === 'string' && record.provider.length > 0
|
|
449
494
|
? record.provider
|
|
450
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
|
+
}
|
|
451
530
|
let text = await callCoachModel(ctx, {
|
|
452
531
|
provider,
|
|
453
532
|
model: config.model,
|
|
@@ -494,6 +573,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
494
573
|
: {}),
|
|
495
574
|
trigger,
|
|
496
575
|
...(followUp.length > 0 ? { followUp: clipSafe(followUp, 300) } : {}),
|
|
576
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
497
577
|
}
|
|
498
578
|
const countNew = store.report(sessionId, turn) === null
|
|
499
579
|
store.saveReport(sessionId, turn, report)
|
|
@@ -514,23 +594,31 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
514
594
|
return exclusive
|
|
515
595
|
}
|
|
516
596
|
|
|
517
|
-
const steeringStatus = () => {
|
|
597
|
+
const steeringStatus = (cwd) => {
|
|
518
598
|
const config = effectiveConfig()
|
|
519
|
-
return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile()) : '' }
|
|
599
|
+
return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile(), { cwd }) : '' }
|
|
520
600
|
}
|
|
521
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
|
+
|
|
522
605
|
/** The system-prompt section provider (sync; frozen per session). */
|
|
523
606
|
const steeringText = (assemble) => {
|
|
524
607
|
const session = assemble !== null && typeof assemble === 'object' && assemble.agent !== null && typeof assemble.agent === 'object'
|
|
525
608
|
? assemble.agent.session
|
|
526
609
|
: undefined
|
|
527
|
-
if (session === null || session === undefined || typeof session !== 'object') return
|
|
610
|
+
if (session === null || session === undefined || typeof session !== 'object') return steeringNow().text
|
|
528
611
|
let frozen = steeringFrozen.get(session)
|
|
529
612
|
if (frozen === undefined) {
|
|
530
|
-
frozen =
|
|
613
|
+
frozen = steeringNow(cwdOf(session))
|
|
531
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
|
+
}
|
|
532
620
|
}
|
|
533
|
-
return frozen
|
|
621
|
+
return frozen.text
|
|
534
622
|
}
|
|
535
623
|
|
|
536
624
|
/**
|
|
@@ -538,35 +626,58 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
538
626
|
* entries are untouched, and a re-emitted directive keeps the enabled flag
|
|
539
627
|
* the user gave its identical text. Capped at MAX_DIRECTIVES overall.
|
|
540
628
|
*/
|
|
541
|
-
const
|
|
542
|
-
|
|
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) => {
|
|
543
647
|
const users = profile.directives.filter((entry) => entry.source === 'user')
|
|
544
|
-
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)))
|
|
545
654
|
const distilled = []
|
|
546
655
|
const seen = new Set()
|
|
547
|
-
|
|
548
|
-
for (const
|
|
549
|
-
const
|
|
656
|
+
const baselines = new Map()
|
|
657
|
+
for (const item of items) {
|
|
658
|
+
const scope = scopeOf(item)
|
|
659
|
+
const key = directiveKey(scope, item.text)
|
|
550
660
|
if (seen.has(key) || userKeys.has(key)) continue
|
|
551
661
|
seen.add(key)
|
|
552
662
|
const kept = previous.get(key)
|
|
553
663
|
if (kept !== undefined) {
|
|
554
|
-
distilled.push({ ...kept, text })
|
|
664
|
+
distilled.push({ ...kept, text: item.text })
|
|
555
665
|
continue
|
|
556
666
|
}
|
|
557
667
|
// A new distilled directive goes on trial against the current messy-turn rate.
|
|
558
|
-
if (
|
|
668
|
+
if (!baselines.has(scope)) baselines.set(scope, baselineRateFor(scope === '' ? undefined : scope))
|
|
559
669
|
distilled.push({
|
|
560
670
|
id: nextDirectiveId(),
|
|
561
|
-
text,
|
|
671
|
+
text: item.text,
|
|
562
672
|
enabled: true,
|
|
563
673
|
source: 'distilled',
|
|
564
674
|
createdAt: Date.now(),
|
|
565
675
|
status: 'candidate',
|
|
566
|
-
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 }),
|
|
567
678
|
})
|
|
568
679
|
}
|
|
569
|
-
profile.directives = [...users, ...distilled
|
|
680
|
+
profile.directives = capDirectives([...users, ...distilled, ...untouched])
|
|
570
681
|
return profile
|
|
571
682
|
}
|
|
572
683
|
|
|
@@ -579,6 +690,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
579
690
|
directivesInFlight = true
|
|
580
691
|
try {
|
|
581
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
|
+
}
|
|
582
700
|
const text = await callCoachModel(ctx, {
|
|
583
701
|
provider,
|
|
584
702
|
model: config.model,
|
|
@@ -589,16 +707,20 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
589
707
|
tool: DIRECTIVE_TOOL,
|
|
590
708
|
sessionId,
|
|
591
709
|
})
|
|
592
|
-
const { kept
|
|
710
|
+
const { kept, rejected } = classifyDirectives(text)
|
|
593
711
|
for (const dropped of rejected) console.warn('[tacit] dropped directive (it asks the user instead of compensating):', dropped)
|
|
594
|
-
if (
|
|
712
|
+
if (kept.length === 0) {
|
|
595
713
|
console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
|
|
596
714
|
return
|
|
597
715
|
}
|
|
598
|
-
|
|
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)
|
|
599
720
|
profile.analysesSinceDirectives = 0
|
|
600
721
|
capAndSaveProfile(profile)
|
|
601
|
-
|
|
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)' : ''))
|
|
602
724
|
} catch (error) {
|
|
603
725
|
// Soft: the counter stays and the next analysis retries.
|
|
604
726
|
console.warn('[tacit] directive distillation failed (will retry):', error instanceof Error ? error.message : String(error))
|
|
@@ -648,13 +770,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
648
770
|
}
|
|
649
771
|
}
|
|
650
772
|
|
|
651
|
-
/** Every live session's finished turns, for the measured trend. */
|
|
652
|
-
const allFinishedTurns = () => {
|
|
773
|
+
/** Every live session's finished turns (optionally only sessions in one workspace), for the measured trend. */
|
|
774
|
+
const allFinishedTurns = ({ cwd } = {}) => {
|
|
653
775
|
const svc = serviceOf(ctx)
|
|
654
776
|
const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
|
|
655
777
|
const out = []
|
|
656
778
|
for (const session of Array.isArray(sessions) ? sessions : []) {
|
|
657
779
|
if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
|
|
780
|
+
if (cwd !== undefined && cwdOf(session) !== cwd) continue
|
|
658
781
|
const { turns } = turnsOf(svc, session.id)
|
|
659
782
|
for (const turn of turns) if (turn?.finished === true) out.push(turn)
|
|
660
783
|
}
|
|
@@ -667,7 +790,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
667
790
|
/**
|
|
668
791
|
* "Learn from my last N turns now": explicit user action, so it ignores the
|
|
669
792
|
* daily auto budget. Skips continuations, tiny prompts and turns that already
|
|
670
|
-
* 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.
|
|
671
795
|
*/
|
|
672
796
|
const runBootstrap = async ({ sessionId, limit }) => {
|
|
673
797
|
if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '' }
|
|
@@ -702,19 +826,29 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
702
826
|
bootstrapState.total = eligible.length
|
|
703
827
|
bootstrapState.startedAt = Date.now()
|
|
704
828
|
let analyzed = 0
|
|
705
|
-
let lastProvider = COACH_PROVIDER
|
|
706
829
|
try {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
+
}
|
|
714
845
|
}
|
|
846
|
+
const concurrency = Math.min(effectiveConfig().bootstrapConcurrency, Math.max(1, eligible.length))
|
|
847
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
|
715
848
|
if (analyzed > 0) {
|
|
716
849
|
await service.flushAuto() // let any scheduled distillation settle before forcing one
|
|
717
|
-
|
|
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 })
|
|
718
852
|
}
|
|
719
853
|
} finally {
|
|
720
854
|
bootstrapState.running = false
|
|
@@ -755,12 +889,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
755
889
|
}
|
|
756
890
|
|
|
757
891
|
/**
|
|
758
|
-
* Zero-click learning.
|
|
892
|
+
* Zero-click learning. Three triggers, all free (no model call to decide):
|
|
759
893
|
* - the newest FINISHED turn is messy (retries / tool errors / compactions /
|
|
760
894
|
* rejection / long step run);
|
|
761
895
|
* - the newest (possibly unfinished) turn's prompt reads as a correction of
|
|
762
896
|
* the previous answer → the PREVIOUS turn is analyzed with that
|
|
763
|
-
* 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.
|
|
764
900
|
* Turns finished before the plugin started are ignored (cold restore).
|
|
765
901
|
*/
|
|
766
902
|
const maybeAutoAnalyze = (sessionId, turns) => {
|
|
@@ -776,6 +912,14 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
776
912
|
// the conversation is its context. Heavy work after it is not a prompt fault.
|
|
777
913
|
if (fresh(newest) && isMessyTurn(newest, { minSteps: config.autoMinSteps }) && !looksLikeContinuation(newest.prompt)) {
|
|
778
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 })
|
|
779
923
|
}
|
|
780
924
|
return
|
|
781
925
|
}
|
|
@@ -792,13 +936,18 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
792
936
|
await Promise.all([...autoRunning])
|
|
793
937
|
},
|
|
794
938
|
|
|
795
|
-
|
|
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
|
|
796
944
|
return {
|
|
797
945
|
ok: true,
|
|
798
946
|
config: effectiveConfig(),
|
|
799
947
|
profile: safeProfile(),
|
|
800
948
|
auto: autoStatus(),
|
|
801
|
-
steering: steeringStatus(),
|
|
949
|
+
steering: steeringStatus(cwdOf(session)),
|
|
950
|
+
workspaces: listWorkspaces(svc),
|
|
802
951
|
bootstrap: { ...bootstrapState },
|
|
803
952
|
message: '',
|
|
804
953
|
}
|
|
@@ -999,7 +1148,8 @@ export function createCoachService(ctx, store, effectiveConfig) {
|
|
|
999
1148
|
} else if (input.action === 'add') {
|
|
1000
1149
|
const text = clipSafe(input.text.trim(), 220)
|
|
1001
1150
|
if (text.length === 0) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'text' }
|
|
1002
|
-
|
|
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 }) })
|
|
1003
1153
|
} else {
|
|
1004
1154
|
profile.directives = profile.directives.filter((entry) => entry.id !== input.id)
|
|
1005
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,11 +88,15 @@
|
|
|
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": {
|
|
98
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2",
|
|
99
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
94
100
|
"react": "^19.2.8",
|
|
95
101
|
"react-dom": "^19.2.8"
|
|
96
102
|
},
|