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/LICENSE +21 -0
- package/README.md +83 -0
- package/README.zh.md +77 -0
- package/client/client.js +1691 -0
- package/cordis.patch.yml +15 -0
- package/lib/analyze.js +893 -0
- package/lib/fold.js +300 -0
- package/lib/index.js +86 -0
- package/lib/routes.js +152 -0
- package/lib/schema.js +294 -0
- package/lib/service.js +1044 -0
- package/lib/store.js +219 -0
- package/package.json +104 -0
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
|
+
}
|