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/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
- * Only user-triggered model calls exist here (analyzeTurn / improveDraft):
12
- * there is no background work, no polling, no telemetry.
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
- /** A human label for a session: the workspace directory's basename, else ''. */
137
- function sessionLabelOf(service, sessionId) {
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
- if (typeof cwd !== 'string' || cwd.length === 0) return ''
143
- const parts = cwd.split(/[\\/]+/).filter((part) => part.length > 0)
144
- 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
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.slice(0, MAX_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; after `directiveTrialTurns` the candidate is
356
- * activated, or retired when the messy rate rose past the baseline by more
357
- * than `directiveWorseBy`. Steering text is frozen per session, so a verdict
358
- * 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.
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 steeringStatus().text
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 = steeringStatus().text
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 mergeDirectives = (profile, texts) => {
540
- 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) => {
541
647
  const users = profile.directives.filter((entry) => entry.source === 'user')
542
- 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)))
543
654
  const distilled = []
544
655
  const seen = new Set()
545
- let baselineRate = null
546
- for (const text of texts) {
547
- 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)
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 (baselineRate === null) baselineRate = computeTrend(allFinishedTurns(), { window: 20 }).recent.messyRate
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].slice(0, MAX_DIRECTIVES)
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: texts, rejected } = classifyDirectives(text)
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 (texts.length === 0) {
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
- 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)
597
720
  profile.analysesSinceDirectives = 0
598
721
  capAndSaveProfile(profile)
599
- 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)' : ''))
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; 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.
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
- for (const item of eligible) {
706
- const previous = previousFinishedOf(item.turns, item.turn.turn)
707
- const result = await runAnalysis(item.sessionId, item.turn.turn, { trigger: 'bootstrap', digest: item.turn, previousDigest: previous })
708
- if (result !== null && typeof result === 'object' && result.ok === true) analyzed += 1
709
- else console.warn('[tacit] bootstrap: ' + item.sessionId + ':' + item.turn.turn + ' skipped: ' + (result?.code ?? 'unknown'))
710
- lastProvider = providerForSession(item.sessionId)
711
- 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
+ }
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
- 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 })
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. Two triggers, both free (no model call to decide):
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
- 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
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
- 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 }) })
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.1",
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,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
- "react": "^18.3.1",
95
- "react-dom": "^18.3.1"
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",