dsh-tacit 0.2.2 → 0.3.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/README.md +24 -3
- package/client/client.js +1822 -108
- package/docs/README.md +3 -3
- package/docs/README.zh.md +7 -6
- package/lib/analyze.js +289 -49
- package/lib/index.js +16 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +15 -2
- package/lib/schema.js +187 -1
- package/lib/service.js +542 -103
- package/lib/store.js +164 -3
- package/lib/usage.js +708 -0
- package/package.json +11 -5
package/docs/README.md
CHANGED
|
@@ -9,13 +9,13 @@ Pick the question you have:
|
|
|
9
9
|
|
|
10
10
|
| I want to… | Read |
|
|
11
11
|
| --- | --- |
|
|
12
|
-
| Install it and see it working in five minutes | [Getting started](getting-started.md) |
|
|
12
|
+
| Install it and see it working in five minutes | [Getting started](getting-started.md) ([中文](getting-started.zh.md)) |
|
|
13
13
|
| Understand what it does, step by step, and what the words mean | [How it works](how-it-works.md) |
|
|
14
14
|
| Know what leaves my machine, what it costs, and what it can't do yet | [Privacy, cost & limitations](privacy-and-cost.md) |
|
|
15
15
|
| Change a setting | [Configuration](configuration.md) |
|
|
16
|
-
| Read or change the code | [Architecture](architecture.md) · [Contributing](../CONTRIBUTING.md) |
|
|
16
|
+
| Read or change the code | [Architecture](architecture.md) · [Contributing](../CONTRIBUTING.md) ([中文](CONTRIBUTING.zh.md)) |
|
|
17
17
|
| See what changed between versions | [Changelog](../CHANGELOG.md) |
|
|
18
18
|
|
|
19
19
|
Reading order for a newcomer: Getting started → How it works → Privacy & cost.
|
|
20
20
|
|
|
21
|
-
中文:[README.zh.md](README.zh.md)
|
|
21
|
+
中文:[README.zh.md](README.zh.md) · [快速开始](getting-started.zh.md) · [贡献指南](CONTRIBUTING.zh.md)(其余文档为英文)。
|
package/docs/README.zh.md
CHANGED
|
@@ -45,7 +45,7 @@ DeepSeek API Key(Tacit 从不读取它)。
|
|
|
45
45
|
| **零点击学习** | 不顺的轮次和你自己的纠正会在后台被分析,并带上上一轮作为上下文;自动分析每天有上限(默认 30 次) | 每次 $0.001–0.003 |
|
|
46
46
|
| **指令要靠表现留下** | 学到的指令作为一小段系统提示注入,你可以查看、编辑、开关或删除;新指令先是*候选*,如果你的不顺轮次比例变差就会退役 | 免费 |
|
|
47
47
|
| **✨ 改进** | 输入框里的按钮,用 Tacit 学到的东西重写当前草稿,带前后对比预览和 👍/👎 | 每次点击 $0.001–0.002 |
|
|
48
|
-
| **测量而非猜测** | 设置页显示你的真实趋势:不顺轮次比例和每轮 token 数,最早 20 轮 vs 最近 20
|
|
48
|
+
| **测量而非猜测** | 设置页显示你的真实趋势:不顺轮次比例和每轮 token 数,最早 20 轮 vs 最近 20 轮——以及 Tacit 自己的花费:每次调用都被计量并按目录价定价,显示在设置页里 | 免费 |
|
|
49
49
|
|
|
50
50
|
## 它是怎么工作的
|
|
51
51
|
|
|
@@ -63,20 +63,21 @@ DeepSeek API Key(Tacit 从不读取它)。
|
|
|
63
63
|
|
|
64
64
|
Tacit 从不接触你的 API Key(所有调用都经过 harness 自己的模型服务),只通过你会话
|
|
65
65
|
自己的 provider 路由调用白名单里的官方模型,报告和指令都存放在
|
|
66
|
-
`~/.dsh/storages/tacit
|
|
67
|
-
|
|
66
|
+
`~/.dsh/storages/tacit/`,拒绝对自身路由的跨站请求,除了自己的报告和过期的用量记录
|
|
67
|
+
之外从不删除任何东西。美元数字是按公开价格的估算;`dsh-cost-meter` 这样的成本插件会显示真实花费。
|
|
68
68
|
完整的数据流与成本表、以及坦白的限制清单(英文):
|
|
69
69
|
[Privacy, cost & limitations](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/privacy-and-cost.md)。
|
|
70
70
|
|
|
71
|
-
##
|
|
71
|
+
## 文档(除标注外均为英文)
|
|
72
72
|
|
|
73
73
|
| | |
|
|
74
74
|
| --- | --- |
|
|
75
|
-
| [
|
|
75
|
+
| [快速开始](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/getting-started.zh.md)([English](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/getting-started.md)) | 安装、确认已启用、快速起步、界面各处在哪、排障 |
|
|
76
76
|
| [How it works](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/how-it-works.md) | 逐步流程,带图和术语表 |
|
|
77
77
|
| [Privacy, cost & limitations](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/privacy-and-cost.md) | 什么留在本地、什么会发出去、每次调用的花费、目前做不到的事 |
|
|
78
78
|
| [Configuration](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/configuration.md) | 所有设置项、默认值和取值范围 |
|
|
79
79
|
| [Architecture](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/architecture.md) | 面向贡献者:模块、钩子、路由、存储 |
|
|
80
|
-
| [
|
|
80
|
+
| [贡献指南](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/CONTRIBUTING.zh.md)([English](https://github.com/hackernotfound/dsh-tacit/blob/main/CONTRIBUTING.md)) | fork → 分支 → PR 的流程、测试、基本规则;欢迎提 issue 和 PR |
|
|
81
|
+
| [Changelog](https://github.com/hackernotfound/dsh-tacit/blob/main/CHANGELOG.md) | 各版本的变更 |
|
|
81
82
|
|
|
82
83
|
MIT © hackernotfound
|
package/lib/analyze.js
CHANGED
|
@@ -40,6 +40,25 @@ export function clipSafe(value, max) {
|
|
|
40
40
|
return text.slice(0, end)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Clip a directive to `max` characters without ever cutting mid-word: prefer
|
|
45
|
+
* the last sentence end at or after `min`, else the last space, and mark a
|
|
46
|
+
* mid-sentence cut with an ellipsis. Text within the limit is returned as is.
|
|
47
|
+
*/
|
|
48
|
+
export function clipDirective(value, max = DIRECTIVE_MAX_CHARS, min = 60) {
|
|
49
|
+
const text = (typeof value === 'string' ? value : '').trim()
|
|
50
|
+
if (text.length <= max) return text
|
|
51
|
+
const head = clipSafe(text, max)
|
|
52
|
+
const sentenceEnd = Math.max(...['. ', '! ', '? ', '; ', '。', '!', '?', ';'].map((mark) => {
|
|
53
|
+
const at = head.lastIndexOf(mark)
|
|
54
|
+
return at >= min ? at + mark.trim().length : -1
|
|
55
|
+
}))
|
|
56
|
+
if (sentenceEnd > 0) return head.slice(0, sentenceEnd).trim()
|
|
57
|
+
const space = head.lastIndexOf(' ')
|
|
58
|
+
const cut = space >= min ? head.slice(0, space) : clipSafe(head, max - 1)
|
|
59
|
+
return cut.replace(/[\s,;:–—-]+$/u, '') + '…'
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
// ── Structured output: the model answers by CALLING one tool whose arguments
|
|
44
63
|
// are the payload (the harness has no JSON mode; tool arguments are the
|
|
45
64
|
// reliable structured channel). Text JSON is still accepted as a fallback.
|
|
@@ -71,11 +90,11 @@ export const ANALYSIS_TOOL = {
|
|
|
71
90
|
|
|
72
91
|
export const IMPROVE_TOOL = {
|
|
73
92
|
name: 'improved',
|
|
74
|
-
description: 'Submit the rewritten draft.',
|
|
93
|
+
description: 'Submit the final rewritten draft, or the draft verbatim when it is already complete.',
|
|
75
94
|
parameters: {
|
|
76
95
|
type: 'object',
|
|
77
96
|
properties: {
|
|
78
|
-
improved: { type: 'string', description: 'the
|
|
97
|
+
improved: { type: 'string', description: 'the final prompt (the draft verbatim if already complete)' },
|
|
79
98
|
rationale: { type: 'string', description: '1-2 sentences on what changed and why' },
|
|
80
99
|
},
|
|
81
100
|
required: ['improved', 'rationale'],
|
|
@@ -88,11 +107,31 @@ export const DIRECTIVE_TOOL = {
|
|
|
88
107
|
parameters: {
|
|
89
108
|
type: 'object',
|
|
90
109
|
properties: {
|
|
91
|
-
directives: {
|
|
110
|
+
directives: {
|
|
111
|
+
type: 'array',
|
|
112
|
+
minItems: 1,
|
|
113
|
+
maxItems: 4,
|
|
114
|
+
items: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
text: { type: 'string', maxLength: 220, description: 'one sentence, at most 25 words' },
|
|
118
|
+
workspace: { type: 'string', description: 'only when the habit shows up in exactly one workspace: that workspace name as written in the evidence' },
|
|
119
|
+
},
|
|
120
|
+
required: ['text'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
92
123
|
},
|
|
93
124
|
required: ['directives'],
|
|
94
125
|
},
|
|
95
126
|
}
|
|
127
|
+
export const MAX_WORKSPACE_DIRECTIVES = 4
|
|
128
|
+
|
|
129
|
+
/** The last path segment of a workspace directory — what a person calls the project. */
|
|
130
|
+
export function workspaceLabel(cwd) {
|
|
131
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return ''
|
|
132
|
+
const parts = cwd.split(/[\\/]+/).filter((part) => part.length > 0)
|
|
133
|
+
return parts.length > 0 ? parts[parts.length - 1] : ''
|
|
134
|
+
}
|
|
96
135
|
export const DIRECTIVE_MAX_TOKENS = 1500
|
|
97
136
|
export const DIRECTIVE_TIMEOUT_MS = 30000
|
|
98
137
|
export const MAX_DIRECTIVES = 8
|
|
@@ -137,7 +176,7 @@ export function buildEnrichUserText({ draft, profile, recentContext }) {
|
|
|
137
176
|
.filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && typeof entry.text === 'string')
|
|
138
177
|
if (directives.length > 0) {
|
|
139
178
|
lines.push('=== WHAT THE COACH KNOWS ABOUT THIS USER ===')
|
|
140
|
-
for (const entry of directives.slice(0, MAX_DIRECTIVES)) lines.push('- ' +
|
|
179
|
+
for (const entry of directives.slice(0, MAX_DIRECTIVES)) lines.push('- ' + clipDirective(entry.text))
|
|
141
180
|
lines.push('')
|
|
142
181
|
}
|
|
143
182
|
const patterns = Array.isArray(profile?.patterns) ? profile.patterns.slice(0, 6) : []
|
|
@@ -226,6 +265,47 @@ const improveShape = z.object({
|
|
|
226
265
|
rationale: z.string().default(''),
|
|
227
266
|
})
|
|
228
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Learning from a recovery: the turn went cleanly right after a messy one in
|
|
270
|
+
* the same conversation. One small call names what the user supplied this
|
|
271
|
+
* time, in the same problem taxonomy, so the distiller knows what this user
|
|
272
|
+
* CAN state when reminded.
|
|
273
|
+
*/
|
|
274
|
+
export const GOOD_TOOL = {
|
|
275
|
+
name: 'report',
|
|
276
|
+
description: 'What the clean prompt supplied that the previous, messy one lacked.',
|
|
277
|
+
parameters: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
properties: {
|
|
280
|
+
strengths: {
|
|
281
|
+
type: 'array',
|
|
282
|
+
maxItems: 4,
|
|
283
|
+
items: {
|
|
284
|
+
type: 'object',
|
|
285
|
+
properties: {
|
|
286
|
+
kind: { type: 'string', description: 'same categories as problems: missing-constraints|ambiguous-goal|missing-context|wrong-scope|...' },
|
|
287
|
+
what: { type: 'string', description: 'one sentence: what the prompt included this time' },
|
|
288
|
+
},
|
|
289
|
+
required: ['kind', 'what'],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
lesson: { type: 'string', description: 'one sentence, about the user: what they included the second time that made the difference' },
|
|
293
|
+
},
|
|
294
|
+
required: ['strengths', 'lesson'],
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
export const GOOD_SYSTEM_PROMPT = [
|
|
298
|
+
'You coach prompt writing inside DeepSeek Harness. The previous turn of this',
|
|
299
|
+
'conversation went badly (retries, tool errors, a correction).',
|
|
300
|
+
'This turn went well. Compare the two prompts and the trajectory: what did the user',
|
|
301
|
+
'INCLUDE this time that the earlier prompt lacked — a file path, a constraint,',
|
|
302
|
+
'a scope, an acceptance criterion, an example? Report 1-4 strengths in the same',
|
|
303
|
+
'categories used for problems, and ONE sentence about the user ("They fix',
|
|
304
|
+
'wandering by naming the target file up front."). If the clean turn was',
|
|
305
|
+
'trivially easy or merely a continuation, return an empty strengths list and',
|
|
306
|
+
'an empty lesson. Never praise; state facts. Reply in the language of the prompt.',
|
|
307
|
+
].join('\n')
|
|
308
|
+
|
|
229
309
|
export const ANALYSIS_SYSTEM_PROMPT = [
|
|
230
310
|
'You are a strict but friendly prompt-engineering coach inside DeepSeek Harness.',
|
|
231
311
|
'You are given ONE past user prompt plus a digest of everything that happened',
|
|
@@ -267,18 +347,52 @@ export const ANALYSIS_SYSTEM_PROMPT = [
|
|
|
267
347
|
|
|
268
348
|
export const IMPROVE_SYSTEM_PROMPT = [
|
|
269
349
|
'You are a prompt-improvement assistant inside DeepSeek Harness.',
|
|
270
|
-
'The user typed a draft prompt into the composer.
|
|
271
|
-
'
|
|
272
|
-
'
|
|
273
|
-
'
|
|
274
|
-
'
|
|
350
|
+
'The user typed a draft prompt for a coding agent into the composer. You get',
|
|
351
|
+
'ONE pass: return the prompt the user would arrive at after several rounds of',
|
|
352
|
+
'editing. Leave nothing for a second pass.',
|
|
353
|
+
'',
|
|
354
|
+
'A finished prompt has every item below (skip an item only when the draft or',
|
|
355
|
+
'context makes it obviously unnecessary):',
|
|
356
|
+
'- GOAL — one sentence: the outcome and what "done" looks like.',
|
|
357
|
+
'- CONTEXT — the concrete facts the agent would otherwise have to discover:',
|
|
358
|
+
' file paths, URLs, names, versions, decisions already made. Take them from',
|
|
359
|
+
' the draft and from RECENT CONVERSATION CONTEXT. Never invent facts.',
|
|
360
|
+
'- SCOPE — what is in and out; what to leave untouched.',
|
|
361
|
+
'- CONSTRAINTS — what not to do, limits, style, language, budget.',
|
|
362
|
+
'- OUTPUT FORMAT — the shape of the answer: list, table, diff, code only,',
|
|
363
|
+
' prioritized, length.',
|
|
364
|
+
'- EFFICIENCY — what the agent need not explore or verify, so it finishes',
|
|
365
|
+
' in fewer steps and tool calls.',
|
|
366
|
+
'',
|
|
367
|
+
'Rules:',
|
|
368
|
+
'- Preserve the user\'s intent; add nothing they did not ask for or clearly',
|
|
369
|
+
' imply.',
|
|
370
|
+
'- Only fill what is genuinely underspecified. Keep the draft\'s wording where',
|
|
371
|
+
' it already works.',
|
|
372
|
+
'- Be as short as completeness allows: no filler, no role preambles ("You are',
|
|
373
|
+
' an expert…"), no restating what the agent already knows.',
|
|
374
|
+
'- Follow the STYLE RULES, NEGATIVE FEEDBACK and RECURRING MISTAKE PATTERNS',
|
|
375
|
+
' when provided; they describe this user\'s habits.',
|
|
376
|
+
'- Silently check the draft against every item first, then write the whole',
|
|
377
|
+
' prompt once.',
|
|
378
|
+
'- FIXED POINT: if the draft already satisfies every item, return it',
|
|
379
|
+
' VERBATIM (character for character) with the rationale "Already complete."',
|
|
380
|
+
' Never make cosmetic edits — a prompt you improved must come back',
|
|
381
|
+
' unchanged when improved again.',
|
|
382
|
+
'',
|
|
383
|
+
'Example',
|
|
384
|
+
'draft: what do you think we can do to market our plugin today',
|
|
385
|
+
'context: the plugin was just published at https://github.com/x/y',
|
|
386
|
+
'improved: The plugin is now public at https://github.com/x/y. How do we',
|
|
387
|
+
'market it and get it noticed? Give a prioritized list of concrete actions,',
|
|
388
|
+
'easiest first, biggest impact last, with the expected effort for each.',
|
|
275
389
|
'',
|
|
276
390
|
'RESPONSE FORMAT — this is mandatory and machine-parsed:',
|
|
277
391
|
'Your ENTIRE response must be ONE JSON object and nothing else. No preamble,',
|
|
278
392
|
'no narration, no explanations outside the JSON, no markdown fences.',
|
|
279
393
|
'Start directly with "{" and end with "}".',
|
|
280
394
|
'{',
|
|
281
|
-
' "improved": "<the
|
|
395
|
+
' "improved": "<the final prompt — the draft verbatim if already complete>",',
|
|
282
396
|
' "rationale": "<1-2 sentences on what you changed and why>"',
|
|
283
397
|
'}',
|
|
284
398
|
'',
|
|
@@ -354,13 +468,23 @@ export const DIRECTIVE_SYSTEM_PROMPT = [
|
|
|
354
468
|
'"continue" — that is fine; the conversation is the context).',
|
|
355
469
|
'Directives must GENERALIZE across future tasks: never mention a specific',
|
|
356
470
|
'task, file, feature, number, or test from one past prompt — describe the',
|
|
357
|
-
'habit and the compensation.
|
|
358
|
-
'
|
|
471
|
+
'habit and the compensation. One exception: when every piece of evidence for',
|
|
472
|
+
'a habit carries the same [workspace: name] tag, return that directive with',
|
|
473
|
+
'"workspace" set to exactly that name; it may then refer to that project\'s',
|
|
474
|
+
'layout ("check apps/web first"). Leave "workspace" out for everything else.',
|
|
475
|
+
'Prefer the most frequent habits. 2-4 directives,',
|
|
476
|
+
'ONE sentence each of at most 25 words (under 180 characters), imperative',
|
|
477
|
+
'mood, addressed to the agent. A directive that needs a second sentence is two',
|
|
478
|
+
'directives or too specific. You are writing',
|
|
359
479
|
'the COMPLETE new set: keep existing directives that still hold (reworded if',
|
|
360
480
|
'sharper), drop ones that were one-off, add what is missing. No preamble.',
|
|
361
481
|
].join('\n')
|
|
362
482
|
|
|
363
|
-
export function buildDirectiveUserText(profile, recentReports = []) {
|
|
483
|
+
export function buildDirectiveUserText(profile, recentReports = [], { labelOf = workspaceLabel } = {}) {
|
|
484
|
+
const tagOf = (cwd) => {
|
|
485
|
+
const label = typeof cwd === 'string' && cwd.length > 0 ? labelOf(cwd) : ''
|
|
486
|
+
return label.length > 0 ? '[workspace: ' + label + '] ' : ''
|
|
487
|
+
}
|
|
364
488
|
const lines = ['=== RECURRING PROMPT HABITS (kind, times seen, latest example) ===']
|
|
365
489
|
const patterns = Array.isArray(profile?.patterns) ? profile.patterns.slice(0, 12) : []
|
|
366
490
|
if (patterns.length === 0) lines.push('(none yet)')
|
|
@@ -373,9 +497,27 @@ export function buildDirectiveUserText(profile, recentReports = []) {
|
|
|
373
497
|
if (corrections.length > 0) {
|
|
374
498
|
lines.push('', '=== RECENT CORRECTIONS (prompt → what the user said next) ===')
|
|
375
499
|
for (const report of corrections) {
|
|
376
|
-
lines.push('- "' + clipSafe(String(report.promptExcerpt ?? ''), 120) + '" → "' + clipSafe(report.followUp, 200) + '"')
|
|
500
|
+
lines.push('- ' + tagOf(report.cwd) + '"' + clipSafe(String(report.promptExcerpt ?? ''), 120) + '" → "' + clipSafe(report.followUp, 200) + '"')
|
|
377
501
|
}
|
|
378
502
|
}
|
|
503
|
+
const lessons = (Array.isArray(recentReports) ? recentReports : [])
|
|
504
|
+
.filter((report) => typeof report?.lesson === 'string' && report.lesson.length > 0)
|
|
505
|
+
.slice(-5)
|
|
506
|
+
if (lessons.length > 0) {
|
|
507
|
+
lines.push('', '=== WHAT WORKED (a clean prompt right after a messy turn — what the user included this time) ===')
|
|
508
|
+
for (const report of lessons) {
|
|
509
|
+
lines.push('- ' + tagOf(report.cwd) + '"' + clipSafe(String(report.promptExcerpt ?? ''), 120) + '": ' + clipSafe(report.lesson, 300))
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const byWorkspace = new Map()
|
|
513
|
+
for (const report of Array.isArray(recentReports) ? recentReports : []) {
|
|
514
|
+
const label = typeof report?.cwd === 'string' && report.cwd.length > 0 ? labelOf(report.cwd) : ''
|
|
515
|
+
if (label.length > 0) byWorkspace.set(label, (byWorkspace.get(label) ?? 0) + 1)
|
|
516
|
+
}
|
|
517
|
+
if (byWorkspace.size > 0) {
|
|
518
|
+
lines.push('', '=== WORKSPACES IN THE RECENT ANALYSES (name, analyses) ===')
|
|
519
|
+
for (const [label, count] of byWorkspace) lines.push('- ' + label + ' (' + count + ')')
|
|
520
|
+
}
|
|
379
521
|
const rules = Array.isArray(profile?.styleRules) ? profile.styleRules.filter((rule) => typeof rule?.rule === 'string' && rule.rule.length > 0) : []
|
|
380
522
|
if (rules.length > 0) {
|
|
381
523
|
lines.push('', '=== STYLE RULES THE USER CONFIRMED ===')
|
|
@@ -384,9 +526,9 @@ export function buildDirectiveUserText(profile, recentReports = []) {
|
|
|
384
526
|
const existing = Array.isArray(profile?.directives) ? profile.directives.filter((entry) => typeof entry?.text === 'string' && entry.text.length > 0) : []
|
|
385
527
|
if (existing.length > 0) {
|
|
386
528
|
lines.push('', '=== CURRENT DIRECTIVES (keep the ones that still hold) ===')
|
|
387
|
-
for (const entry of existing) lines.push('- ' +
|
|
529
|
+
for (const entry of existing) lines.push('- ' + clipDirective(entry.text) + (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? ' [workspace: ' + labelOf(entry.workspace) + ']' : ''))
|
|
388
530
|
}
|
|
389
|
-
lines.push('', 'Write 2-4 directives for the agent about this user.')
|
|
531
|
+
lines.push('', 'Write 2-4 directives for the agent about this user. A habit the user has', 'shown they can fix themselves is still worth a directive: the agent should', 'compensate for it when it is missing, not ask.')
|
|
390
532
|
return lines.join('\n')
|
|
391
533
|
}
|
|
392
534
|
|
|
@@ -405,30 +547,42 @@ export function classifyDirectives(text) {
|
|
|
405
547
|
const kept = []
|
|
406
548
|
const rejected = []
|
|
407
549
|
for (const item of raw) {
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
550
|
+
// Both shapes are accepted: a bare sentence, or { text, workspace? }.
|
|
551
|
+
const source = typeof item === 'string' ? item : (item !== null && typeof item === 'object' && typeof item.text === 'string' ? item.text : null)
|
|
552
|
+
if (source === null) continue
|
|
553
|
+
const value = clipDirective(source)
|
|
554
|
+
const workspace = typeof item === 'object' && item !== null && typeof item.workspace === 'string' && item.workspace.trim().length > 0
|
|
555
|
+
? clipSafe(item.workspace.trim(), 200)
|
|
556
|
+
: undefined
|
|
557
|
+
const key = (workspace ?? '') + '\n' + value.toLowerCase()
|
|
411
558
|
if (value.length === 0 || seen.has(key)) continue
|
|
412
559
|
seen.add(key)
|
|
413
560
|
if (ASKS_USER_RE.test(value)) {
|
|
414
561
|
rejected.push(value)
|
|
415
562
|
continue
|
|
416
563
|
}
|
|
417
|
-
kept.push(value)
|
|
564
|
+
kept.push(workspace === undefined ? { text: value } : { text: value, workspace })
|
|
418
565
|
if (kept.length >= 4) break
|
|
419
566
|
}
|
|
420
567
|
return { kept, rejected }
|
|
421
568
|
}
|
|
422
569
|
|
|
423
570
|
/**
|
|
424
|
-
* The system-prompt section: what the agent is told about this user
|
|
425
|
-
*
|
|
571
|
+
* The system-prompt section: what the agent is told about this user, plus the
|
|
572
|
+
* ids of the directives that actually made it into the text (the directive
|
|
573
|
+
* cap and the character budget can drop enabled ones). `text` is '' when
|
|
574
|
+
* nothing is enabled (an empty section contributes nothing).
|
|
426
575
|
*/
|
|
427
|
-
export function
|
|
428
|
-
const
|
|
576
|
+
export function buildSteeringSection(profile, { cwd } = {}) {
|
|
577
|
+
const here = typeof cwd === 'string' && cwd.length > 0 ? cwd : null
|
|
578
|
+
const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : null)
|
|
579
|
+
const candidates = (Array.isArray(profile?.directives) ? profile.directives : [])
|
|
429
580
|
.filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && entry.status !== 'retired'
|
|
430
581
|
&& typeof entry.text === 'string' && entry.text.trim().length > 0)
|
|
431
|
-
|
|
582
|
+
.filter((entry) => scopeOf(entry) === null || scopeOf(entry) === here)
|
|
583
|
+
// This workspace's own directives first: they are the more specific ones.
|
|
584
|
+
const enabled = [...candidates.filter((entry) => scopeOf(entry) !== null), ...candidates.filter((entry) => scopeOf(entry) === null)]
|
|
585
|
+
if (enabled.length === 0) return { text: '', ids: [] }
|
|
432
586
|
const header = [
|
|
433
587
|
'## About this user (learned by Tacit from their past prompts)',
|
|
434
588
|
'This user tends to leave the following unsaid. Compensate silently when the',
|
|
@@ -436,14 +590,21 @@ export function renderSteeringSection(profile) {
|
|
|
436
590
|
'the prompt always win over these notes.',
|
|
437
591
|
]
|
|
438
592
|
const lines = [...header]
|
|
593
|
+
const ids = []
|
|
439
594
|
let length = lines.join('\n').length
|
|
440
595
|
for (const entry of enabled.slice(0, MAX_DIRECTIVES)) {
|
|
441
|
-
const line = '- ' +
|
|
596
|
+
const line = '- ' + clipDirective(entry.text)
|
|
442
597
|
if (length + line.length + 1 > STEERING_MAX_CHARS) break
|
|
443
598
|
lines.push(line)
|
|
599
|
+
if (typeof entry.id === 'string') ids.push(entry.id)
|
|
444
600
|
length += line.length + 1
|
|
445
601
|
}
|
|
446
|
-
return lines.length > header.length ? lines.join('\n') : ''
|
|
602
|
+
return lines.length > header.length ? { text: lines.join('\n'), ids } : { text: '', ids: [] }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** The steering section text alone. */
|
|
606
|
+
export function renderSteeringSection(profile, options = {}) {
|
|
607
|
+
return buildSteeringSection(profile, options).text
|
|
447
608
|
}
|
|
448
609
|
|
|
449
610
|
/** Build the distillation user text from verbatim rejected-improvement reasons. */
|
|
@@ -653,6 +814,27 @@ const clipText = (value, max) => {
|
|
|
653
814
|
}
|
|
654
815
|
|
|
655
816
|
/** Shape a parsed analysis object into a report (falls back gracefully). */
|
|
817
|
+
/** Shape a good-prompt call into a report: no problems, the original prompt kept, the lesson as explanation. */
|
|
818
|
+
export function normalizeGoodReport(parsed, { turn, time, model, prompt }) {
|
|
819
|
+
const raw = parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
820
|
+
const strengths = (Array.isArray(raw.strengths) ? raw.strengths : [])
|
|
821
|
+
.filter((item) => item !== null && typeof item === 'object' && typeof item.what === 'string' && item.what.trim().length > 0)
|
|
822
|
+
.slice(0, 4)
|
|
823
|
+
.map((item) => ({ kind: clipText(typeof item.kind === 'string' ? item.kind : 'general', 60) || 'general', what: clipText(item.what, 600) }))
|
|
824
|
+
const lesson = typeof raw.lesson === 'string' ? clipText(raw.lesson.trim(), 300) : ''
|
|
825
|
+
return reportSchema.parse({
|
|
826
|
+
ok: true,
|
|
827
|
+
turn,
|
|
828
|
+
time,
|
|
829
|
+
model,
|
|
830
|
+
problems: [],
|
|
831
|
+
improvedPrompt: typeof prompt === 'string' ? clipText(prompt, 4000) : '',
|
|
832
|
+
explanation: lesson,
|
|
833
|
+
strengths,
|
|
834
|
+
lesson,
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
|
|
656
838
|
export function normalizeReport(parsed, { turn, time, model, rawText }) {
|
|
657
839
|
if (parsed === null) {
|
|
658
840
|
return {
|
|
@@ -729,9 +911,16 @@ export function aggregateProfile(prev, report, maxPatterns, options = {}) {
|
|
|
729
911
|
rejected: counterOf(pattern, 'rejected'),
|
|
730
912
|
verified: counterOf(pattern, 'verified'),
|
|
731
913
|
unverified: counterOf(pattern, 'unverified'),
|
|
914
|
+
resolved: counterOf(pattern, 'resolved'),
|
|
732
915
|
})
|
|
733
916
|
}
|
|
734
917
|
}
|
|
918
|
+
// A good-prompt report says which habits the user overcame on their own this time.
|
|
919
|
+
for (const strength of Array.isArray(report?.strengths) ? report.strengths : []) {
|
|
920
|
+
if (strength === null || typeof strength !== 'object') continue
|
|
921
|
+
const current = patterns.get(normalizeKind(strength.kind))
|
|
922
|
+
if (current !== undefined) current.resolved += 1
|
|
923
|
+
}
|
|
735
924
|
const problems = Array.isArray(report?.problems) ? report.problems : []
|
|
736
925
|
for (const problem of problems) {
|
|
737
926
|
if (problem === null || typeof problem !== 'object') continue
|
|
@@ -745,6 +934,7 @@ export function aggregateProfile(prev, report, maxPatterns, options = {}) {
|
|
|
745
934
|
rejected: 0,
|
|
746
935
|
verified: 0,
|
|
747
936
|
unverified: 0,
|
|
937
|
+
resolved: 0,
|
|
748
938
|
}
|
|
749
939
|
current.count += 1
|
|
750
940
|
if (typeof problem.what === 'string' && problem.what.length > 0) current.lastExample = problem.what.slice(0, 200)
|
|
@@ -833,8 +1023,12 @@ export function lastDownReasons(profile, n = 3) {
|
|
|
833
1023
|
* NEVER returned as the answer (chain of thought is not a report). A
|
|
834
1024
|
* deployment that rejects the reasoning effort gets one retry without it.
|
|
835
1025
|
* Returns the answer text ('' when the model produced nothing usable).
|
|
1026
|
+
*
|
|
1027
|
+
* When `onUsage(record)` is given, it is called once per underlying `run()`
|
|
1028
|
+
* (twice on the reasoning-effort retry) with a usage/cost record. A sink
|
|
1029
|
+
* that throws never fails the model call.
|
|
836
1030
|
*/
|
|
837
|
-
export async function callCoachModel(ctx, { provider, model, system, userText, maxTokens, timeoutMs, tool, sessionId, reasoningEffort = COACH_REASONING_EFFORT }) {
|
|
1031
|
+
export async function callCoachModel(ctx, { provider, model, system, userText, maxTokens, timeoutMs, tool, sessionId, reasoningEffort = COACH_REASONING_EFFORT, onUsage }) {
|
|
838
1032
|
const llm = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
|
|
839
1033
|
if (llm === undefined || typeof llm.stream !== 'function') {
|
|
840
1034
|
const error = new Error('the harness LLM service is unavailable')
|
|
@@ -847,35 +1041,81 @@ export async function callCoachModel(ctx, { provider, model, system, userText, m
|
|
|
847
1041
|
content: [{ type: 'text', text: userText }],
|
|
848
1042
|
source: { kind: 'plugin', plugin: 'dsh-tacit' },
|
|
849
1043
|
})
|
|
1044
|
+
const toCount = (value) => (Number.isFinite(value) && value >= 0 ? value : 0)
|
|
850
1045
|
const run = async (effort) => {
|
|
1046
|
+
const startedAt = Date.now()
|
|
851
1047
|
let text = ''
|
|
852
1048
|
let toolArgs = ''
|
|
853
1049
|
let toolDeltas = ''
|
|
854
|
-
let
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1050
|
+
let finishKind = ''
|
|
1051
|
+
let failure = null
|
|
1052
|
+
let usage = null
|
|
1053
|
+
let thrown = null
|
|
1054
|
+
try {
|
|
1055
|
+
for await (const chunk of llm.stream({
|
|
1056
|
+
provider,
|
|
1057
|
+
model,
|
|
1058
|
+
messages: [message],
|
|
1059
|
+
system,
|
|
1060
|
+
maxTokens,
|
|
1061
|
+
signal: controller.signal,
|
|
1062
|
+
...(effort !== undefined ? { reasoningEffort: effort } : {}),
|
|
1063
|
+
...(tool !== undefined ? { tools: [tool] } : {}),
|
|
1064
|
+
...(typeof sessionId === 'string' && sessionId.length > 0 ? { sessionId } : {}),
|
|
1065
|
+
})) {
|
|
1066
|
+
if (chunk === null || typeof chunk !== 'object') continue
|
|
1067
|
+
if (chunk.type === 'usage' && chunk.usage !== null && typeof chunk.usage === 'object') {
|
|
1068
|
+
usage = {
|
|
1069
|
+
inputTokens: toCount(chunk.usage.inputTokens),
|
|
1070
|
+
outputTokens: toCount(chunk.usage.outputTokens),
|
|
1071
|
+
cacheReadTokens: toCount(chunk.usage.cacheReadTokens),
|
|
1072
|
+
cacheWriteTokens: toCount(chunk.usage.cacheWriteTokens),
|
|
1073
|
+
reasoningTokens: toCount(chunk.usage.reasoningTokens),
|
|
1074
|
+
}
|
|
1075
|
+
} else if (chunk.type === 'finish') {
|
|
1076
|
+
finishKind = typeof chunk.reason === 'string' ? chunk.reason : (chunk.reason?.kind ?? '')
|
|
1077
|
+
failure = chunk.reason?.failure ?? null
|
|
1078
|
+
} else if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
1079
|
+
else if (chunk.type === 'tool-call-delta' && typeof chunk.argumentsDelta === 'string') toolDeltas += chunk.argumentsDelta
|
|
1080
|
+
else if (chunk.type === 'block-end' && chunk.block !== null && typeof chunk.block === 'object'
|
|
1081
|
+
&& chunk.block.type === 'tool-call' && typeof chunk.block.arguments === 'string' && toolArgs === '') {
|
|
1082
|
+
toolArgs = chunk.block.arguments
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (finishKind === 'error' || finishKind === 'aborted') {
|
|
1086
|
+
thrown = Object.assign(new Error(failure?.message ?? finishKind), { code: failure?.code ?? finishKind.toUpperCase() })
|
|
1087
|
+
throw thrown
|
|
1088
|
+
}
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
thrown = thrown ?? error
|
|
1091
|
+
throw error
|
|
1092
|
+
} finally {
|
|
1093
|
+
if (typeof onUsage === 'function') {
|
|
1094
|
+
const code = (thrown !== null && typeof thrown === 'object' ? thrown.code : undefined) ?? failure?.code ?? ''
|
|
1095
|
+
const status = thrown !== null || finishKind === 'error' || finishKind === 'aborted'
|
|
1096
|
+
? 'failed'
|
|
1097
|
+
: (usage === null ? 'unmetered' : 'ok')
|
|
1098
|
+
try {
|
|
1099
|
+
onUsage({
|
|
1100
|
+
startedAt,
|
|
1101
|
+
durationMs: Date.now() - startedAt,
|
|
1102
|
+
model,
|
|
1103
|
+
provider,
|
|
1104
|
+
reasoningEffort: effort ?? null,
|
|
1105
|
+
finish: finishKind,
|
|
1106
|
+
status,
|
|
1107
|
+
code,
|
|
1108
|
+
usage,
|
|
1109
|
+
})
|
|
1110
|
+
} catch {
|
|
1111
|
+
// a sink bug must never fail a model call
|
|
1112
|
+
}
|
|
873
1113
|
}
|
|
874
1114
|
}
|
|
875
1115
|
if (toolArgs.length > 0) return toolArgs
|
|
876
1116
|
if (toolDeltas.length > 0) return toolDeltas
|
|
877
|
-
if (text.length === 0 &&
|
|
878
|
-
console.warn('[tacit] model call ended without an answer (finish: ' +
|
|
1117
|
+
if (text.length === 0 && finishKind.length > 0 && finishKind !== 'stop') {
|
|
1118
|
+
console.warn('[tacit] model call ended without an answer (finish: ' + finishKind + ', maxTokens: ' + String(maxTokens) + ')')
|
|
879
1119
|
}
|
|
880
1120
|
return text
|
|
881
1121
|
}
|
package/lib/index.js
CHANGED
|
@@ -80,7 +80,22 @@ export function apply(ctx, config) {
|
|
|
80
80
|
if (typeof off === 'function') ctx.effect(() => off, 'tacit: pre-step enrichment')
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// One line so an audit can tell from the logs alone that Tacit is loaded
|
|
84
|
+
// and what it is currently injecting.
|
|
85
|
+
try {
|
|
86
|
+
const cfg = effectiveConfig()
|
|
87
|
+
const directives = store.profile().directives
|
|
88
|
+
const count = (status) => directives.filter((entry) => entry.status === status).length
|
|
89
|
+
console.info('[tacit] loaded — directives: ' + count('active') + ' active, ' + count('candidate') + ' candidates, '
|
|
90
|
+
+ count('retired') + ' retired; steering ' + (cfg.steerAgent ? 'on' : 'off') + '; auto-analysis '
|
|
91
|
+
+ (cfg.autoAnalyze ? 'on (cap ' + cfg.autoDailyBudget + '/day)' : 'off'))
|
|
92
|
+
} catch {
|
|
93
|
+
// Logging must never keep the plugin from loading.
|
|
94
|
+
}
|
|
95
|
+
|
|
83
96
|
ctx.effect(() => () => {
|
|
84
|
-
//
|
|
97
|
+
// Reports and the profile are written atomically at call time; only the
|
|
98
|
+
// usage ledger keeps debounced state, so an unload flushes it.
|
|
99
|
+
service.usage.flush()
|
|
85
100
|
}, 'tacit: dispose')
|
|
86
101
|
}
|