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/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
- /** A human label for a session: the workspace directory's basename, else ''. */
138
- function sessionLabelOf(service, sessionId) {
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
- if (typeof cwd !== 'string' || cwd.length === 0) return ''
144
- const parts = cwd.split(/[\\/]+/).filter((part) => part.length > 0)
145
- return parts.length > 0 ? parts[parts.length - 1] : ''
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.slice(0, MAX_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; after `directiveTrialTurns` the candidate is
358
- * activated, or retired when the messy rate rose past the baseline by more
359
- * than `directiveWorseBy`. Steering text is frozen per session, so a verdict
360
- * reaches new sessions only by design.
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 steeringStatus().text
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 = steeringStatus().text
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 mergeDirectives = (profile, texts) => {
542
- const previous = new Map(profile.directives.filter((entry) => entry.source !== 'user').map((entry) => [entry.text.trim().toLowerCase(), entry]))
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.trim().toLowerCase()))
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
- let baselineRate = null
548
- for (const text of texts) {
549
- const key = text.trim().toLowerCase()
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 (baselineRate === null) baselineRate = computeTrend(allFinishedTurns(), { window: 20 }).recent.messyRate
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].slice(0, MAX_DIRECTIVES)
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: texts, rejected } = classifyDirectives(text)
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 (texts.length === 0) {
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
- profile = mergeDirectives(safeProfile(), texts)
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
- console.info('[tacit] distilled ' + texts.length + ' directive(s) into the steering section')
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; then forces one directive distillation.
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
- for (const item of eligible) {
708
- const previous = previousFinishedOf(item.turns, item.turn.turn)
709
- const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous })
710
- if (result !== null && typeof result === 'object' && result.ok === true) analyzed += 1
711
- else console.warn('[tacit] bootstrap: ' + item.sessionId + ':' + item.turn.turn + ' skipped: ' + (result?.code ?? 'unknown'))
712
- lastProvider = providerForSession(item.sessionId)
713
- bootstrapState.done += 1
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
- await maybeDistillDirectives(eligible[0].sessionId, lastProvider, { force: true })
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. Two triggers, both free (no model call to decide):
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
- async getState() {
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
- profile.directives.push({ id: nextDirectiveId(), text, enabled: true, source: 'user', createdAt: Date.now() })
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.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
- "check": "pnpm test && pnpm check:docs && pnpm check:package",
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
  },