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/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)(仅 README 有中文版)。
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
- 东西。美元数字是按公开价格的估算;`dsh-cost-meter` 这样的成本插件会显示真实花费。
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
- | [Getting started](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/getting-started.md) | 安装、确认已启用、快速起步、界面各处在哪、排障 |
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
- | [Contributing](https://github.com/hackernotfound/dsh-tacit/blob/main/CONTRIBUTING.md) · [Changelog](https://github.com/hackernotfound/dsh-tacit/blob/main/CHANGELOG.md) | |
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 rewritten prompt' },
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: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 4 },
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('- ' + clipSafe(entry.text, DIRECTIVE_MAX_CHARS))
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. 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.',
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 rewritten prompt>",',
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. Prefer the most frequent habits. 2-4 directives,',
358
- 'one sentence each, imperative mood, addressed to the agent. You are writing',
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('- ' + clipSafe(entry.text, DIRECTIVE_MAX_CHARS))
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
- if (typeof item !== 'string') continue
409
- const value = clipSafe(item.trim(), DIRECTIVE_MAX_CHARS)
410
- const key = value.toLowerCase()
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
- * '' when nothing is enabled (an empty section contributes nothing).
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 renderSteeringSection(profile) {
428
- const enabled = (Array.isArray(profile?.directives) ? profile.directives : [])
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
- if (enabled.length === 0) return ''
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 = '- ' + clipSafe(entry.text.trim(), DIRECTIVE_MAX_CHARS)
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 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
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 && finish.length > 0 && finish !== 'stop') {
878
- console.warn('[tacit] model call ended without an answer (finish: ' + finish + ', maxTokens: ' + String(maxTokens) + ')')
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
- // Nothing to flush: all writes are atomic and complete at call time.
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
  }