dsh-tacit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/service.js ADDED
@@ -0,0 +1,1044 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ /**
4
+ * dsh-tacit — the coach service (host side).
5
+ *
6
+ * Reached by the browser through the plugin's own /api/tacit/* routes.
7
+ * Every method is a plain async function returning a structured payload with
8
+ * an `ok` flag and a stable error `code` the client localizes. Expected
9
+ * failures never throw; unexpected ones are mapped by the route layer.
10
+ *
11
+ * Only user-triggered model calls exist here (analyzeTurn / improveDraft):
12
+ * there is no background work, no polling, no telemetry.
13
+ */
14
+
15
+ import {
16
+ Config,
17
+ COACH_MODELS,
18
+ COACH_PROVIDER,
19
+ analyzeArgSchema,
20
+ appliedArgSchema,
21
+ configArgSchema,
22
+ feedbackArgSchema,
23
+ improveArgSchema,
24
+ profileSchema,
25
+ reportSchema,
26
+ sessionArgSchema,
27
+ directivesArgSchema,
28
+ statsArgSchema,
29
+ bootstrapArgSchema,
30
+ } from './schema.js'
31
+ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
32
+ import { textOfBlocks } from './fold.js'
33
+ import {
34
+ aggregateProfile,
35
+ buildAnalysisUserText,
36
+ buildDistillUserText,
37
+ buildImproveUserText,
38
+ callCoachModel,
39
+ improvePatterns,
40
+ lastDownReasons,
41
+ normalizeDistillRules,
42
+ normalizeImprove,
43
+ normalizeReport,
44
+ parseJsonObject,
45
+ ANALYSIS_SYSTEM_PROMPT,
46
+ ANALYSIS_REPAIR_SYSTEM_PROMPT,
47
+ IMPROVE_SYSTEM_PROMPT,
48
+ IMPROVE_REPAIR_SYSTEM_PROMPT,
49
+ DISTILL_SYSTEM_PROMPT,
50
+ ANALYZE_MAX_TOKENS,
51
+ ANALYZE_TIMEOUT_MS,
52
+ IMPROVE_MAX_TOKENS,
53
+ IMPROVE_TIMEOUT_MS,
54
+ DISTILL_MAX_TOKENS,
55
+ DISTILL_TIMEOUT_MS,
56
+ MAX_STYLE_RULES,
57
+ MAX_FEEDBACK_LOG,
58
+ MAX_FEEDBACK_REASON_CHARS,
59
+ ANALYSIS_TOOL,
60
+ IMPROVE_TOOL,
61
+ DISTILL_TOOL,
62
+ clipSafe,
63
+ isMessyTurn,
64
+ looksLikeCorrection,
65
+ looksLikeContinuation,
66
+ classifyDirectives,
67
+ DIRECTIVE_SYSTEM_PROMPT,
68
+ DIRECTIVE_TOOL,
69
+ DIRECTIVE_MAX_TOKENS,
70
+ DIRECTIVE_TIMEOUT_MS,
71
+ MAX_DIRECTIVES,
72
+ buildDirectiveUserText,
73
+ renderSteeringSection,
74
+ ENRICH_SYSTEM_PROMPT,
75
+ ENRICH_TOOL,
76
+ ENRICH_MAX_TOKENS,
77
+ ENRICH_TIMEOUT_MS,
78
+ ENRICH_MIN_DRAFT_CHARS,
79
+ ENRICH_MAX_DRAFT_CHARS,
80
+ ENRICH_PREFIX,
81
+ buildEnrichUserText,
82
+ normalizeEnrichNote,
83
+ computeTrend,
84
+ } from './analyze.js'
85
+
86
+ /** In-memory rewrite ledger bounds (never persisted). */
87
+ const MAX_REWRITE_RECORDS = 50
88
+ /** Pending outcome verifications kept per session (FIFO, oldest dropped). */
89
+ const MAX_PENDING_VERIFICATIONS = 20
90
+
91
+ /** Merge the loader/YAML base config with the UI-written patch (patch wins). */
92
+ export function mergeConfig(base, patch) {
93
+ const merged = Config.parse({
94
+ ...(base !== null && typeof base === 'object' ? base : {}),
95
+ ...(patch !== null && typeof patch === 'object' ? patch : {}),
96
+ })
97
+ // Allowlist the model; a bad persisted value falls back to the default.
98
+ if (!COACH_MODELS.includes(merged.model)) merged.model = 'deepseek-v4-flash'
99
+ merged.maxKeptTurns = Math.max(1, Math.min(1000, Math.round(Number(merged.maxKeptTurns) || 60)))
100
+ merged.maxPromptChars = Math.max(200, Math.min(100000, Math.round(Number(merged.maxPromptChars) || 4000)))
101
+ merged.maxToolCallChars = Math.max(100, Math.min(20000, Math.round(Number(merged.maxToolCallChars) || 500)))
102
+ merged.maxAssistantChars = Math.max(200, Math.min(100000, Math.round(Number(merged.maxAssistantChars) || 4000)))
103
+ merged.maxToolCallsPerTurn = Math.max(1, Math.min(500, Math.round(Number(merged.maxToolCallsPerTurn) || 50)))
104
+ merged.maxPatterns = Math.max(1, Math.min(50, Math.round(Number(merged.maxPatterns) || 12)))
105
+ merged.autoAnalyze = merged.autoAnalyze !== false
106
+ merged.autoDailyBudget = Math.max(0, Math.min(1000, Math.round(Number(merged.autoDailyBudget ?? 30))))
107
+ merged.autoMinSteps = Math.max(1, Math.min(500, Math.round(Number(merged.autoMinSteps) || 15)))
108
+ merged.steerAgent = merged.steerAgent !== false
109
+ merged.directiveEvery = Math.max(1, Math.min(100, Math.round(Number(merged.directiveEvery) || 3)))
110
+ merged.enrichPrompts = merged.enrichPrompts === true
111
+ merged.directiveTrialTurns = Math.max(1, Math.min(500, Math.round(Number(merged.directiveTrialTurns) || 10)))
112
+ merged.directiveWorseBy = Math.max(0, Math.min(1, Number.isFinite(Number(merged.directiveWorseBy)) ? Number(merged.directiveWorseBy) : 0.15))
113
+ return merged
114
+ }
115
+
116
+ function serviceOf(ctx) {
117
+ const get = (name) => (ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get(name) : undefined)
118
+ return { get, llm: get('llm'), sessions: get('sessions'), sessionProjections: get('sessionProjections') }
119
+ }
120
+
121
+ function turnsOf(service, sessionId) {
122
+ const session = typeof service.sessions?.get === 'function' ? service.sessions.get(sessionId) : undefined
123
+ if (session === undefined) return { session, turns: [] }
124
+ if (service.sessionProjections === undefined || typeof service.sessionProjections.snapshot !== 'function') {
125
+ return { session, turns: [] }
126
+ }
127
+ try {
128
+ const snapshot = service.sessionProjections.snapshot(session)
129
+ const value = snapshot?.values?.tacitTimeline
130
+ return { session, turns: Array.isArray(value?.turns) ? value.turns : [] }
131
+ } catch {
132
+ return { session, turns: [] }
133
+ }
134
+ }
135
+
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
139
+ const cwd = session !== null && typeof session === 'object' && session.header !== null && typeof session.header === 'object'
140
+ ? session.header.cwd
141
+ : 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] : ''
145
+ }
146
+
147
+ /** Short, secret-free context digest of a session's last two finished turns. */
148
+ function recentContextOf(turns) {
149
+ const finished = (Array.isArray(turns) ? turns : []).filter((turn) => turn?.finished === true).slice(-2)
150
+ if (finished.length === 0) return ''
151
+ return finished.map((turn) => {
152
+ const prompt = typeof turn.prompt === 'string' ? turn.prompt.slice(0, 600) : ''
153
+ const finalText = typeof turn.finalText === 'string' ? turn.finalText.slice(0, 600) : ''
154
+ return 'prompt: ' + (prompt || '(none)') + '\nresponse: ' + (finalText || '(none)')
155
+ }).join('\n---\n')
156
+ }
157
+
158
+ // ── Free outcome verification signals (v2 loop) ────────────────────────────
159
+ // The spec's hard rule: rework quality is judged ONLY by error/retry/
160
+ // compaction/rejection signals and the emptiness of the final answer —
161
+ // NEVER by steps or tool-call counts (user correction).
162
+
163
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.round(value) : 0)
164
+
165
+ /** Rework score of one finished turn: lower is better. */
166
+ function outcomeScore(turn) {
167
+ if (turn === null || typeof turn !== 'object') return Number.POSITIVE_INFINITY
168
+ let score = num(turn.toolErrors) + num(turn.retries) + num(turn.compactions)
169
+ const reason = typeof turn.endReason === 'string' ? turn.endReason : ''
170
+ if (reason === 'rejected' || reason === 'cancelled') score += 1
171
+ if (typeof turn.finalText !== 'string' || turn.finalText.trim() === '') score += 1
172
+ return score
173
+ }
174
+
175
+ /** Compact baseline digest captured at apply time. */
176
+ function outcomeBaselineOf(turn) {
177
+ if (turn === null || typeof turn !== 'object') return null
178
+ return {
179
+ turn: typeof turn.turn === 'number' ? turn.turn : 0,
180
+ toolErrors: num(turn.toolErrors),
181
+ retries: num(turn.retries),
182
+ compactions: num(turn.compactions),
183
+ endReason: typeof turn.endReason === 'string' ? turn.endReason : '',
184
+ finalText: typeof turn.finalText === 'string' ? turn.finalText : '',
185
+ }
186
+ }
187
+
188
+ /** The newest finished turn of a projection view, or null. */
189
+ function lastFinishedTurnOf(turns) {
190
+ const finished = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object' && turn.finished === true)
191
+ return finished.length > 0 ? finished[finished.length - 1] : null
192
+ }
193
+
194
+ function coachErrorCode(error) {
195
+ const message = error instanceof Error ? error.message : String(error)
196
+ if (error !== null && typeof error === 'object' && typeof error.code === 'string') return error.code
197
+ if (/abort|aborted|timeout/i.test(message)) return 'timeout'
198
+ if (/auth|401|403|api key|key not/i.test(message)) return 'no-api-key'
199
+ if (/rate|429/i.test(message)) return 'rate-limited'
200
+ return 'call-failed'
201
+ }
202
+
203
+ /** Local calendar day key for the daily auto budget. */
204
+ function dayKey(now = Date.now()) {
205
+ const date = new Date(now)
206
+ const pad = (value) => String(value).padStart(2, '0')
207
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
208
+ }
209
+
210
+ export function createCoachService(ctx, store, effectiveConfig) {
211
+ const inFlight = new Map()
212
+ /** Turns already handed to automatic analysis (sessionId:turn). */
213
+ const autoSeen = new Set()
214
+ /** In-flight automatic analyses (for flushAuto / tests). */
215
+ const autoRunning = new Set()
216
+ /** Only turns finishing after this instant are eligible for auto-analysis (cold-restore guard). */
217
+ const pluginStartedAt = Date.now()
218
+ /** rewriteId → {rewriteId, sessionId, patternsUsed, draft, improved} (last 50). */
219
+ const rewriteRecords = new Map()
220
+ /** sessionId → FIFO [{rewriteId, baseline}] (one per applied rewrite). */
221
+ const pendingVerifications = new Map()
222
+ let distillInFlight = false
223
+ let rewriteSeq = 0
224
+
225
+ const safeProfile = () => {
226
+ const parsed = profileSchema.safeParse(store.profile())
227
+ return parsed.success
228
+ ? parsed.data
229
+ : { analyzedCount: 0, patterns: [], updatedAt: 0, styleRules: [], feedbackLog: [], pendingDistill: 0, directives: [], analysesSinceDirectives: 0 }
230
+ }
231
+ let directiveSeq = 0
232
+ const nextDirectiveId = () => {
233
+ directiveSeq += 1
234
+ return 'd' + Date.now().toString(36) + '-' + directiveSeq.toString(36)
235
+ }
236
+ let directivesInFlight = false
237
+ /** Steering text frozen per live session (keeps the model's prefix cache stable within a session). */
238
+ const steeringFrozen = new WeakMap()
239
+
240
+ const nextRewriteId = () => {
241
+ rewriteSeq += 1
242
+ return 'rw' + Date.now().toString(36) + '-' + rewriteSeq.toString(36)
243
+ }
244
+
245
+ const rememberRewrite = (record) => {
246
+ rewriteRecords.set(record.rewriteId, record)
247
+ if (rewriteRecords.size > MAX_REWRITE_RECORDS) {
248
+ rewriteRecords.delete(rewriteRecords.keys().next().value)
249
+ }
250
+ }
251
+
252
+ const providerForSession = (sessionId) => {
253
+ const { turns } = turnsOf(serviceOf(ctx), sessionId)
254
+ const known = turns.filter((turn) => typeof turn?.provider === 'string' && turn.provider.length > 0)
255
+ return known.length > 0 ? known[known.length - 1].provider : COACH_PROVIDER
256
+ }
257
+
258
+ /** Increment one counter on each pattern kind (create the pattern when unknown). */
259
+ const bumpPatterns = (profile, kinds, key) => {
260
+ for (const kind of kinds) {
261
+ const found = profile.patterns.find((pattern) => pattern !== null && typeof pattern === 'object' && pattern.kind === kind)
262
+ if (found !== undefined) found[key] += 1
263
+ else profile.patterns.push({ kind, count: 0, lastExample: '', applied: 0, accepted: 0, rejected: 0, verified: 0, unverified: 0, [key]: 1 })
264
+ }
265
+ return profile
266
+ }
267
+
268
+ /** Bound every v2 field, validate, persist; returns the stored profile. */
269
+ const capAndSaveProfile = (profile) => {
270
+ const config = effectiveConfig()
271
+ profile.patterns = profile.patterns.slice(0, config.maxPatterns)
272
+ profile.styleRules = profile.styleRules.slice(-MAX_STYLE_RULES)
273
+ profile.feedbackLog = profile.feedbackLog.slice(-MAX_FEEDBACK_LOG)
274
+ profile.directives = profile.directives.slice(0, MAX_DIRECTIVES)
275
+ profile.updatedAt = Date.now()
276
+ const validated = profileSchema.parse(profile)
277
+ store.saveProfile(validated)
278
+ return validated
279
+ }
280
+
281
+ /**
282
+ * The ONE new paid call: distill 3+ unreviewed 👎 reasons into 2-3 durable
283
+ * style rules (≤300 tokens). Guarded: in-flight dedup, soft-silent failure
284
+ * (pendingDistill stays and retries on the next trigger), never throws.
285
+ * Returns the profile with fresh rules on success, the input profile on a
286
+ * soft no-op/failure.
287
+ */
288
+ const maybeDistill = async (profile, provider) => {
289
+ if (profile.pendingDistill < 3 || distillInFlight) return profile
290
+ distillInFlight = true
291
+ try {
292
+ const config = effectiveConfig()
293
+ const reasons = lastDownReasons(profile, 3)
294
+ const text = await callCoachModel(ctx, {
295
+ provider,
296
+ model: config.model,
297
+ system: DISTILL_SYSTEM_PROMPT,
298
+ userText: buildDistillUserText(reasons),
299
+ maxTokens: DISTILL_MAX_TOKENS,
300
+ timeoutMs: DISTILL_TIMEOUT_MS,
301
+ tool: DISTILL_TOOL,
302
+ })
303
+ const rules = normalizeDistillRules(text)
304
+ if (rules.length === 0) return profile
305
+ const fresh = safeProfile()
306
+ fresh.styleRules = [...fresh.styleRules, ...rules.map((rule) => ({ rule, createdAt: Date.now() }))].slice(-MAX_STYLE_RULES)
307
+ fresh.pendingDistill = 0
308
+ return capAndSaveProfile(fresh)
309
+ } catch {
310
+ return profile
311
+ } finally {
312
+ distillInFlight = false
313
+ }
314
+ }
315
+
316
+ const runExclusive = (key, task) => {
317
+ if (inFlight.has(key)) return null
318
+ const promise = task().finally(() => inFlight.delete(key))
319
+ inFlight.set(key, promise)
320
+ return promise
321
+ }
322
+
323
+ /**
324
+ * Free outcome verification: runs on the existing projection change feed
325
+ * (no polling, no background loops). When a NEW finished turn lands for a
326
+ * session with pending verifications, the FIFO head — the applied rewrite
327
+ * whose baseline was the immediately preceding finished turn — is compared
328
+ * on rework signals only (never steps/tool counts).
329
+ */
330
+ const handleProjectionChange = (session, key, value) => {
331
+ if (key !== 'tacitTimeline') return
332
+ const sessionId = session !== null && typeof session === 'object' && typeof session.id === 'string' ? session.id : null
333
+ if (sessionId === null) return
334
+ const turns = Array.isArray(value?.turns) ? value.turns : []
335
+ try {
336
+ maybeAutoAnalyze(sessionId, turns)
337
+ } catch {
338
+ // Zero-click learning must never break the feed.
339
+ }
340
+ try {
341
+ recordTrialTurns(sessionId, turns)
342
+ } catch {
343
+ // Trials are bookkeeping; never break the feed.
344
+ }
345
+ handleVerification(sessionId, turns)
346
+ }
347
+
348
+ /** Finished turns already counted toward directive trials (sessionId:turn). */
349
+ const seenFinished = new Set()
350
+
351
+ const pct = (rate) => String(Math.round(rate * 100)) + '%'
352
+
353
+ /**
354
+ * 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.
359
+ */
360
+ const recordTrialTurns = (sessionId, turns) => {
361
+ const fresh = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object'
362
+ && turn.finished === true && typeof turn.turn === 'number' && typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt
363
+ && !seenFinished.has(sessionId + ':' + turn.turn))
364
+ if (fresh.length === 0) return
365
+ for (const turn of fresh) seenFinished.add(sessionId + ':' + turn.turn)
366
+ const profile = safeProfile()
367
+ const candidates = profile.directives.filter((entry) => entry.status === 'candidate' && entry.trial !== undefined)
368
+ if (candidates.length === 0) return
369
+ const config = effectiveConfig()
370
+ const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
371
+ for (const entry of candidates) {
372
+ entry.trial.turns += fresh.length
373
+ entry.trial.messy += messyCount
374
+ if (entry.trial.turns < config.directiveTrialTurns) continue
375
+ const rate = entry.trial.messy / entry.trial.turns
376
+ if (rate > entry.trial.baselineRate + config.directiveWorseBy) {
377
+ entry.status = 'retired'
378
+ entry.enabled = false
379
+ entry.retiredReason = 'messy turns ' + pct(entry.trial.baselineRate) + ' → ' + pct(rate) + ' while active'
380
+ console.info('[tacit] retired directive (' + entry.retiredReason + '): ' + entry.text)
381
+ } else {
382
+ entry.status = 'active'
383
+ console.info('[tacit] activated directive (messy turns ' + pct(entry.trial.baselineRate) + ' → ' + pct(rate) + '): ' + entry.text)
384
+ }
385
+ }
386
+ capAndSaveProfile(profile)
387
+ }
388
+
389
+ const handleVerification = (sessionId, turns) => {
390
+ const queue = pendingVerifications.get(sessionId)
391
+ if (queue === undefined || queue.length === 0) return
392
+ const finished = lastFinishedTurnOf(turns)
393
+ if (finished === null) return
394
+ const head = queue[0]
395
+ if (head.baseline !== null && finished.turn <= head.baseline.turn) return
396
+ queue.shift()
397
+ if (head.baseline === null) return // no prior finished turn to compare against
398
+ const record = rewriteRecords.get(head.rewriteId)
399
+ if (record === undefined || record.patternsUsed.length === 0) return
400
+ const better = outcomeScore(finished) < outcomeScore(head.baseline)
401
+ let profile = safeProfile()
402
+ profile = bumpPatterns(profile, record.patternsUsed, better ? 'verified' : 'unverified')
403
+ capAndSaveProfile(profile)
404
+ }
405
+
406
+ const svcAtStartup = serviceOf(ctx)
407
+ if (svcAtStartup.sessionProjections !== undefined && typeof svcAtStartup.sessionProjections.onChanged === 'function') {
408
+ const unsubscribe = svcAtStartup.sessionProjections.onChanged(handleProjectionChange)
409
+ // The registry's own onChanged effect rides the harness root fiber; tie the
410
+ // unsubscription to THIS plugin's fiber so an unload stops the listener.
411
+ if (typeof ctx.effect === 'function') ctx.effect(() => unsubscribe, 'tacit: outcome verification feed')
412
+ }
413
+
414
+ /**
415
+ * One analysis of a retained turn (manual click or automatic trigger):
416
+ * model call → report on disk → profile aggregation. Deduped per
417
+ * session:turn while running. Never throws.
418
+ */
419
+ /** The newest finished turn that ended before `turn` (context the agent already had). */
420
+ const previousFinishedOf = (turns, turn) => {
421
+ const earlier = (Array.isArray(turns) ? turns : [])
422
+ .filter((candidate) => candidate !== null && typeof candidate === 'object' && candidate.finished === true && typeof candidate.turn === 'number' && candidate.turn < turn)
423
+ return earlier.length > 0 ? earlier[earlier.length - 1] : null
424
+ }
425
+
426
+ const runAnalysis = (sessionId, turn, { trigger = 'manual', followUp = '', digest = null, previousDigest = null } = {}) => {
427
+ const profile = safeProfile()
428
+ const key = `${sessionId}:${turn}`
429
+ const exclusive = runExclusive(key, async () => {
430
+ const svc = serviceOf(ctx)
431
+ const { session, turns } = turnsOf(svc, sessionId)
432
+ if (session === undefined) return { ok: false, report: null, profile, code: 'no-session', detail: '' }
433
+ // The change feed already carries the digest; a manual click re-reads the snapshot.
434
+ const record = digest !== null && typeof digest === 'object' ? digest : turns.find((candidate) => candidate?.turn === turn)
435
+ if (record === undefined) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
436
+ // A bare "continue" carries no intent to learn from; the auto and bootstrap paths
437
+ // never get here, and a manual click gets a soft, explained refusal instead of a paid call.
438
+ if (trigger === 'manual' && looksLikeContinuation(record.prompt)) {
439
+ return { ok: false, report: null, profile, code: 'continuation', detail: '' }
440
+ }
441
+ const previous = previousDigest !== null && typeof previousDigest === 'object' ? previousDigest : previousFinishedOf(turns, turn)
442
+ const userText = buildAnalysisUserText(record, { followUp, previous })
443
+ if (userText === null) return { ok: false, report: null, profile, code: 'not-retained', detail: '' }
444
+ const config = effectiveConfig()
445
+ try {
446
+ const provider = typeof record.provider === 'string' && record.provider.length > 0
447
+ ? record.provider
448
+ : COACH_PROVIDER
449
+ let text = await callCoachModel(ctx, {
450
+ provider,
451
+ model: config.model,
452
+ system: ANALYSIS_SYSTEM_PROMPT,
453
+ userText,
454
+ maxTokens: ANALYZE_MAX_TOKENS,
455
+ timeoutMs: ANALYZE_TIMEOUT_MS,
456
+ tool: ANALYSIS_TOOL,
457
+ sessionId,
458
+ })
459
+ if (text.trim() === '') {
460
+ return { ok: false, report: null, profile, code: 'empty-response', detail: '' }
461
+ }
462
+ let parsed = parseJsonObject(text)
463
+ if (parsed === null) {
464
+ // One-shot repair: the model answered in prose; re-ask for strict JSON.
465
+ const repaired = await callCoachModel(ctx, {
466
+ provider,
467
+ model: config.model,
468
+ system: ANALYSIS_REPAIR_SYSTEM_PROMPT,
469
+ userText,
470
+ maxTokens: ANALYZE_MAX_TOKENS,
471
+ timeoutMs: ANALYZE_TIMEOUT_MS,
472
+ tool: ANALYSIS_TOOL,
473
+ sessionId,
474
+ })
475
+ if (repaired.trim() !== '') {
476
+ const reparsed = parseJsonObject(repaired)
477
+ if (reparsed !== null) {
478
+ text = repaired
479
+ parsed = reparsed
480
+ }
481
+ }
482
+ }
483
+ const report = {
484
+ ...reportSchema.parse(normalizeReport(parsed, {
485
+ turn,
486
+ time: Date.now(),
487
+ model: config.model,
488
+ rawText: text,
489
+ })),
490
+ ...(typeof record.prompt === 'string' && record.prompt.length > 0
491
+ ? { promptExcerpt: clipSafe(record.prompt, 200) }
492
+ : {}),
493
+ trigger,
494
+ ...(followUp.length > 0 ? { followUp: clipSafe(followUp, 300) } : {}),
495
+ }
496
+ const countNew = store.report(sessionId, turn) === null
497
+ store.saveReport(sessionId, turn, report)
498
+ const nextProfile = aggregateProfile(store.profile(), report, config.maxPatterns, { countNew })
499
+ if (countNew) nextProfile.analysesSinceDirectives = (nextProfile.analysesSinceDirectives ?? 0) + 1
500
+ store.saveProfile(nextProfile)
501
+ if (countNew && !bootstrapState.running) {
502
+ const task = maybeDistillDirectives(sessionId, provider).catch(() => null).finally(() => autoRunning.delete(task))
503
+ autoRunning.add(task)
504
+ }
505
+ return { ok: true, report, profile: nextProfile, code: '', detail: '' }
506
+ } catch (error) {
507
+ const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
508
+ return { ok: false, report: null, profile, code: coachErrorCode(error), detail }
509
+ }
510
+ })
511
+ if (exclusive === null) return Promise.resolve({ ok: false, report: null, profile, code: 'busy', detail: '' })
512
+ return exclusive
513
+ }
514
+
515
+ const steeringStatus = () => {
516
+ const config = effectiveConfig()
517
+ return { enabled: config.steerAgent, text: config.steerAgent ? renderSteeringSection(safeProfile()) : '' }
518
+ }
519
+
520
+ /** The system-prompt section provider (sync; frozen per session). */
521
+ const steeringText = (assemble) => {
522
+ const session = assemble !== null && typeof assemble === 'object' && assemble.agent !== null && typeof assemble.agent === 'object'
523
+ ? assemble.agent.session
524
+ : undefined
525
+ if (session === null || session === undefined || typeof session !== 'object') return steeringStatus().text
526
+ let frozen = steeringFrozen.get(session)
527
+ if (frozen === undefined) {
528
+ frozen = steeringStatus().text
529
+ steeringFrozen.set(session, frozen)
530
+ }
531
+ return frozen
532
+ }
533
+
534
+ /**
535
+ * Replace the distilled directives with the model's new complete set; user
536
+ * entries are untouched, and a re-emitted directive keeps the enabled flag
537
+ * the user gave its identical text. Capped at MAX_DIRECTIVES overall.
538
+ */
539
+ const mergeDirectives = (profile, texts) => {
540
+ const previous = new Map(profile.directives.filter((entry) => entry.source !== 'user').map((entry) => [entry.text.trim().toLowerCase(), entry]))
541
+ const users = profile.directives.filter((entry) => entry.source === 'user')
542
+ const userKeys = new Set(users.map((entry) => entry.text.trim().toLowerCase()))
543
+ const distilled = []
544
+ const seen = new Set()
545
+ let baselineRate = null
546
+ for (const text of texts) {
547
+ const key = text.trim().toLowerCase()
548
+ if (seen.has(key) || userKeys.has(key)) continue
549
+ seen.add(key)
550
+ const kept = previous.get(key)
551
+ if (kept !== undefined) {
552
+ distilled.push({ ...kept, text })
553
+ continue
554
+ }
555
+ // A new distilled directive goes on trial against the current messy-turn rate.
556
+ if (baselineRate === null) baselineRate = computeTrend(allFinishedTurns(), { window: 20 }).recent.messyRate
557
+ distilled.push({
558
+ id: nextDirectiveId(),
559
+ text,
560
+ enabled: true,
561
+ source: 'distilled',
562
+ createdAt: Date.now(),
563
+ status: 'candidate',
564
+ trial: { turns: 0, messy: 0, baselineRate, startedAt: Date.now() },
565
+ })
566
+ }
567
+ profile.directives = [...users, ...distilled].slice(0, MAX_DIRECTIVES)
568
+ return profile
569
+ }
570
+
571
+ /** ONE small call every `directiveEvery` new analyses (or forced). Soft-fails; never throws. */
572
+ const maybeDistillDirectives = async (sessionId, provider, { force = false } = {}) => {
573
+ if (directivesInFlight) return
574
+ const config = effectiveConfig()
575
+ let profile = safeProfile()
576
+ if (!force && profile.analysesSinceDirectives < config.directiveEvery) return
577
+ directivesInFlight = true
578
+ try {
579
+ const recent = store.listAllReports(20).map((entry) => store.report(entry.sessionId, entry.turn)).filter((report) => report !== null)
580
+ const text = await callCoachModel(ctx, {
581
+ provider,
582
+ model: config.model,
583
+ system: DIRECTIVE_SYSTEM_PROMPT,
584
+ userText: buildDirectiveUserText(profile, recent.reverse()),
585
+ maxTokens: DIRECTIVE_MAX_TOKENS,
586
+ timeoutMs: DIRECTIVE_TIMEOUT_MS,
587
+ tool: DIRECTIVE_TOOL,
588
+ sessionId,
589
+ })
590
+ const { kept: texts, rejected } = classifyDirectives(text)
591
+ for (const dropped of rejected) console.warn('[tacit] dropped directive (it asks the user instead of compensating):', dropped)
592
+ if (texts.length === 0) {
593
+ console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
594
+ return
595
+ }
596
+ profile = mergeDirectives(safeProfile(), texts)
597
+ profile.analysesSinceDirectives = 0
598
+ capAndSaveProfile(profile)
599
+ console.info('[tacit] distilled ' + texts.length + ' directive(s) into the steering section')
600
+ } catch (error) {
601
+ // Soft: the counter stays and the next analysis retries.
602
+ console.warn('[tacit] directive distillation failed (will retry):', error instanceof Error ? error.message : String(error))
603
+ } finally {
604
+ directivesInFlight = false
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Opt-in `agent/pre-step` listener. APPEND-ONLY: the user's own message is
610
+ * never rewritten; when the note is worth it, one plugin-sourced user
611
+ * message rides after it (so it is logged and visible). Any failure, an
612
+ * empty note, or a later step leaves the step exactly as it was.
613
+ */
614
+ const preStep = async (payload, next) => {
615
+ try {
616
+ const config = effectiveConfig()
617
+ if (!config.enrichPrompts) return next()
618
+ if (payload === null || typeof payload !== 'object' || payload.step !== 1) return next()
619
+ const messages = Array.isArray(payload.messages) ? payload.messages : []
620
+ const human = messages.find((message) => message !== null && typeof message === 'object' && message.source?.kind === 'user')
621
+ const draft = human !== undefined ? textOfBlocks(human.content).trim() : ''
622
+ if (draft.length < ENRICH_MIN_DRAFT_CHARS || draft.length > ENRICH_MAX_DRAFT_CHARS) return next()
623
+ const sessionId = typeof payload.agent?.session?.id === 'string' ? payload.agent.session.id : (typeof payload.agent?.id === 'string' ? payload.agent.id : '')
624
+ const { turns } = sessionId.length > 0 ? turnsOf(serviceOf(ctx), sessionId) : { turns: [] }
625
+ const text = await callCoachModel(ctx, {
626
+ provider: sessionId.length > 0 ? providerForSession(sessionId) : COACH_PROVIDER,
627
+ model: config.model,
628
+ system: ENRICH_SYSTEM_PROMPT,
629
+ userText: buildEnrichUserText({ draft, profile: safeProfile(), recentContext: recentContextOf(turns) }),
630
+ maxTokens: ENRICH_MAX_TOKENS,
631
+ timeoutMs: ENRICH_TIMEOUT_MS,
632
+ tool: ENRICH_TOOL,
633
+ sessionId,
634
+ })
635
+ const note = normalizeEnrichNote(text)
636
+ if (note.length === 0) return next()
637
+ const base = await next()
638
+ if (base === null || typeof base !== 'object' || base.kind !== 'enter' || !Array.isArray(base.messages)) return base
639
+ const added = createUserMessage({
640
+ content: [{ type: 'text', text: ENRICH_PREFIX + note }],
641
+ source: { kind: 'plugin', plugin: 'dsh-tacit' },
642
+ })
643
+ return { kind: 'enter', messages: [...base.messages, added] }
644
+ } catch {
645
+ return next()
646
+ }
647
+ }
648
+
649
+ /** Every live session's finished turns, for the measured trend. */
650
+ const allFinishedTurns = () => {
651
+ const svc = serviceOf(ctx)
652
+ const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
653
+ const out = []
654
+ for (const session of Array.isArray(sessions) ? sessions : []) {
655
+ if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
656
+ const { turns } = turnsOf(svc, session.id)
657
+ for (const turn of turns) if (turn?.finished === true) out.push(turn)
658
+ }
659
+ return out
660
+ }
661
+
662
+ /** One bootstrap at a time; progress is exposed through /state. */
663
+ const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0 }
664
+
665
+ /**
666
+ * "Learn from my last N turns now": explicit user action, so it ignores the
667
+ * daily auto budget. Skips continuations, tiny prompts and turns that already
668
+ * have a report; then forces one directive distillation.
669
+ */
670
+ const runBootstrap = async ({ sessionId, limit }) => {
671
+ if (bootstrapState.running) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'busy', detail: '' }
672
+ const svc = serviceOf(ctx)
673
+ const pool = []
674
+ if (typeof sessionId === 'string' && sessionId.length > 0) {
675
+ const { session, turns } = turnsOf(svc, sessionId)
676
+ if (session === undefined) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'no-session', detail: '' }
677
+ for (const turn of turns) if (turn?.finished === true) pool.push({ sessionId, turn, turns })
678
+ } else {
679
+ const sessions = typeof svc.sessions?.list === 'function' ? svc.sessions.list() : []
680
+ for (const session of Array.isArray(sessions) ? sessions : []) {
681
+ if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
682
+ const { turns } = turnsOf(svc, session.id)
683
+ for (const turn of turns) if (turn?.finished === true) pool.push({ sessionId: session.id, turn, turns })
684
+ }
685
+ }
686
+ pool.sort((a, b) => (b.turn.endedAt ?? 0) - (a.turn.endedAt ?? 0))
687
+ const eligible = []
688
+ let skipped = 0
689
+ for (const item of pool) {
690
+ const prompt = typeof item.turn.prompt === 'string' ? item.turn.prompt.trim() : ''
691
+ if (prompt.length < ENRICH_MIN_DRAFT_CHARS || looksLikeContinuation(prompt) || store.report(item.sessionId, item.turn.turn) !== null) {
692
+ skipped += 1
693
+ continue
694
+ }
695
+ eligible.push(item)
696
+ if (eligible.length >= limit) break
697
+ }
698
+ bootstrapState.running = true
699
+ bootstrapState.done = 0
700
+ bootstrapState.total = eligible.length
701
+ bootstrapState.startedAt = Date.now()
702
+ let analyzed = 0
703
+ let lastProvider = COACH_PROVIDER
704
+ 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
712
+ }
713
+ if (analyzed > 0) {
714
+ await service.flushAuto() // let any scheduled distillation settle before forcing one
715
+ await maybeDistillDirectives(eligible[0].sessionId, lastProvider, { force: true })
716
+ }
717
+ } finally {
718
+ bootstrapState.running = false
719
+ }
720
+ return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '' }
721
+ }
722
+
723
+ const autoStatus = () => {
724
+ const config = effectiveConfig()
725
+ return { today: store.autoLedger(dayKey()).count, budget: config.autoDailyBudget }
726
+ }
727
+
728
+ /** Spend one unit of today's auto budget; false when exhausted. */
729
+ const spendAuto = () => {
730
+ const config = effectiveConfig()
731
+ const ledger = store.autoLedger(dayKey())
732
+ if (ledger.count >= config.autoDailyBudget) return false
733
+ store.bumpAuto(ledger.date)
734
+ return true
735
+ }
736
+
737
+ const scheduleAuto = (sessionId, turn, options) => {
738
+ const key = `${sessionId}:${turn}`
739
+ if (autoSeen.has(key)) return
740
+ autoSeen.add(key)
741
+ if (store.report(sessionId, turn) !== null) return // already analyzed (manually or earlier)
742
+ if (!spendAuto()) return
743
+ const task = runAnalysis(sessionId, turn, options)
744
+ .then((result) => {
745
+ if (result !== null && typeof result === 'object' && result.ok === false) {
746
+ console.warn('[tacit] auto-analysis of ' + key + ' skipped: ' + result.code + (result.detail ? ' — ' + result.detail : ''))
747
+ }
748
+ return result
749
+ })
750
+ .catch(() => null)
751
+ .finally(() => autoRunning.delete(task))
752
+ autoRunning.add(task)
753
+ }
754
+
755
+ /**
756
+ * Zero-click learning. Two triggers, both free (no model call to decide):
757
+ * - the newest FINISHED turn is messy (retries / tool errors / compactions /
758
+ * rejection / long step run);
759
+ * - the newest (possibly unfinished) turn's prompt reads as a correction of
760
+ * the previous answer → the PREVIOUS turn is analyzed with that
761
+ * follow-up attached as evidence.
762
+ * Turns finished before the plugin started are ignored (cold restore).
763
+ */
764
+ const maybeAutoAnalyze = (sessionId, turns) => {
765
+ const config = effectiveConfig()
766
+ if (!config.autoAnalyze) return
767
+ const list = Array.isArray(turns) ? turns.filter((turn) => turn !== null && typeof turn === 'object' && typeof turn.turn === 'number') : []
768
+ if (list.length === 0) return
769
+ const fresh = (turn) => typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt
770
+ const newest = list[list.length - 1]
771
+ const previous = list.length >= 2 ? list[list.length - 2] : null
772
+ if (newest.finished === true) {
773
+ // A bare continuation ("continue", "go ahead") is adequate by construction:
774
+ // the conversation is its context. Heavy work after it is not a prompt fault.
775
+ if (fresh(newest) && isMessyTurn(newest, { minSteps: config.autoMinSteps }) && !looksLikeContinuation(newest.prompt)) {
776
+ scheduleAuto(sessionId, newest.turn, { trigger: 'auto', digest: newest, previousDigest: previous })
777
+ }
778
+ return
779
+ }
780
+ // Newest turn is running: is its prompt a correction of the previous one?
781
+ if (previous !== null && previous.finished === true && fresh(previous) && looksLikeCorrection(newest.prompt)) {
782
+ const beforePrevious = list.length >= 3 ? list[list.length - 3] : null
783
+ scheduleAuto(sessionId, previous.turn, { trigger: 'correction', followUp: newest.prompt, digest: previous, previousDigest: beforePrevious })
784
+ }
785
+ }
786
+
787
+ const service = {
788
+ /** Await every in-flight automatic analysis (tests / orderly shutdown). */
789
+ async flushAuto() {
790
+ await Promise.all([...autoRunning])
791
+ },
792
+
793
+ async getState() {
794
+ return {
795
+ ok: true,
796
+ config: effectiveConfig(),
797
+ profile: safeProfile(),
798
+ auto: autoStatus(),
799
+ steering: steeringStatus(),
800
+ bootstrap: { ...bootstrapState },
801
+ message: '',
802
+ }
803
+ },
804
+
805
+ async getReports(args) {
806
+ const parsed = sessionArgSchema.safeParse(args)
807
+ if (!parsed.success) return { ok: false, reports: {}, message: 'bad-request' }
808
+ const reports = {}
809
+ for (const entry of store.listReports(parsed.data.sessionId)) {
810
+ const checked = reportSchema.safeParse(entry.report)
811
+ if (checked.success) reports[String(entry.turn)] = checked.data
812
+ }
813
+ return { ok: true, reports, message: '' }
814
+ },
815
+
816
+ async listHistory(args) {
817
+ const raw = args !== null && typeof args === 'object' ? args : {}
818
+ const limit = typeof raw.limit === 'number' && Number.isFinite(raw.limit) ? raw.limit : 50
819
+ const svc = serviceOf(ctx)
820
+ const entries = store.listAllReports(limit).map((entry) => ({ ...entry, sessionLabel: sessionLabelOf(svc, entry.sessionId) }))
821
+ return { ok: true, entries, code: '', detail: '' }
822
+ },
823
+
824
+ async analyzeTurn(args) {
825
+ const parsed = analyzeArgSchema.safeParse(args)
826
+ if (!parsed.success) {
827
+ return { ok: false, report: null, profile: safeProfile(), code: 'bad-request', detail: '' }
828
+ }
829
+ return runAnalysis(parsed.data.sessionId, parsed.data.turn, { trigger: 'manual' })
830
+ },
831
+
832
+ async improveDraft(args) {
833
+ const parsed = improveArgSchema.safeParse(args)
834
+ if (!parsed.success) {
835
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'bad-request', detail: '' }
836
+ }
837
+ const { sessionId, draft } = parsed.data
838
+ const config = effectiveConfig()
839
+ const profile = safeProfile()
840
+ const svc = serviceOf(ctx)
841
+ const { turns } = turnsOf(svc, sessionId)
842
+ const recentContext = recentContextOf(turns)
843
+ // Distillation also fires on user-triggered improve calls (soft, in-flight
844
+ // deduped, never awaited: an improve call is never blocked by it).
845
+ if (profile.pendingDistill >= 3) {
846
+ maybeDistill(profile, providerForSession(sessionId)).catch(() => {})
847
+ }
848
+ // Only trusted (or still-inexperienced) patterns reach the prompt;
849
+ // style rules + the last 3 verbatim down-reasons ride along for free
850
+ // on this existing call. There is no learning gate.
851
+ const selected = improvePatterns(profile, config.maxPatterns)
852
+ const userText = buildImproveUserText({
853
+ draft: draft.trim(),
854
+ profile: { patterns: selected },
855
+ recentContext,
856
+ styleRules: profile.styleRules,
857
+ negativeFeedback: lastDownReasons(profile, 3),
858
+ })
859
+ try {
860
+ // Provider follows the session's own route (latest known), so proxy
861
+ // or custom provider setups keep working; the shipped DeepSeek
862
+ // adapter id is the fallback.
863
+ const provider = providerForSession(sessionId)
864
+ let text = await callCoachModel(ctx, {
865
+ provider,
866
+ model: config.model,
867
+ system: IMPROVE_SYSTEM_PROMPT,
868
+ userText,
869
+ maxTokens: IMPROVE_MAX_TOKENS,
870
+ timeoutMs: IMPROVE_TIMEOUT_MS,
871
+ tool: IMPROVE_TOOL,
872
+ sessionId,
873
+ })
874
+ if (text.trim() === '') {
875
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: 'empty-response', detail: '' }
876
+ }
877
+ let parsed = parseJsonObject(text)
878
+ if (parsed === null) {
879
+ const repaired = await callCoachModel(ctx, {
880
+ provider,
881
+ model: config.model,
882
+ system: IMPROVE_REPAIR_SYSTEM_PROMPT,
883
+ userText,
884
+ maxTokens: IMPROVE_MAX_TOKENS,
885
+ timeoutMs: IMPROVE_TIMEOUT_MS,
886
+ tool: IMPROVE_TOOL,
887
+ sessionId,
888
+ })
889
+ if (repaired.trim() !== '') {
890
+ const reparsed = parseJsonObject(repaired)
891
+ if (reparsed !== null) parsed = reparsed
892
+ }
893
+ }
894
+ const result = normalizeImprove(parsed, draft.trim())
895
+ const rewriteId = nextRewriteId()
896
+ const patternsUsed = selected.map((pattern) => pattern.kind)
897
+ rememberRewrite({
898
+ rewriteId,
899
+ sessionId,
900
+ patternsUsed,
901
+ draft: draft.trim().slice(0, 1000),
902
+ improved: result.improved.slice(0, 2000),
903
+ })
904
+ return { ok: true, ...result, rewriteId, patternsUsed, code: '', detail: '' }
905
+ } catch (error) {
906
+ const detail = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
907
+ return { ok: false, improved: '', rationale: '', rewriteId: '', patternsUsed: [], code: coachErrorCode(error), detail }
908
+ }
909
+ },
910
+
911
+ /**
912
+ * 👍/👎 on an applied rewrite: trust counters + good-examples library +
913
+ * bounded feedback log. A 👎 reason is clipped to 300 chars, logged
914
+ * verbatim (it rides the very next improve prompt), and counts toward
915
+ * the distillation trigger. Without a known rewriteId the verdict is
916
+ * ignored (soft 400).
917
+ */
918
+ async feedback(args) {
919
+ const parsed = feedbackArgSchema.safeParse(args)
920
+ if (!parsed.success) return { ok: false, profile: safeProfile(), code: 'bad-request', detail: '' }
921
+ const record = rewriteRecords.get(parsed.data.rewriteId)
922
+ if (record === undefined) return { ok: false, profile: safeProfile(), code: 'unknown-rewrite', detail: '' }
923
+ const { verdict } = parsed.data
924
+ const reason = typeof parsed.data.reason === 'string'
925
+ ? parsed.data.reason.trim().slice(0, MAX_FEEDBACK_REASON_CHARS)
926
+ : ''
927
+ let profile = safeProfile()
928
+ if (verdict === 'up') {
929
+ profile = bumpPatterns(profile, record.patternsUsed, 'accepted')
930
+ profile.feedbackLog = [...profile.feedbackLog, {
931
+ time: Date.now(),
932
+ verdict: 'up',
933
+ reason: '',
934
+ patternKinds: [...record.patternsUsed],
935
+ }].slice(-MAX_FEEDBACK_LOG)
936
+ } else {
937
+ profile = bumpPatterns(profile, record.patternsUsed, 'rejected')
938
+ profile.feedbackLog = [...profile.feedbackLog, {
939
+ time: Date.now(),
940
+ verdict: 'down',
941
+ reason,
942
+ patternKinds: [...record.patternsUsed],
943
+ }].slice(-MAX_FEEDBACK_LOG)
944
+ if (reason.length > 0) profile.pendingDistill += 1
945
+ }
946
+ profile = capAndSaveProfile(profile)
947
+ // 3+ unreviewed down-reasons fire ONE distillation call, on this
948
+ // user action. Awaited so the returned profile already carries the
949
+ // fresh style rules; a failure is soft-silent and retries later.
950
+ if (profile.pendingDistill >= 3) {
951
+ profile = await maybeDistill(profile, providerForSession(record.sessionId))
952
+ }
953
+ return { ok: true, profile, code: '', detail: '' }
954
+ },
955
+
956
+ /**
957
+ * The client applied a rewrite: bump `applied` on the used patterns and
958
+ * capture the current last finished turn's digest as the baseline for
959
+ * the free outcome verification of the immediately following turn.
960
+ */
961
+ async applied(args) {
962
+ const parsed = appliedArgSchema.safeParse(args)
963
+ if (!parsed.success) return { ok: false, code: 'bad-request', detail: '' }
964
+ const record = rewriteRecords.get(parsed.data.rewriteId)
965
+ if (record === undefined) return { ok: false, code: 'unknown-rewrite', detail: '' }
966
+ const { sessionId, rewriteId } = parsed.data
967
+ let profile = safeProfile()
968
+ profile = bumpPatterns(profile, record.patternsUsed, 'applied')
969
+ capAndSaveProfile(profile)
970
+ const { session, turns } = turnsOf(serviceOf(ctx), sessionId)
971
+ const baseline = session === undefined ? null : outcomeBaselineOf(lastFinishedTurnOf(turns))
972
+ let queue = pendingVerifications.get(sessionId)
973
+ if (queue === undefined) {
974
+ queue = []
975
+ pendingVerifications.set(sessionId, queue)
976
+ }
977
+ queue.push({ rewriteId, baseline })
978
+ if (queue.length > MAX_PENDING_VERIFICATIONS) queue.shift()
979
+ return { ok: true, code: '', detail: '' }
980
+ },
981
+
982
+ /** Settings edits to the agent-facing directives. */
983
+ async directives(args) {
984
+ const parsed = directivesArgSchema.safeParse(args)
985
+ if (!parsed.success) return { ok: false, profile: safeProfile(), steering: steeringStatus(), code: 'bad-request', detail: '' }
986
+ const profile = safeProfile()
987
+ const input = parsed.data
988
+ if (input.action === 'toggle') {
989
+ const found = profile.directives.find((entry) => entry.id === input.id)
990
+ if (found === undefined) return { ok: false, profile, steering: steeringStatus(), code: 'bad-request', detail: 'id' }
991
+ found.enabled = input.enabled
992
+ // Re-enabling a retired directive is an explicit override.
993
+ if (input.enabled && found.status === 'retired') {
994
+ found.status = 'active'
995
+ delete found.retiredReason
996
+ }
997
+ } else if (input.action === 'add') {
998
+ const text = clipSafe(input.text.trim(), 220)
999
+ 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() })
1001
+ } else {
1002
+ profile.directives = profile.directives.filter((entry) => entry.id !== input.id)
1003
+ }
1004
+ const saved = capAndSaveProfile(profile)
1005
+ return { ok: true, profile: saved, steering: steeringStatus(), code: '', detail: '' }
1006
+ },
1007
+
1008
+ /** System-prompt section provider (registered by the host entry). */
1009
+ steeringText,
1010
+
1011
+ /** agent/pre-step listener (registered by the host entry). */
1012
+ preStep,
1013
+
1014
+ async bootstrap(args) {
1015
+ const parsed = bootstrapArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1016
+ if (!parsed.success) return { ok: false, analyzed: 0, skipped: 0, directives: 0, code: 'bad-request', detail: '' }
1017
+ return runBootstrap({ sessionId: parsed.data.sessionId, limit: parsed.data.limit ?? 20 })
1018
+ },
1019
+
1020
+ async stats(args) {
1021
+ const parsed = statsArgSchema.safeParse(args !== null && typeof args === 'object' ? args : {})
1022
+ const window = parsed.success && typeof parsed.data.window === 'number' ? parsed.data.window : 20
1023
+ return { ok: true, trend: computeTrend(allFinishedTurns(), { window }), code: '', detail: '' }
1024
+ },
1025
+
1026
+ async updateConfig(args) {
1027
+ const parsed = configArgSchema.safeParse(args)
1028
+ if (!parsed.success) return { ok: false, config: effectiveConfig(), code: 'bad-request', detail: '' }
1029
+ const patch = parsed.data.patch ?? {}
1030
+ if (typeof patch.model === 'string' && !COACH_MODELS.includes(patch.model)) {
1031
+ return { ok: false, config: effectiveConfig(), code: 'bad-request', detail: 'model' }
1032
+ }
1033
+ store.saveConfigPatch({ ...store.configPatch(), ...patch })
1034
+ return { ok: true, config: effectiveConfig(), code: '', detail: '' }
1035
+ },
1036
+
1037
+ async clearReports() {
1038
+ const removed = store.clearReports()
1039
+ return { ok: true, removed, code: '', detail: '' }
1040
+ },
1041
+ }
1042
+
1043
+ return service
1044
+ }