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/analyze.js
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
|
|
3
|
+
/**
|
|
4
|
+
* dsh-tacit — analysis: prompt building, model call, parsing, profile
|
|
5
|
+
* aggregation. Pure functions are exported for unit tests; the only harness
|
|
6
|
+
* coupling is the `ctx.llm.stream` waterfall (credentials resolved by the
|
|
7
|
+
* harness itself — this plugin never reads or stores API keys).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
import { reportSchema, profileSchema } from './schema.js'
|
|
13
|
+
|
|
14
|
+
export const ANALYZE_TIMEOUT_MS = 120000
|
|
15
|
+
export const IMPROVE_TIMEOUT_MS = 60000
|
|
16
|
+
export const DISTILL_TIMEOUT_MS = 30000
|
|
17
|
+
export const ANALYZE_MAX_TOKENS = 3000
|
|
18
|
+
export const IMPROVE_MAX_TOKENS = 1500
|
|
19
|
+
/**
|
|
20
|
+
* Output budgets include the model's (low-effort) reasoning tokens — a budget
|
|
21
|
+
* that is too small ends the call before the tool call is emitted, which
|
|
22
|
+
* surfaces as an empty answer. Only generated tokens are billed.
|
|
23
|
+
*/
|
|
24
|
+
export const DISTILL_MAX_TOKENS = 1000
|
|
25
|
+
const DISTILL_MAX_RULES = 3
|
|
26
|
+
export const MAX_STYLE_RULES = 6
|
|
27
|
+
export const MAX_FEEDBACK_LOG = 10
|
|
28
|
+
export const MAX_FEEDBACK_REASON_CHARS = 300
|
|
29
|
+
/** A rewrite record needs at least this many applied samples before its trust gates it. */
|
|
30
|
+
export const TRUST_MIN_APPLIED = 2
|
|
31
|
+
/** Reasoning effort for every coach call (DeepSeek accepts off|low|high|max). */
|
|
32
|
+
const COACH_REASONING_EFFORT = 'low'
|
|
33
|
+
|
|
34
|
+
/** Clip to `max` UTF-16 units without splitting a surrogate pair. */
|
|
35
|
+
export function clipSafe(value, max) {
|
|
36
|
+
const text = typeof value === 'string' ? value : ''
|
|
37
|
+
if (text.length <= max) return text
|
|
38
|
+
let end = max
|
|
39
|
+
if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1
|
|
40
|
+
return text.slice(0, end)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Structured output: the model answers by CALLING one tool whose arguments
|
|
44
|
+
// are the payload (the harness has no JSON mode; tool arguments are the
|
|
45
|
+
// reliable structured channel). Text JSON is still accepted as a fallback.
|
|
46
|
+
|
|
47
|
+
const PROBLEM_PARAMETERS = {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
kind: { type: 'string', description: 'short category, e.g. missing-constraints|ambiguous-goal|missing-context|wrong-scope' },
|
|
51
|
+
severity: { type: 'string', enum: ['high', 'medium', 'low'] },
|
|
52
|
+
what: { type: 'string', description: 'one sentence: what the prompt got wrong' },
|
|
53
|
+
why: { type: 'string', description: 'one sentence: the observed trajectory evidence' },
|
|
54
|
+
},
|
|
55
|
+
required: ['kind', 'severity', 'what', 'why'],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const ANALYSIS_TOOL = {
|
|
59
|
+
name: 'report',
|
|
60
|
+
description: 'Submit the coaching report for the analyzed prompt.',
|
|
61
|
+
parameters: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
problems: { type: 'array', items: PROBLEM_PARAMETERS },
|
|
65
|
+
improvedPrompt: { type: 'string', description: 'rewritten prompt that keeps the intent but fixes the problems' },
|
|
66
|
+
explanation: { type: 'string', description: '2-4 sentences summarizing the key improvements' },
|
|
67
|
+
},
|
|
68
|
+
required: ['problems', 'improvedPrompt', 'explanation'],
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const IMPROVE_TOOL = {
|
|
73
|
+
name: 'improved',
|
|
74
|
+
description: 'Submit the rewritten draft.',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
improved: { type: 'string', description: 'the rewritten prompt' },
|
|
79
|
+
rationale: { type: 'string', description: '1-2 sentences on what changed and why' },
|
|
80
|
+
},
|
|
81
|
+
required: ['improved', 'rationale'],
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const DIRECTIVE_TOOL = {
|
|
86
|
+
name: 'directives',
|
|
87
|
+
description: 'Submit the agent-facing directives distilled from this user\'s prompting habits.',
|
|
88
|
+
parameters: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
directives: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 4 },
|
|
92
|
+
},
|
|
93
|
+
required: ['directives'],
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
export const DIRECTIVE_MAX_TOKENS = 1500
|
|
97
|
+
export const DIRECTIVE_TIMEOUT_MS = 30000
|
|
98
|
+
export const MAX_DIRECTIVES = 8
|
|
99
|
+
const DIRECTIVE_MAX_CHARS = 220
|
|
100
|
+
/** Whole steering section budget (~300 tokens). */
|
|
101
|
+
export const STEERING_MAX_CHARS = 1400
|
|
102
|
+
|
|
103
|
+
// ── Opt-in pre-send enrichment ─────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export const ENRICH_MAX_TOKENS = 1000
|
|
106
|
+
export const ENRICH_TIMEOUT_MS = 15000
|
|
107
|
+
export const ENRICH_MIN_DRAFT_CHARS = 8
|
|
108
|
+
export const ENRICH_MAX_DRAFT_CHARS = 1500
|
|
109
|
+
export const ENRICH_PREFIX = 'Context from Tacit (learned from this user\'s past prompts, not their words): '
|
|
110
|
+
|
|
111
|
+
export const ENRICH_TOOL = {
|
|
112
|
+
name: 'context',
|
|
113
|
+
description: 'Submit the context note to append for the agent, or an empty note when the prompt is already clear.',
|
|
114
|
+
parameters: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
note: { type: 'string', description: '1-3 short sentences: what the user probably means and what to check before starting; empty when nothing is worth adding' },
|
|
118
|
+
},
|
|
119
|
+
required: ['note'],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const ENRICH_SYSTEM_PROMPT = [
|
|
124
|
+
'You help a coding agent understand ONE specific user. You are given what',
|
|
125
|
+
'the coach learned about how this user under-specifies prompts, the recent',
|
|
126
|
+
'conversation, and the prompt the user is sending right now.',
|
|
127
|
+
'Write a SHORT context note (1-3 sentences) for the agent: the most likely',
|
|
128
|
+
'intent behind the prompt and 1-2 concrete things to check or assume before',
|
|
129
|
+
'starting, based on this user\'s habits. Never add requirements the user did',
|
|
130
|
+
'not imply, never restate the prompt, never give generic advice. If the',
|
|
131
|
+
'prompt is already specific enough, return an empty note.',
|
|
132
|
+
].join('\n')
|
|
133
|
+
|
|
134
|
+
export function buildEnrichUserText({ draft, profile, recentContext }) {
|
|
135
|
+
const lines = []
|
|
136
|
+
const directives = (Array.isArray(profile?.directives) ? profile.directives : [])
|
|
137
|
+
.filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && typeof entry.text === 'string')
|
|
138
|
+
if (directives.length > 0) {
|
|
139
|
+
lines.push('=== WHAT THE COACH KNOWS ABOUT THIS USER ===')
|
|
140
|
+
for (const entry of directives.slice(0, MAX_DIRECTIVES)) lines.push('- ' + clipSafe(entry.text, DIRECTIVE_MAX_CHARS))
|
|
141
|
+
lines.push('')
|
|
142
|
+
}
|
|
143
|
+
const patterns = Array.isArray(profile?.patterns) ? profile.patterns.slice(0, 6) : []
|
|
144
|
+
if (patterns.length > 0) {
|
|
145
|
+
lines.push('=== RECURRING HABITS ===')
|
|
146
|
+
for (const pattern of patterns) lines.push('- ' + String(pattern.kind) + ': ' + clipSafe(String(pattern.lastExample ?? ''), 160))
|
|
147
|
+
lines.push('')
|
|
148
|
+
}
|
|
149
|
+
if (typeof recentContext === 'string' && recentContext.length > 0) {
|
|
150
|
+
lines.push('=== RECENT CONVERSATION ===', clipSafe(recentContext, 1200), '')
|
|
151
|
+
}
|
|
152
|
+
lines.push('=== THE PROMPT BEING SENT NOW ===', clipSafe(String(draft ?? ''), ENRICH_MAX_DRAFT_CHARS))
|
|
153
|
+
return lines.join('\n')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The note text, trimmed and clipped; '' when the model had nothing to add. */
|
|
157
|
+
export function normalizeEnrichNote(text) {
|
|
158
|
+
const parsed = parseJsonObject(text)
|
|
159
|
+
const note = parsed !== null && typeof parsed.note === 'string' ? parsed.note.trim() : ''
|
|
160
|
+
return clipSafe(note, 600)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Measured trend ─────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function windowStats(turns) {
|
|
166
|
+
const n = turns.length
|
|
167
|
+
if (n === 0) return { n: 0, messyRate: 0, tokensPerTurn: 0 }
|
|
168
|
+
let messy = 0
|
|
169
|
+
let tokens = 0
|
|
170
|
+
for (const turn of turns) {
|
|
171
|
+
if (isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })) messy += 1
|
|
172
|
+
const usage = turn.usage !== null && typeof turn.usage === 'object' ? turn.usage : {}
|
|
173
|
+
const read = (key) => (typeof usage[key] === 'number' && Number.isFinite(usage[key]) ? usage[key] : 0)
|
|
174
|
+
tokens += read('inputTokens') + read('outputTokens') + read('cacheReadTokens') + read('reasoningTokens')
|
|
175
|
+
}
|
|
176
|
+
return { n, messyRate: messy / n, tokensPerTurn: Math.round(tokens / n) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Real before/after numbers from the fold: the first `window` finished turns
|
|
181
|
+
* vs. the latest `window`, on rework signals (retries/errors/compactions/
|
|
182
|
+
* rejections — step counts are deliberately NOT a signal) and tokens/turn.
|
|
183
|
+
*/
|
|
184
|
+
export function computeTrend(turns, { window = 20 } = {}) {
|
|
185
|
+
const finished = (Array.isArray(turns) ? turns : [])
|
|
186
|
+
.filter((turn) => turn !== null && typeof turn === 'object' && turn.finished === true)
|
|
187
|
+
.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))
|
|
188
|
+
const size = Math.max(1, Math.round(window))
|
|
189
|
+
const early = finished.slice(0, size)
|
|
190
|
+
const recent = finished.slice(-size)
|
|
191
|
+
return {
|
|
192
|
+
enough: finished.length >= size * 2,
|
|
193
|
+
window: size,
|
|
194
|
+
early: windowStats(early),
|
|
195
|
+
recent: windowStats(recent),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const DISTILL_TOOL = {
|
|
200
|
+
name: 'rules',
|
|
201
|
+
description: 'Submit the distilled style rules.',
|
|
202
|
+
parameters: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
rules: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 3 },
|
|
206
|
+
},
|
|
207
|
+
required: ['rules'],
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const analysisReportShape = z.object({
|
|
212
|
+
problems: z.array(
|
|
213
|
+
z.object({
|
|
214
|
+
kind: z.string(),
|
|
215
|
+
severity: z.string(),
|
|
216
|
+
what: z.string(),
|
|
217
|
+
why: z.string(),
|
|
218
|
+
}),
|
|
219
|
+
).default([]),
|
|
220
|
+
improvedPrompt: z.string().default(''),
|
|
221
|
+
explanation: z.string().default(''),
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const improveShape = z.object({
|
|
225
|
+
improved: z.string().default(''),
|
|
226
|
+
rationale: z.string().default(''),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
export const ANALYSIS_SYSTEM_PROMPT = [
|
|
230
|
+
'You are a strict but friendly prompt-engineering coach inside DeepSeek Harness.',
|
|
231
|
+
'You are given ONE past user prompt plus a digest of everything that happened',
|
|
232
|
+
'while the agent answered it: tool calls made, steps taken, retries,',
|
|
233
|
+
'compactions, token usage, and the final response (excerpt).',
|
|
234
|
+
'',
|
|
235
|
+
'Your job: diagnose the PROMPT, not the agent. Point out what the prompt left',
|
|
236
|
+
'ambiguous or under-specified and how that caused the observed trajectory',
|
|
237
|
+
'(wrong tools, extra steps, retries, wasted tokens, off-target answer).',
|
|
238
|
+
'Prioritize concrete, actionable findings grounded in the digest. When the',
|
|
239
|
+
"user's NEXT message is included, it is the strongest evidence: it shows",
|
|
240
|
+
'what the prompt failed to say and what the user actually wanted.',
|
|
241
|
+
'',
|
|
242
|
+
'The conversation carries across turns. A short prompt ("continue", "go',
|
|
243
|
+
'ahead", "yes") is ADEQUATE when the previous turn supplies the context —',
|
|
244
|
+
'never blame it for being short. Heavy but successful work (many steps or',
|
|
245
|
+
'tool calls) is NOT a prompt fault; only rework signals are: retries, tool',
|
|
246
|
+
'errors, compactions, cancellation, or the user correcting the agent next.',
|
|
247
|
+
'When the prompt was adequate, return "problems": [] and the original prompt',
|
|
248
|
+
'unchanged as improvedPrompt.',
|
|
249
|
+
'',
|
|
250
|
+
'RESPONSE FORMAT — this is mandatory and machine-parsed:',
|
|
251
|
+
'Your ENTIRE response must be ONE JSON object and nothing else. No preamble,',
|
|
252
|
+
'no narration, no explanations outside the JSON, no markdown fences.',
|
|
253
|
+
'Start directly with "{" and end with "}".',
|
|
254
|
+
'{',
|
|
255
|
+
' "problems": [',
|
|
256
|
+
' {"kind": "<short category, e.g. missing-constraints|ambiguous-goal|missing-context|wrong-scope>",',
|
|
257
|
+
' "severity": "high|medium|low",',
|
|
258
|
+
' "what": "<one sentence: what the prompt got wrong>",',
|
|
259
|
+
' "why": "<one sentence: the observed trajectory evidence>"}',
|
|
260
|
+
' ],',
|
|
261
|
+
' "improvedPrompt": "<a rewritten version of the original prompt that keeps its intent but fixes the problems>",',
|
|
262
|
+
' "explanation": "<2-4 sentences summarizing the key improvements>"',
|
|
263
|
+
'}',
|
|
264
|
+
'',
|
|
265
|
+
'Reply in the same language as the prompt being analyzed.',
|
|
266
|
+
].join('\n')
|
|
267
|
+
|
|
268
|
+
export const IMPROVE_SYSTEM_PROMPT = [
|
|
269
|
+
'You are a prompt-improvement assistant inside DeepSeek Harness.',
|
|
270
|
+
'The user typed a draft prompt into the composer. Rewrite it to be more',
|
|
271
|
+
'precise, complete, and token-efficient, while PRESERVING its intent.',
|
|
272
|
+
'Do not add requirements the user did not ask for; fill in only what is',
|
|
273
|
+
'genuinely underspecified (scope, constraints, format, acceptance criteria).',
|
|
274
|
+
'Learn from the user\'s recurring mistake patterns when provided.',
|
|
275
|
+
'',
|
|
276
|
+
'RESPONSE FORMAT — this is mandatory and machine-parsed:',
|
|
277
|
+
'Your ENTIRE response must be ONE JSON object and nothing else. No preamble,',
|
|
278
|
+
'no narration, no explanations outside the JSON, no markdown fences.',
|
|
279
|
+
'Start directly with "{" and end with "}".',
|
|
280
|
+
'{',
|
|
281
|
+
' "improved": "<the rewritten prompt>",',
|
|
282
|
+
' "rationale": "<1-2 sentences on what you changed and why>"',
|
|
283
|
+
'}',
|
|
284
|
+
'',
|
|
285
|
+
'Reply in the same language as the draft.',
|
|
286
|
+
].join('\n')
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* One-shot repair prompts: used when the first answer was not parseable JSON.
|
|
290
|
+
* The model re-generates the SAME payload, this time under a hard
|
|
291
|
+
* JSON-only instruction (its previous prose is not fed back — the original
|
|
292
|
+
* task is enough).
|
|
293
|
+
*/
|
|
294
|
+
export const ANALYSIS_REPAIR_SYSTEM_PROMPT = [
|
|
295
|
+
'You are a strict prompt-engineering coach inside DeepSeek Harness.',
|
|
296
|
+
'Your previous response was not a valid JSON object.',
|
|
297
|
+
'Now respond with EXACTLY ONE JSON object and nothing else — no prose,',
|
|
298
|
+
'no narration, no markdown fences. Start directly with "{" and end with "}".',
|
|
299
|
+
'Use the shape:',
|
|
300
|
+
'{"problems":[{"kind":"<short category>","severity":"high|medium|low","what":"<one sentence>","why":"<one sentence, trajectory evidence>"}],',
|
|
301
|
+
'"improvedPrompt":"<rewritten prompt keeping the intent>",',
|
|
302
|
+
'"explanation":"<2-4 sentences>"}',
|
|
303
|
+
'Reply in the same language as the prompt being analyzed.',
|
|
304
|
+
].join('\n')
|
|
305
|
+
|
|
306
|
+
export const IMPROVE_REPAIR_SYSTEM_PROMPT = [
|
|
307
|
+
'You are a prompt-improvement assistant inside DeepSeek Harness.',
|
|
308
|
+
'Your previous response was not a valid JSON object.',
|
|
309
|
+
'Now respond with EXACTLY ONE JSON object and nothing else — no prose,',
|
|
310
|
+
'no narration, no markdown fences. Start directly with "{" and end with "}".',
|
|
311
|
+
'Use the shape:',
|
|
312
|
+
'{"improved":"<the rewritten prompt>","rationale":"<1-2 sentences>"}',
|
|
313
|
+
'Reply in the same language as the draft.',
|
|
314
|
+
].join('\n')
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Style-rule distillation: the ONLY new paid model call of the v2 loop,
|
|
318
|
+
* fired rarely (every 3+ unreviewed 👎 reasons) with a hard 300-token cap.
|
|
319
|
+
*/
|
|
320
|
+
export const DISTILL_SYSTEM_PROMPT = [
|
|
321
|
+
'You distill user feedback into durable prompt-writing style rules for a',
|
|
322
|
+
'prompt-improvement coach inside DeepSeek Harness.',
|
|
323
|
+
'Given verbatim reasons why the user rejected past prompt rewrites, write',
|
|
324
|
+
'2-3 general, durable style rules the coach must follow in EVERY future',
|
|
325
|
+
'rewrite. Each rule is one complete imperative sentence, specific enough',
|
|
326
|
+
'to steer a rewrite, general enough to survive future tasks. No preamble.',
|
|
327
|
+
'',
|
|
328
|
+
'RESPONSE FORMAT — this is mandatory and machine-parsed:',
|
|
329
|
+
'Your ENTIRE response must be ONE JSON object and nothing else.',
|
|
330
|
+
'Start directly with "{" and end with "}".',
|
|
331
|
+
'{"rules": ["<rule one>", "<rule two>", "<rule three, optional>"]}',
|
|
332
|
+
].join('\n')
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Directive distillation: turns what the coach learned about the user's
|
|
336
|
+
* prompting habits into imperatives for the AGENT — so the agent compensates
|
|
337
|
+
* on the user's behalf on every turn instead of the user changing how they
|
|
338
|
+
* write. One tiny call every `directiveEvery` analyses.
|
|
339
|
+
*/
|
|
340
|
+
export const DIRECTIVE_SYSTEM_PROMPT = [
|
|
341
|
+
'You write directives for a coding agent about ONE specific user, based on',
|
|
342
|
+
'how that user tends to under-specify their prompts (learned from past',
|
|
343
|
+
'analyses) and how they corrected the agent afterwards.',
|
|
344
|
+
'',
|
|
345
|
+
'Each directive tells the AGENT what to assume, check, or do differently for',
|
|
346
|
+
'this user so the user does not have to write it every time. Good directives',
|
|
347
|
+
'are specific and actionable: "When the user names a feature but no files,',
|
|
348
|
+
'grep the repo for it before asking." Bad directives restate generic best',
|
|
349
|
+
'practice ("be helpful"). Never contradict explicit instructions in a prompt.',
|
|
350
|
+
'Directives must REDUCE the user\'s effort: prefer "assume X", "check Y',
|
|
351
|
+
'first", "do Z without asking". NEVER tell the agent to stop and ask the user',
|
|
352
|
+
'unless the information is genuinely undiscoverable from the repo, the',
|
|
353
|
+
'conversation, or the user\'s habits. Never target one wording (e.g. a bare',
|
|
354
|
+
'"continue" — that is fine; the conversation is the context).',
|
|
355
|
+
'Directives must GENERALIZE across future tasks: never mention a specific',
|
|
356
|
+
'task, file, feature, number, or test from one past prompt — describe the',
|
|
357
|
+
'habit and the compensation. Prefer the most frequent habits. 2-4 directives,',
|
|
358
|
+
'one sentence each, imperative mood, addressed to the agent. You are writing',
|
|
359
|
+
'the COMPLETE new set: keep existing directives that still hold (reworded if',
|
|
360
|
+
'sharper), drop ones that were one-off, add what is missing. No preamble.',
|
|
361
|
+
].join('\n')
|
|
362
|
+
|
|
363
|
+
export function buildDirectiveUserText(profile, recentReports = []) {
|
|
364
|
+
const lines = ['=== RECURRING PROMPT HABITS (kind, times seen, latest example) ===']
|
|
365
|
+
const patterns = Array.isArray(profile?.patterns) ? profile.patterns.slice(0, 12) : []
|
|
366
|
+
if (patterns.length === 0) lines.push('(none yet)')
|
|
367
|
+
for (const pattern of patterns) {
|
|
368
|
+
lines.push('- ' + String(pattern.kind) + ' (' + String(pattern.count ?? 0) + 'x): ' + clipSafe(String(pattern.lastExample ?? ''), 200))
|
|
369
|
+
}
|
|
370
|
+
const corrections = (Array.isArray(recentReports) ? recentReports : [])
|
|
371
|
+
.filter((report) => typeof report?.followUp === 'string' && report.followUp.length > 0)
|
|
372
|
+
.slice(-5)
|
|
373
|
+
if (corrections.length > 0) {
|
|
374
|
+
lines.push('', '=== RECENT CORRECTIONS (prompt → what the user said next) ===')
|
|
375
|
+
for (const report of corrections) {
|
|
376
|
+
lines.push('- "' + clipSafe(String(report.promptExcerpt ?? ''), 120) + '" → "' + clipSafe(report.followUp, 200) + '"')
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const rules = Array.isArray(profile?.styleRules) ? profile.styleRules.filter((rule) => typeof rule?.rule === 'string' && rule.rule.length > 0) : []
|
|
380
|
+
if (rules.length > 0) {
|
|
381
|
+
lines.push('', '=== STYLE RULES THE USER CONFIRMED ===')
|
|
382
|
+
for (const rule of rules) lines.push('- ' + clipSafe(rule.rule, 300))
|
|
383
|
+
}
|
|
384
|
+
const existing = Array.isArray(profile?.directives) ? profile.directives.filter((entry) => typeof entry?.text === 'string' && entry.text.length > 0) : []
|
|
385
|
+
if (existing.length > 0) {
|
|
386
|
+
lines.push('', '=== CURRENT DIRECTIVES (keep the ones that still hold) ===')
|
|
387
|
+
for (const entry of existing) lines.push('- ' + clipSafe(entry.text, DIRECTIVE_MAX_CHARS))
|
|
388
|
+
}
|
|
389
|
+
lines.push('', 'Write 2-4 directives for the agent about this user.')
|
|
390
|
+
return lines.join('\n')
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** A directive that makes the agent stop and ask — the opposite of compensating for the user. */
|
|
394
|
+
const ASKS_USER_RE = /\b(ask|confirm with|check with|clarify with|verify with|get (approval|confirmation|permission) from) (the )?user\b|\bstop and ask\b|\bbefore doing anything\b|\bask (what|which|whether|if|for)\b/i
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Parse a directives payload into clipped, deduped one-liners (≤4 kept);
|
|
398
|
+
* directives that instruct the agent to ask the user are rejected.
|
|
399
|
+
*/
|
|
400
|
+
export function classifyDirectives(text) {
|
|
401
|
+
const parsed = parseJsonObject(text)
|
|
402
|
+
const raw = parsed !== null && Array.isArray(parsed.directives) ? parsed.directives : null
|
|
403
|
+
if (raw === null) return { kept: [], rejected: [] }
|
|
404
|
+
const seen = new Set()
|
|
405
|
+
const kept = []
|
|
406
|
+
const rejected = []
|
|
407
|
+
for (const item of raw) {
|
|
408
|
+
if (typeof item !== 'string') continue
|
|
409
|
+
const value = clipSafe(item.trim(), DIRECTIVE_MAX_CHARS)
|
|
410
|
+
const key = value.toLowerCase()
|
|
411
|
+
if (value.length === 0 || seen.has(key)) continue
|
|
412
|
+
seen.add(key)
|
|
413
|
+
if (ASKS_USER_RE.test(value)) {
|
|
414
|
+
rejected.push(value)
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
kept.push(value)
|
|
418
|
+
if (kept.length >= 4) break
|
|
419
|
+
}
|
|
420
|
+
return { kept, rejected }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The system-prompt section: what the agent is told about this user.
|
|
425
|
+
* '' when nothing is enabled (an empty section contributes nothing).
|
|
426
|
+
*/
|
|
427
|
+
export function renderSteeringSection(profile) {
|
|
428
|
+
const enabled = (Array.isArray(profile?.directives) ? profile.directives : [])
|
|
429
|
+
.filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && entry.status !== 'retired'
|
|
430
|
+
&& typeof entry.text === 'string' && entry.text.trim().length > 0)
|
|
431
|
+
if (enabled.length === 0) return ''
|
|
432
|
+
const header = [
|
|
433
|
+
'## About this user (learned by Tacit from their past prompts)',
|
|
434
|
+
'This user tends to leave the following unsaid. Compensate silently when the',
|
|
435
|
+
'answer is discoverable; ask only when it is not. Explicit instructions in',
|
|
436
|
+
'the prompt always win over these notes.',
|
|
437
|
+
]
|
|
438
|
+
const lines = [...header]
|
|
439
|
+
let length = lines.join('\n').length
|
|
440
|
+
for (const entry of enabled.slice(0, MAX_DIRECTIVES)) {
|
|
441
|
+
const line = '- ' + clipSafe(entry.text.trim(), DIRECTIVE_MAX_CHARS)
|
|
442
|
+
if (length + line.length + 1 > STEERING_MAX_CHARS) break
|
|
443
|
+
lines.push(line)
|
|
444
|
+
length += line.length + 1
|
|
445
|
+
}
|
|
446
|
+
return lines.length > header.length ? lines.join('\n') : ''
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Build the distillation user text from verbatim rejected-improvement reasons. */
|
|
450
|
+
export function buildDistillUserText(reasons) {
|
|
451
|
+
const list = (Array.isArray(reasons) ? reasons : [])
|
|
452
|
+
.filter((reason) => typeof reason === 'string' && reason.length > 0)
|
|
453
|
+
.slice(0, 3)
|
|
454
|
+
.map((reason) => '- ' + reason.slice(0, MAX_FEEDBACK_REASON_CHARS))
|
|
455
|
+
const lines = [
|
|
456
|
+
'=== REJECTED-IMPROVEMENT REASONS (verbatim) ===',
|
|
457
|
+
...(list.length > 0 ? list : ['(none recorded)']),
|
|
458
|
+
'',
|
|
459
|
+
'Distill these into 2-3 durable style rules for rewriting user prompts.',
|
|
460
|
+
'Return ONLY the JSON object.',
|
|
461
|
+
]
|
|
462
|
+
return lines.join('\n')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Normalize a distillation response into 0..3 clipped, deduped rule strings.
|
|
467
|
+
* Returns [] when the model produced nothing usable (soft no-op).
|
|
468
|
+
*/
|
|
469
|
+
export function normalizeDistillRules(text) {
|
|
470
|
+
if (typeof text !== 'string') return []
|
|
471
|
+
const parsed = parseJsonObject(text)
|
|
472
|
+
const raw = parsed !== null && Array.isArray(parsed.rules) ? parsed.rules : null
|
|
473
|
+
if (raw === null) return []
|
|
474
|
+
const seen = new Set()
|
|
475
|
+
const rules = []
|
|
476
|
+
for (const item of raw) {
|
|
477
|
+
if (typeof item !== 'string') continue
|
|
478
|
+
const rule = item.trim().slice(0, 300)
|
|
479
|
+
if (rule.length === 0 || seen.has(rule.toLowerCase())) continue
|
|
480
|
+
seen.add(rule.toLowerCase())
|
|
481
|
+
rules.push(rule)
|
|
482
|
+
if (rules.length >= DISTILL_MAX_RULES) break
|
|
483
|
+
}
|
|
484
|
+
return rules
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Compact, bounded digest of a turn for the coach prompt. */
|
|
488
|
+
export function digestTurn(turn) {
|
|
489
|
+
if (turn === null || typeof turn !== 'object') return null
|
|
490
|
+
const usage = turn.usage !== null && typeof turn.usage === 'object' ? turn.usage : {}
|
|
491
|
+
const tools = Array.isArray(turn.toolCalls) ? turn.toolCalls : []
|
|
492
|
+
return {
|
|
493
|
+
turn: typeof turn.turn === 'number' ? turn.turn : 0,
|
|
494
|
+
prompt: typeof turn.prompt === 'string' ? turn.prompt : '',
|
|
495
|
+
steps: typeof turn.steps === 'number' ? turn.steps : 0,
|
|
496
|
+
retries: typeof turn.retries === 'number' ? turn.retries : 0,
|
|
497
|
+
compactions: typeof turn.compactions === 'number' ? turn.compactions : 0,
|
|
498
|
+
toolErrors: typeof turn.toolErrors === 'number' ? turn.toolErrors : 0,
|
|
499
|
+
toolCalls: tools.slice(0, 25).map((call) => ({
|
|
500
|
+
name: typeof call?.name === 'string' ? call.name : '?',
|
|
501
|
+
args: typeof call?.args === 'string' ? call.args.slice(0, 400) : '',
|
|
502
|
+
})),
|
|
503
|
+
usage: {
|
|
504
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
505
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
506
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
507
|
+
reasoningTokens: usage.reasoningTokens ?? 0,
|
|
508
|
+
},
|
|
509
|
+
finalText: typeof turn.finalText === 'string' ? turn.finalText.slice(0, 3000) : '',
|
|
510
|
+
model: typeof turn.model === 'string' ? turn.model : '',
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Finished turn with rework signals or a long run of model steps. */
|
|
515
|
+
export function isMessyTurn(turn, { minSteps = 15 } = {}) {
|
|
516
|
+
if (turn === null || typeof turn !== 'object' || turn.finished !== true) return false
|
|
517
|
+
const n = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
|
518
|
+
if (n(turn.retries) > 0 || n(turn.toolErrors) > 0 || n(turn.compactions) > 0) return true
|
|
519
|
+
if (turn.endReason === 'rejected' || turn.endReason === 'cancelled') return true
|
|
520
|
+
return n(turn.steps) >= Math.max(1, minSteps)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const CORRECTION_START = /^(no\b|nope\b|not\b|wrong\b|that'?s not\b|thats not\b|i meant\b|i said\b|i didn'?t\b|why (did|are|is|do)\b|what are you doing\b|stop\b|undo\b|revert\b|still\b|again\b|instead\b|不对|不是|错了|我是说|为什么)/i
|
|
524
|
+
const CORRECTION_ANY = /\b(i meant|not what i (asked|meant|wanted)|that'?s not|you (didn'?t|did not|ignored|missed)|why did you|wrong (file|folder|branch|approach)|is it stuck|why is it stuck|doesn'?t work|didn'?t work)\b|我是说|不是这个|为什么/i
|
|
525
|
+
const CORRECTION_MAX_CHARS = 300
|
|
526
|
+
|
|
527
|
+
/** Cheap heuristic: does this next user message read as a correction of the agent? */
|
|
528
|
+
export function looksLikeCorrection(text) {
|
|
529
|
+
if (typeof text !== 'string') return false
|
|
530
|
+
const value = text.trim()
|
|
531
|
+
if (value.length === 0 || value.length > CORRECTION_MAX_CHARS) return false
|
|
532
|
+
return CORRECTION_START.test(value) || CORRECTION_ANY.test(value)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const CONTINUATION_RE = /^(continue|go ahead|go on|proceed|next|carry on|keep going|yes|yep|yeah|ok(ay)?|do it|sure|please (continue|proceed)|继续|好的|可以|接着来)[.!\s]*$/i
|
|
536
|
+
/** A bare continuation ("continue", "go ahead", "yes") — adequate whenever the previous turn supplies context. */
|
|
537
|
+
export function looksLikeContinuation(text) {
|
|
538
|
+
if (typeof text !== 'string') return false
|
|
539
|
+
const value = text.trim()
|
|
540
|
+
if (value.length === 0) return false
|
|
541
|
+
if (CONTINUATION_RE.test(value)) return true
|
|
542
|
+
// A short message that OPENS with a continuation phrase ("go ahead make the plan").
|
|
543
|
+
const words = value.split(/\s+/).filter((word) => word.length > 0)
|
|
544
|
+
return words.length <= 6 && /^(continue|go ahead|go on|proceed|carry on|keep going|yes|yep|ok(ay)?|do it|sure|please (continue|proceed)|继续|好的|可以)\b/i.test(value)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function buildAnalysisUserText(turn, { followUp, previous } = {}) {
|
|
548
|
+
const digest = digestTurn(turn)
|
|
549
|
+
if (digest === null) return null
|
|
550
|
+
const lines = []
|
|
551
|
+
if (previous !== null && typeof previous === 'object') {
|
|
552
|
+
const previousPrompt = typeof previous.prompt === 'string' ? previous.prompt : ''
|
|
553
|
+
const previousAnswer = typeof previous.finalText === 'string' ? previous.finalText : ''
|
|
554
|
+
lines.push('=== PREVIOUS TURN (context the agent already had) ===')
|
|
555
|
+
lines.push('prompt: ' + (previousPrompt.length > 0 ? clipSafe(previousPrompt, 600) : '(none)'))
|
|
556
|
+
lines.push('answer: ' + (previousAnswer.length > 0 ? clipSafe(previousAnswer, 600) : '(none)'))
|
|
557
|
+
lines.push('')
|
|
558
|
+
}
|
|
559
|
+
lines.push(
|
|
560
|
+
'=== ORIGINAL PROMPT (turn ' + digest.turn + ') ===',
|
|
561
|
+
digest.prompt || '(no text)',
|
|
562
|
+
)
|
|
563
|
+
if (looksLikeContinuation(digest.prompt)) {
|
|
564
|
+
lines.push('Note: this prompt is a continuation of the previous turn; judge it with that context, not in isolation.')
|
|
565
|
+
}
|
|
566
|
+
lines.push(
|
|
567
|
+
'',
|
|
568
|
+
'=== TRAJECTORY DIGEST ===',
|
|
569
|
+
'- steps (model calls): ' + digest.steps,
|
|
570
|
+
'- tool calls: ' + digest.toolCalls.length,
|
|
571
|
+
'- tool errors: ' + digest.toolErrors,
|
|
572
|
+
'- retries: ' + digest.retries,
|
|
573
|
+
'- compactions: ' + digest.compactions,
|
|
574
|
+
'- tokens: input ' + digest.usage.inputTokens
|
|
575
|
+
+ ', output ' + digest.usage.outputTokens
|
|
576
|
+
+ ', reasoning ' + digest.usage.reasoningTokens
|
|
577
|
+
+ ', cacheRead ' + digest.usage.cacheReadTokens,
|
|
578
|
+
'- model: ' + (digest.model || '(unknown)'),
|
|
579
|
+
)
|
|
580
|
+
if (digest.toolCalls.length > 0) {
|
|
581
|
+
lines.push('', '--- tool calls (name + argument preview) ---')
|
|
582
|
+
for (const call of digest.toolCalls) {
|
|
583
|
+
lines.push('- ' + call.name + ': ' + call.args)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
lines.push('', '=== FINAL RESPONSE EXCERPT ===')
|
|
587
|
+
lines.push(digest.finalText || '(none)')
|
|
588
|
+
if (typeof followUp === 'string' && followUp.trim().length > 0) {
|
|
589
|
+
lines.push('', "=== USER'S NEXT MESSAGE (sent right after this answer — likely a correction) ===")
|
|
590
|
+
lines.push(clipSafe(followUp.trim(), 1000))
|
|
591
|
+
}
|
|
592
|
+
return lines.join('\n')
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function buildImproveUserText({ draft, profile, recentContext, styleRules, negativeFeedback }) {
|
|
596
|
+
const patterns = Array.isArray(profile?.patterns) ? profile.patterns : []
|
|
597
|
+
const lines = []
|
|
598
|
+
const rules = Array.isArray(styleRules) ? styleRules.filter((rule) => rule !== null && typeof rule === 'object' && typeof rule.rule === 'string' && rule.rule.length > 0) : []
|
|
599
|
+
if (rules.length > 0) {
|
|
600
|
+
lines.push('=== STYLE RULES (learned from your feedback — follow these) ===')
|
|
601
|
+
for (const entry of rules) lines.push('- ' + entry.rule.slice(0, 300))
|
|
602
|
+
lines.push('')
|
|
603
|
+
}
|
|
604
|
+
const negatives = Array.isArray(negativeFeedback) ? negativeFeedback.filter((reason) => typeof reason === 'string' && reason.length > 0).slice(0, 3) : []
|
|
605
|
+
if (negatives.length > 0) {
|
|
606
|
+
lines.push('=== NEGATIVE FEEDBACK (verbatim — do not repeat these mistakes) ===')
|
|
607
|
+
negatives.forEach((reason, index) => {
|
|
608
|
+
lines.push(index === 0
|
|
609
|
+
? '- your last suggestion was rejected because: ' + reason.slice(0, MAX_FEEDBACK_REASON_CHARS)
|
|
610
|
+
: '- an earlier suggestion was rejected because: ' + reason.slice(0, MAX_FEEDBACK_REASON_CHARS))
|
|
611
|
+
})
|
|
612
|
+
lines.push('')
|
|
613
|
+
}
|
|
614
|
+
if (patterns.length > 0) {
|
|
615
|
+
lines.push('=== RECURRING MISTAKE PATTERNS LEARNED FROM PAST ANALYSES ===')
|
|
616
|
+
for (const pattern of patterns.slice(0, 12)) {
|
|
617
|
+
lines.push('- ' + (typeof pattern.kind === 'string' ? pattern.kind : 'general')
|
|
618
|
+
+ ' (seen ' + (typeof pattern.count === 'number' ? pattern.count : 0) + 'x): '
|
|
619
|
+
+ (typeof pattern.lastExample === 'string' ? pattern.lastExample.slice(0, 200) : ''))
|
|
620
|
+
}
|
|
621
|
+
lines.push('')
|
|
622
|
+
}
|
|
623
|
+
if (typeof recentContext === 'string' && recentContext.length > 0) {
|
|
624
|
+
lines.push('=== RECENT CONVERSATION CONTEXT ===')
|
|
625
|
+
lines.push(recentContext.slice(0, 1500))
|
|
626
|
+
lines.push('')
|
|
627
|
+
}
|
|
628
|
+
lines.push('=== DRAFT TO IMPROVE ===')
|
|
629
|
+
lines.push(draft)
|
|
630
|
+
return lines.join('\n')
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Strip markdown fences and parse the first {...} JSON object in the text. */
|
|
634
|
+
export function parseJsonObject(text) {
|
|
635
|
+
if (typeof text !== 'string') return null
|
|
636
|
+
let value = text.trim()
|
|
637
|
+
const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(value)
|
|
638
|
+
if (fence !== null) value = fence[1].trim()
|
|
639
|
+
const start = value.indexOf('{')
|
|
640
|
+
const end = value.lastIndexOf('}')
|
|
641
|
+
if (start === -1 || end <= start) return null
|
|
642
|
+
try {
|
|
643
|
+
const parsed = JSON.parse(value.slice(start, end + 1))
|
|
644
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
645
|
+
} catch {
|
|
646
|
+
return null
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const clipText = (value, max) => {
|
|
651
|
+
const text = typeof value === 'string' ? value : ''
|
|
652
|
+
return text.length <= max ? text : text.slice(0, max)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Shape a parsed analysis object into a report (falls back gracefully). */
|
|
656
|
+
export function normalizeReport(parsed, { turn, time, model, rawText }) {
|
|
657
|
+
if (parsed === null) {
|
|
658
|
+
return {
|
|
659
|
+
ok: true,
|
|
660
|
+
turn,
|
|
661
|
+
time,
|
|
662
|
+
model,
|
|
663
|
+
problems: [{
|
|
664
|
+
kind: 'notes',
|
|
665
|
+
severity: 'info',
|
|
666
|
+
what: clipText(rawText, 4000),
|
|
667
|
+
why: '',
|
|
668
|
+
}],
|
|
669
|
+
improvedPrompt: '',
|
|
670
|
+
explanation: '',
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const result = analysisReportShape.safeParse(parsed)
|
|
674
|
+
const shaped = result.success ? result.data : { problems: [], improvedPrompt: '', explanation: '' }
|
|
675
|
+
const problems = shaped.problems.slice(0, 12).map((problem) => ({
|
|
676
|
+
kind: clipText(problem.kind, 60) || 'general',
|
|
677
|
+
severity: ['high', 'medium', 'low'].includes(String(problem.severity)) ? problem.severity : 'medium',
|
|
678
|
+
what: clipText(problem.what, 600),
|
|
679
|
+
why: clipText(problem.why, 600),
|
|
680
|
+
}))
|
|
681
|
+
return reportSchema.parse({
|
|
682
|
+
ok: true,
|
|
683
|
+
turn,
|
|
684
|
+
time,
|
|
685
|
+
model,
|
|
686
|
+
problems,
|
|
687
|
+
improvedPrompt: clipText(shaped.improvedPrompt, 8000),
|
|
688
|
+
explanation: clipText(shaped.explanation, 2000),
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export function normalizeImprove(parsed, draft) {
|
|
693
|
+
const fallback = { improved: draft, rationale: '' }
|
|
694
|
+
if (parsed === null) return fallback
|
|
695
|
+
const result = improveShape.safeParse(parsed)
|
|
696
|
+
const shaped = result.success ? result.data : fallback
|
|
697
|
+
const improved = clipText(shaped.improved, 20000).trim()
|
|
698
|
+
return {
|
|
699
|
+
improved: improved.length > 0 ? improved : draft,
|
|
700
|
+
rationale: clipText(shaped.rationale, 1000),
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const normalizeKind = (value) => {
|
|
705
|
+
const kind = String(value ?? '').trim().toLowerCase().replace(/\s+/g, '-').slice(0, 40)
|
|
706
|
+
return kind.length > 0 ? kind : 'general'
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Deterministic aggregation of one finished report into the mistake profile:
|
|
711
|
+
* pattern counts merge by normalized kind, lastExample keeps the newest
|
|
712
|
+
* text; v2 trust/feedback fields (counters, styleRules,
|
|
713
|
+
* feedbackLog, pendingDistill) are carried over untouched — analysis never
|
|
714
|
+
* erases what the self-improving loop learned. `analyzedCount` (the learning
|
|
715
|
+
* gate) increments only for a NEWLY analyzed turn (`countNew`), so
|
|
716
|
+
* re-coaching the same prompt never double counts. No second model call.
|
|
717
|
+
*/
|
|
718
|
+
export function aggregateProfile(prev, report, maxPatterns, options = {}) {
|
|
719
|
+
const countNew = options.countNew !== false
|
|
720
|
+
const patterns = new Map()
|
|
721
|
+
for (const pattern of Array.isArray(prev?.patterns) ? prev.patterns : []) {
|
|
722
|
+
if (pattern !== null && typeof pattern === 'object' && typeof pattern.kind === 'string') {
|
|
723
|
+
patterns.set(normalizeKind(pattern.kind), {
|
|
724
|
+
kind: normalizeKind(pattern.kind),
|
|
725
|
+
count: typeof pattern.count === 'number' && pattern.count > 0 ? pattern.count : 0,
|
|
726
|
+
lastExample: typeof pattern.lastExample === 'string' ? pattern.lastExample : '',
|
|
727
|
+
applied: counterOf(pattern, 'applied'),
|
|
728
|
+
accepted: counterOf(pattern, 'accepted'),
|
|
729
|
+
rejected: counterOf(pattern, 'rejected'),
|
|
730
|
+
verified: counterOf(pattern, 'verified'),
|
|
731
|
+
unverified: counterOf(pattern, 'unverified'),
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const problems = Array.isArray(report?.problems) ? report.problems : []
|
|
736
|
+
for (const problem of problems) {
|
|
737
|
+
if (problem === null || typeof problem !== 'object') continue
|
|
738
|
+
const kind = normalizeKind(problem.kind)
|
|
739
|
+
const current = patterns.get(kind) ?? {
|
|
740
|
+
kind,
|
|
741
|
+
count: 0,
|
|
742
|
+
lastExample: '',
|
|
743
|
+
applied: 0,
|
|
744
|
+
accepted: 0,
|
|
745
|
+
rejected: 0,
|
|
746
|
+
verified: 0,
|
|
747
|
+
unverified: 0,
|
|
748
|
+
}
|
|
749
|
+
current.count += 1
|
|
750
|
+
if (typeof problem.what === 'string' && problem.what.length > 0) current.lastExample = problem.what.slice(0, 200)
|
|
751
|
+
patterns.set(kind, current)
|
|
752
|
+
}
|
|
753
|
+
const sorted = [...patterns.values()].sort((a, b) => b.count - a.count).slice(0, maxPatterns)
|
|
754
|
+
return profileSchema.parse({
|
|
755
|
+
analyzedCount: (typeof prev?.analyzedCount === 'number' ? prev.analyzedCount : 0) + (countNew ? 1 : 0),
|
|
756
|
+
patterns: sorted,
|
|
757
|
+
updatedAt: Date.now(),
|
|
758
|
+
styleRules: Array.isArray(prev?.styleRules) ? prev.styleRules : [],
|
|
759
|
+
feedbackLog: Array.isArray(prev?.feedbackLog) ? prev.feedbackLog : [],
|
|
760
|
+
pendingDistill: typeof prev?.pendingDistill === 'number' && prev.pendingDistill >= 0 ? Math.round(prev.pendingDistill) : 0,
|
|
761
|
+
directives: Array.isArray(prev?.directives) ? prev.directives : [],
|
|
762
|
+
analysesSinceDirectives: typeof prev?.analysesSinceDirectives === 'number' && prev.analysesSinceDirectives >= 0 ? Math.round(prev.analysesSinceDirectives) : 0,
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ── Trust & selection (v2 self-improving loop) ─────────────────────────────
|
|
767
|
+
|
|
768
|
+
const counterOf = (pattern, key) => {
|
|
769
|
+
const value = pattern !== null && typeof pattern === 'object' ? pattern[key] : undefined
|
|
770
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.round(value) : 0
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Pure trust score for one pattern: the acceptance/verification weighted
|
|
775
|
+
* ratio minus the rejection/unverification ratio, normalized by applied+1
|
|
776
|
+
* so a pattern with no samples is neutral (0), never divide-by-zero.
|
|
777
|
+
*
|
|
778
|
+
* trust = ((accepted + 2·verified) − (rejected + unverified)) / (applied + 1)
|
|
779
|
+
*/
|
|
780
|
+
export function trustScore(pattern) {
|
|
781
|
+
if (pattern === null || typeof pattern !== 'object') return 0
|
|
782
|
+
const applied = counterOf(pattern, 'applied')
|
|
783
|
+
const accepted = counterOf(pattern, 'accepted')
|
|
784
|
+
const rejected = counterOf(pattern, 'rejected')
|
|
785
|
+
const verified = counterOf(pattern, 'verified')
|
|
786
|
+
const unverified = counterOf(pattern, 'unverified')
|
|
787
|
+
if (applied + accepted + rejected + verified + unverified === 0) return 0
|
|
788
|
+
return ((accepted + 2 * verified) - (rejected + unverified)) / (applied + 1)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* The patterns offered to the improve prompt. Deterministic top-k:
|
|
793
|
+
* - patterns with >= TRUST_MIN_APPLIED applied samples only stay when their
|
|
794
|
+
* trust is > 0 ("trusted" — the coach stops repeating advice that failed);
|
|
795
|
+
* - patterns with fewer applied samples are still inexperienced and rank by
|
|
796
|
+
* count, exactly as before the loop existed.
|
|
797
|
+
* Trusted patterns come first (by trust desc), then rookies (by count desc).
|
|
798
|
+
*/
|
|
799
|
+
export function improvePatterns(profile, maxPatterns) {
|
|
800
|
+
const patterns = Array.isArray(profile?.patterns) ? profile.patterns : []
|
|
801
|
+
const cap = Number.isFinite(maxPatterns) && maxPatterns > 0 ? Math.round(maxPatterns) : 12
|
|
802
|
+
const rookies = []
|
|
803
|
+
const seasoned = []
|
|
804
|
+
for (const pattern of patterns) {
|
|
805
|
+
if (pattern === null || typeof pattern !== 'object' || typeof pattern.kind !== 'string') continue
|
|
806
|
+
if (counterOf(pattern, 'applied') < TRUST_MIN_APPLIED) rookies.push(pattern)
|
|
807
|
+
else seasoned.push(pattern)
|
|
808
|
+
}
|
|
809
|
+
const trusted = seasoned
|
|
810
|
+
.filter((pattern) => trustScore(pattern) > 0)
|
|
811
|
+
.sort((a, b) => trustScore(b) - trustScore(a) || (b.count ?? 0) - (a.count ?? 0))
|
|
812
|
+
const byCount = (a, b) => (b.count ?? 0) - (a.count ?? 0)
|
|
813
|
+
return [...trusted, ...rookies.sort(byCount)].slice(0, cap)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** The last `n` verbatim down-reasons (newest first), each clipped to 300 chars. */
|
|
817
|
+
export function lastDownReasons(profile, n = 3) {
|
|
818
|
+
const log = Array.isArray(profile?.feedbackLog) ? profile.feedbackLog : []
|
|
819
|
+
const downs = log.filter((entry) => (
|
|
820
|
+
entry !== null && typeof entry === 'object'
|
|
821
|
+
&& entry.verdict === 'down' && typeof entry.reason === 'string' && entry.reason.length > 0
|
|
822
|
+
))
|
|
823
|
+
return downs.slice(-n).reverse().map((entry) => entry.reason.slice(0, MAX_FEEDBACK_REASON_CHARS))
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Call the coach model through the harness's own LLM waterfall (the harness
|
|
828
|
+
* resolves the user's configured DeepSeek API key — never this plugin).
|
|
829
|
+
*
|
|
830
|
+
* Cheap and structured: low reasoning effort, and when a `tool` schema is
|
|
831
|
+
* given the model answers by calling it — the tool-call arguments (raw JSON)
|
|
832
|
+
* are returned. Plain text is the fallback channel. Reasoning deltas are
|
|
833
|
+
* NEVER returned as the answer (chain of thought is not a report). A
|
|
834
|
+
* deployment that rejects the reasoning effort gets one retry without it.
|
|
835
|
+
* Returns the answer text ('' when the model produced nothing usable).
|
|
836
|
+
*/
|
|
837
|
+
export async function callCoachModel(ctx, { provider, model, system, userText, maxTokens, timeoutMs, tool, sessionId, reasoningEffort = COACH_REASONING_EFFORT }) {
|
|
838
|
+
const llm = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
|
|
839
|
+
if (llm === undefined || typeof llm.stream !== 'function') {
|
|
840
|
+
const error = new Error('the harness LLM service is unavailable')
|
|
841
|
+
error.code = 'no-llm'
|
|
842
|
+
throw error
|
|
843
|
+
}
|
|
844
|
+
const controller = new AbortController()
|
|
845
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
846
|
+
const message = createUserMessage({
|
|
847
|
+
content: [{ type: 'text', text: userText }],
|
|
848
|
+
source: { kind: 'plugin', plugin: 'dsh-tacit' },
|
|
849
|
+
})
|
|
850
|
+
const run = async (effort) => {
|
|
851
|
+
let text = ''
|
|
852
|
+
let toolArgs = ''
|
|
853
|
+
let toolDeltas = ''
|
|
854
|
+
let finish = ''
|
|
855
|
+
for await (const chunk of llm.stream({
|
|
856
|
+
provider,
|
|
857
|
+
model,
|
|
858
|
+
messages: [message],
|
|
859
|
+
system,
|
|
860
|
+
maxTokens,
|
|
861
|
+
signal: controller.signal,
|
|
862
|
+
...(effort !== undefined ? { reasoningEffort: effort } : {}),
|
|
863
|
+
...(tool !== undefined ? { tools: [tool] } : {}),
|
|
864
|
+
...(typeof sessionId === 'string' && sessionId.length > 0 ? { sessionId } : {}),
|
|
865
|
+
})) {
|
|
866
|
+
if (chunk === null || typeof chunk !== 'object') continue
|
|
867
|
+
if (chunk.type === 'finish' && typeof chunk.reason === 'string') finish = chunk.reason
|
|
868
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
869
|
+
else if (chunk.type === 'tool-call-delta' && typeof chunk.argumentsDelta === 'string') toolDeltas += chunk.argumentsDelta
|
|
870
|
+
else if (chunk.type === 'block-end' && chunk.block !== null && typeof chunk.block === 'object'
|
|
871
|
+
&& chunk.block.type === 'tool-call' && typeof chunk.block.arguments === 'string' && toolArgs === '') {
|
|
872
|
+
toolArgs = chunk.block.arguments
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (toolArgs.length > 0) return toolArgs
|
|
876
|
+
if (toolDeltas.length > 0) return toolDeltas
|
|
877
|
+
if (text.length === 0 && finish.length > 0 && finish !== 'stop') {
|
|
878
|
+
console.warn('[tacit] model call ended without an answer (finish: ' + finish + ', maxTokens: ' + String(maxTokens) + ')')
|
|
879
|
+
}
|
|
880
|
+
return text
|
|
881
|
+
}
|
|
882
|
+
try {
|
|
883
|
+
try {
|
|
884
|
+
return await run(reasoningEffort)
|
|
885
|
+
} catch (error) {
|
|
886
|
+
const code = error !== null && typeof error === 'object' ? error.code : undefined
|
|
887
|
+
if (reasoningEffort !== undefined && code === 'UNSUPPORTED_REASONING_EFFORT') return await run(undefined)
|
|
888
|
+
throw error
|
|
889
|
+
}
|
|
890
|
+
} finally {
|
|
891
|
+
clearTimeout(timer)
|
|
892
|
+
}
|
|
893
|
+
}
|