dsh-tacit 0.2.3 → 0.4.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.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,8 +63,8 @@ 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
 
package/lib/analyze.js CHANGED
@@ -90,11 +90,11 @@ export const ANALYSIS_TOOL = {
90
90
 
91
91
  export const IMPROVE_TOOL = {
92
92
  name: 'improved',
93
- description: 'Submit the rewritten draft.',
93
+ description: 'Submit the final rewritten draft, or the draft verbatim when it is already complete.',
94
94
  parameters: {
95
95
  type: 'object',
96
96
  properties: {
97
- improved: { type: 'string', description: 'the rewritten prompt' },
97
+ improved: { type: 'string', description: 'the final prompt (the draft verbatim if already complete)' },
98
98
  rationale: { type: 'string', description: '1-2 sentences on what changed and why' },
99
99
  },
100
100
  required: ['improved', 'rationale'],
@@ -114,6 +114,7 @@ export const DIRECTIVE_TOOL = {
114
114
  items: {
115
115
  type: 'object',
116
116
  properties: {
117
+ id: { type: 'string', description: 'the [id] of the current directive this one keeps or rewords; omit for a genuinely new directive' },
117
118
  text: { type: 'string', maxLength: 220, description: 'one sentence, at most 25 words' },
118
119
  workspace: { type: 'string', description: 'only when the habit shows up in exactly one workspace: that workspace name as written in the evidence' },
119
120
  },
@@ -125,6 +126,67 @@ export const DIRECTIVE_TOOL = {
125
126
  },
126
127
  }
127
128
  export const MAX_WORKSPACE_DIRECTIVES = 4
129
+ /** Retired directives kept on the profile so the distiller is told not to re-propose them. */
130
+ export const MAX_RETIRED = 6
131
+
132
+ /** The workspace a directive is limited to; '' = every conversation. */
133
+ export const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : '')
134
+ const directiveKey = (scope, text) => scope + '\n' + text.trim().toLowerCase()
135
+
136
+ /** At most MAX_DIRECTIVES global and MAX_WORKSPACE_DIRECTIVES per-workspace live directives, plus the last MAX_RETIRED retired ones; order kept. */
137
+ export function capDirectives(list) {
138
+ const retired = list.filter((entry) => entry.status === 'retired')
139
+ const forgotten = new Set(retired.slice(0, Math.max(0, retired.length - MAX_RETIRED)))
140
+ const counts = new Map()
141
+ return list.filter((entry) => {
142
+ if (entry.status === 'retired') return !forgotten.has(entry)
143
+ const scope = scopeOf(entry)
144
+ const n = counts.get(scope) ?? 0
145
+ if (n >= (scope === '' ? MAX_DIRECTIVES : MAX_WORKSPACE_DIRECTIVES)) return false
146
+ counts.set(scope, n + 1)
147
+ return true
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Merge the model's new complete set of directives ({ id?, text, workspace? })
153
+ * into the profile: user entries are untouched; the global distilled set and
154
+ * the distilled set of every workspace the model mentioned are replaced;
155
+ * distilled entries of other workspaces are kept (their evidence was not in
156
+ * this batch), and so are retired ones (the do-not-re-propose list). An item
157
+ * naming an existing id, or repeating an existing text, keeps that entry's
158
+ * identity, state, trial and enabled flag and only updates the text; a
159
+ * retired entry stays exactly as it is; a new one queues for its trial slot.
160
+ */
161
+ export function mergeDirectives(profile, items, { nextId }) {
162
+ const users = profile.directives.filter((entry) => entry.source === 'user')
163
+ const userKeys = new Set(users.map((entry) => directiveKey(scopeOf(entry), entry.text)))
164
+ const prior = profile.directives.filter((entry) => entry.source !== 'user')
165
+ const byId = new Map(prior.map((entry) => [entry.id, entry]))
166
+ const byKey = new Map(prior.map((entry) => [directiveKey(scopeOf(entry), entry.text), entry]))
167
+ const mentioned = new Set([''])
168
+ for (const item of items) mentioned.add(scopeOf(item))
169
+ const distilled = []
170
+ const seen = new Set()
171
+ const matched = new Set()
172
+ for (const item of items) {
173
+ const scope = scopeOf(item)
174
+ const key = directiveKey(scope, item.text)
175
+ if (seen.has(key) || userKeys.has(key)) continue
176
+ seen.add(key)
177
+ const kept = (typeof item.id === 'string' ? byId.get(item.id) : undefined) ?? byKey.get(key)
178
+ if (kept !== undefined) {
179
+ if (kept.status === 'retired' || matched.has(kept.id)) continue
180
+ matched.add(kept.id)
181
+ distilled.push({ ...kept, text: item.text })
182
+ continue
183
+ }
184
+ distilled.push({ id: nextId(), text: item.text, enabled: true, source: 'distilled', createdAt: Date.now(), status: 'queued', ...(scope === '' ? {} : { workspace: scope }) })
185
+ }
186
+ const untouched = prior.filter((entry) => !matched.has(entry.id) && (entry.status === 'retired' || !mentioned.has(scopeOf(entry))))
187
+ profile.directives = capDirectives([...users, ...distilled, ...untouched])
188
+ return profile
189
+ }
128
190
 
129
191
  /** The last path segment of a workspace directory — what a person calls the project. */
130
192
  export function workspaceLabel(cwd) {
@@ -201,23 +263,36 @@ export function normalizeEnrichNote(text) {
201
263
 
202
264
  // ── Measured trend ─────────────────────────────────────────────────────────
203
265
 
266
+ /**
267
+ * One conversation's turns, each with `corrected`: whether the user's NEXT
268
+ * message in that conversation reads as a correction of it. The last turn has
269
+ * no next message and counts as not corrected.
270
+ */
271
+ export function markCorrections(turns) {
272
+ const list = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object')
273
+ return list.map((turn, index) => ({ ...turn, corrected: index + 1 < list.length && looksLikeCorrection(list[index + 1].prompt) }))
274
+ }
275
+
204
276
  function windowStats(turns) {
205
277
  const n = turns.length
206
- if (n === 0) return { n: 0, messyRate: 0, tokensPerTurn: 0 }
278
+ if (n === 0) return { n: 0, correctionRate: 0, messyRate: 0, tokensPerTurn: 0 }
279
+ let corrected = 0
207
280
  let messy = 0
208
281
  let tokens = 0
209
282
  for (const turn of turns) {
283
+ if (turn.corrected === true) corrected += 1
210
284
  if (isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })) messy += 1
211
285
  const usage = turn.usage !== null && typeof turn.usage === 'object' ? turn.usage : {}
212
286
  const read = (key) => (typeof usage[key] === 'number' && Number.isFinite(usage[key]) ? usage[key] : 0)
213
287
  tokens += read('inputTokens') + read('outputTokens') + read('cacheReadTokens') + read('reasoningTokens')
214
288
  }
215
- return { n, messyRate: messy / n, tokensPerTurn: Math.round(tokens / n) }
289
+ return { n, correctionRate: corrected / n, messyRate: messy / n, tokensPerTurn: Math.round(tokens / n) }
216
290
  }
217
291
 
218
292
  /**
219
293
  * Real before/after numbers from the fold: the first `window` finished turns
220
- * vs. the latest `window`, on rework signals (retries/errors/compactions/
294
+ * vs. the latest `window`, on how often the user corrected the agent (turns
295
+ * pre-marked by `markCorrections`), rework signals (retries/errors/compactions/
221
296
  * rejections — step counts are deliberately NOT a signal) and tokens/turn.
222
297
  */
223
298
  export function computeTrend(turns, { window = 20 } = {}) {
@@ -347,18 +422,52 @@ export const ANALYSIS_SYSTEM_PROMPT = [
347
422
 
348
423
  export const IMPROVE_SYSTEM_PROMPT = [
349
424
  'You are a prompt-improvement assistant inside DeepSeek Harness.',
350
- 'The user typed a draft prompt into the composer. Rewrite it to be more',
351
- 'precise, complete, and token-efficient, while PRESERVING its intent.',
352
- 'Do not add requirements the user did not ask for; fill in only what is',
353
- 'genuinely underspecified (scope, constraints, format, acceptance criteria).',
354
- 'Learn from the user\'s recurring mistake patterns when provided.',
425
+ 'The user typed a draft prompt for a coding agent into the composer. You get',
426
+ 'ONE pass: return the prompt the user would arrive at after several rounds of',
427
+ 'editing. Leave nothing for a second pass.',
428
+ '',
429
+ 'A finished prompt has every item below (skip an item only when the draft or',
430
+ 'context makes it obviously unnecessary):',
431
+ '- GOAL — one sentence: the outcome and what "done" looks like.',
432
+ '- CONTEXT — the concrete facts the agent would otherwise have to discover:',
433
+ ' file paths, URLs, names, versions, decisions already made. Take them from',
434
+ ' the draft and from RECENT CONVERSATION CONTEXT. Never invent facts.',
435
+ '- SCOPE — what is in and out; what to leave untouched.',
436
+ '- CONSTRAINTS — what not to do, limits, style, language, budget.',
437
+ '- OUTPUT FORMAT — the shape of the answer: list, table, diff, code only,',
438
+ ' prioritized, length.',
439
+ '- EFFICIENCY — what the agent need not explore or verify, so it finishes',
440
+ ' in fewer steps and tool calls.',
441
+ '',
442
+ 'Rules:',
443
+ '- Preserve the user\'s intent; add nothing they did not ask for or clearly',
444
+ ' imply.',
445
+ '- Only fill what is genuinely underspecified. Keep the draft\'s wording where',
446
+ ' it already works.',
447
+ '- Be as short as completeness allows: no filler, no role preambles ("You are',
448
+ ' an expert…"), no restating what the agent already knows.',
449
+ '- Follow the STYLE RULES, NEGATIVE FEEDBACK and RECURRING MISTAKE PATTERNS',
450
+ ' when provided; they describe this user\'s habits.',
451
+ '- Silently check the draft against every item first, then write the whole',
452
+ ' prompt once.',
453
+ '- FIXED POINT: if the draft already satisfies every item, return it',
454
+ ' VERBATIM (character for character) with the rationale "Already complete."',
455
+ ' Never make cosmetic edits — a prompt you improved must come back',
456
+ ' unchanged when improved again.',
457
+ '',
458
+ 'Example',
459
+ 'draft: what do you think we can do to market our plugin today',
460
+ 'context: the plugin was just published at https://github.com/x/y',
461
+ 'improved: The plugin is now public at https://github.com/x/y. How do we',
462
+ 'market it and get it noticed? Give a prioritized list of concrete actions,',
463
+ 'easiest first, biggest impact last, with the expected effort for each.',
355
464
  '',
356
465
  'RESPONSE FORMAT — this is mandatory and machine-parsed:',
357
466
  'Your ENTIRE response must be ONE JSON object and nothing else. No preamble,',
358
467
  'no narration, no explanations outside the JSON, no markdown fences.',
359
468
  'Start directly with "{" and end with "}".',
360
469
  '{',
361
- ' "improved": "<the rewritten prompt>",',
470
+ ' "improved": "<the final prompt — the draft verbatim if already complete>",',
362
471
  ' "rationale": "<1-2 sentences on what you changed and why>"',
363
472
  '}',
364
473
  '',
@@ -443,7 +552,9 @@ export const DIRECTIVE_SYSTEM_PROMPT = [
443
552
  'mood, addressed to the agent. A directive that needs a second sentence is two',
444
553
  'directives or too specific. You are writing',
445
554
  'the COMPLETE new set: keep existing directives that still hold (reworded if',
446
- 'sharper), drop ones that were one-off, add what is missing. No preamble.',
555
+ 'sharper, returned with their id), drop ones that were one-off, add what is',
556
+ 'missing. Never re-propose a retired directive or a close rephrasing of one.',
557
+ 'No preamble.',
447
558
  ].join('\n')
448
559
 
449
560
  export function buildDirectiveUserText(profile, recentReports = [], { labelOf = workspaceLabel } = {}) {
@@ -489,10 +600,19 @@ export function buildDirectiveUserText(profile, recentReports = [], { labelOf =
489
600
  lines.push('', '=== STYLE RULES THE USER CONFIRMED ===')
490
601
  for (const rule of rules) lines.push('- ' + clipSafe(rule.rule, 300))
491
602
  }
492
- const existing = Array.isArray(profile?.directives) ? profile.directives.filter((entry) => typeof entry?.text === 'string' && entry.text.length > 0) : []
603
+ const directives = Array.isArray(profile?.directives) ? profile.directives.filter((entry) => typeof entry?.text === 'string' && entry.text.length > 0) : []
604
+ const directiveLine = (entry, withId) => '- ' + (withId && typeof entry.id === 'string' ? '[' + entry.id + '] ' : '') + clipDirective(entry.text)
605
+ + (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? ' [workspace: ' + labelOf(entry.workspace) + ']' : '')
606
+ const existing = directives.filter((entry) => entry.status !== 'retired')
493
607
  if (existing.length > 0) {
494
608
  lines.push('', '=== CURRENT DIRECTIVES (keep the ones that still hold) ===')
495
- for (const entry of existing) lines.push('- ' + clipDirective(entry.text) + (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? ' [workspace: ' + labelOf(entry.workspace) + ']' : ''))
609
+ for (const entry of existing) lines.push(directiveLine(entry, true))
610
+ lines.push('When you keep or reword one of these, return it with its [id]; leave id out only for a genuinely new directive.')
611
+ }
612
+ const retired = directives.filter((entry) => entry.status === 'retired').slice(-MAX_RETIRED)
613
+ if (retired.length > 0) {
614
+ lines.push('', '=== RETIRED (made things worse while active; do not re-propose these) ===')
615
+ for (const entry of retired) lines.push(directiveLine(entry, false))
496
616
  }
497
617
  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.')
498
618
  return lines.join('\n')
@@ -502,7 +622,8 @@ export function buildDirectiveUserText(profile, recentReports = [], { labelOf =
502
622
  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
503
623
 
504
624
  /**
505
- * Parse a directives payload into clipped, deduped one-liners (≤4 kept);
625
+ * Parse a directives payload into clipped, deduped one-liners (≤4 kept), each
626
+ * with the id of the current directive it restates when the model gave one;
506
627
  * directives that instruct the agent to ask the user are rejected.
507
628
  */
508
629
  export function classifyDirectives(text) {
@@ -520,6 +641,7 @@ export function classifyDirectives(text) {
520
641
  const workspace = typeof item === 'object' && item !== null && typeof item.workspace === 'string' && item.workspace.trim().length > 0
521
642
  ? clipSafe(item.workspace.trim(), 200)
522
643
  : undefined
644
+ const id = typeof item === 'object' && item !== null && typeof item.id === 'string' && item.id.trim().length > 0 ? clipSafe(item.id.trim(), 64) : undefined
523
645
  const key = (workspace ?? '') + '\n' + value.toLowerCase()
524
646
  if (value.length === 0 || seen.has(key)) continue
525
647
  seen.add(key)
@@ -527,7 +649,7 @@ export function classifyDirectives(text) {
527
649
  rejected.push(value)
528
650
  continue
529
651
  }
530
- kept.push(workspace === undefined ? { text: value } : { text: value, workspace })
652
+ kept.push({ text: value, ...(workspace === undefined ? {} : { workspace }), ...(id === undefined ? {} : { id }) })
531
653
  if (kept.length >= 4) break
532
654
  }
533
655
  return { kept, rejected }
@@ -540,14 +662,13 @@ export function classifyDirectives(text) {
540
662
  * nothing is enabled (an empty section contributes nothing).
541
663
  */
542
664
  export function buildSteeringSection(profile, { cwd } = {}) {
543
- const here = typeof cwd === 'string' && cwd.length > 0 ? cwd : null
544
- const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : null)
665
+ const here = typeof cwd === 'string' && cwd.length > 0 ? cwd : ''
545
666
  const candidates = (Array.isArray(profile?.directives) ? profile.directives : [])
546
- .filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && entry.status !== 'retired'
667
+ .filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && entry.status !== 'retired' && entry.status !== 'queued'
547
668
  && typeof entry.text === 'string' && entry.text.trim().length > 0)
548
- .filter((entry) => scopeOf(entry) === null || scopeOf(entry) === here)
669
+ .filter((entry) => scopeOf(entry) === '' || scopeOf(entry) === here)
549
670
  // This workspace's own directives first: they are the more specific ones.
550
- const enabled = [...candidates.filter((entry) => scopeOf(entry) !== null), ...candidates.filter((entry) => scopeOf(entry) === null)]
671
+ const enabled = [...candidates.filter((entry) => scopeOf(entry) !== ''), ...candidates.filter((entry) => scopeOf(entry) === '')]
551
672
  if (enabled.length === 0) return { text: '', ids: [] }
552
673
  const header = [
553
674
  '## About this user (learned by Tacit from their past prompts)',
@@ -989,8 +1110,12 @@ export function lastDownReasons(profile, n = 3) {
989
1110
  * NEVER returned as the answer (chain of thought is not a report). A
990
1111
  * deployment that rejects the reasoning effort gets one retry without it.
991
1112
  * Returns the answer text ('' when the model produced nothing usable).
1113
+ *
1114
+ * When `onUsage(record)` is given, it is called once per underlying `run()`
1115
+ * (twice on the reasoning-effort retry) with a usage/cost record. A sink
1116
+ * that throws never fails the model call.
992
1117
  */
993
- export async function callCoachModel(ctx, { provider, model, system, userText, maxTokens, timeoutMs, tool, sessionId, reasoningEffort = COACH_REASONING_EFFORT }) {
1118
+ export async function callCoachModel(ctx, { provider, model, system, userText, maxTokens, timeoutMs, tool, sessionId, reasoningEffort = COACH_REASONING_EFFORT, onUsage }) {
994
1119
  const llm = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
995
1120
  if (llm === undefined || typeof llm.stream !== 'function') {
996
1121
  const error = new Error('the harness LLM service is unavailable')
@@ -1003,35 +1128,81 @@ export async function callCoachModel(ctx, { provider, model, system, userText, m
1003
1128
  content: [{ type: 'text', text: userText }],
1004
1129
  source: { kind: 'plugin', plugin: 'dsh-tacit' },
1005
1130
  })
1131
+ const toCount = (value) => (Number.isFinite(value) && value >= 0 ? value : 0)
1006
1132
  const run = async (effort) => {
1133
+ const startedAt = Date.now()
1007
1134
  let text = ''
1008
1135
  let toolArgs = ''
1009
1136
  let toolDeltas = ''
1010
- let finish = ''
1011
- for await (const chunk of llm.stream({
1012
- provider,
1013
- model,
1014
- messages: [message],
1015
- system,
1016
- maxTokens,
1017
- signal: controller.signal,
1018
- ...(effort !== undefined ? { reasoningEffort: effort } : {}),
1019
- ...(tool !== undefined ? { tools: [tool] } : {}),
1020
- ...(typeof sessionId === 'string' && sessionId.length > 0 ? { sessionId } : {}),
1021
- })) {
1022
- if (chunk === null || typeof chunk !== 'object') continue
1023
- if (chunk.type === 'finish' && typeof chunk.reason === 'string') finish = chunk.reason
1024
- if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
1025
- else if (chunk.type === 'tool-call-delta' && typeof chunk.argumentsDelta === 'string') toolDeltas += chunk.argumentsDelta
1026
- else if (chunk.type === 'block-end' && chunk.block !== null && typeof chunk.block === 'object'
1027
- && chunk.block.type === 'tool-call' && typeof chunk.block.arguments === 'string' && toolArgs === '') {
1028
- toolArgs = chunk.block.arguments
1137
+ let finishKind = ''
1138
+ let failure = null
1139
+ let usage = null
1140
+ let thrown = null
1141
+ try {
1142
+ for await (const chunk of llm.stream({
1143
+ provider,
1144
+ model,
1145
+ messages: [message],
1146
+ system,
1147
+ maxTokens,
1148
+ signal: controller.signal,
1149
+ ...(effort !== undefined ? { reasoningEffort: effort } : {}),
1150
+ ...(tool !== undefined ? { tools: [tool] } : {}),
1151
+ ...(typeof sessionId === 'string' && sessionId.length > 0 ? { sessionId } : {}),
1152
+ })) {
1153
+ if (chunk === null || typeof chunk !== 'object') continue
1154
+ if (chunk.type === 'usage' && chunk.usage !== null && typeof chunk.usage === 'object') {
1155
+ usage = {
1156
+ inputTokens: toCount(chunk.usage.inputTokens),
1157
+ outputTokens: toCount(chunk.usage.outputTokens),
1158
+ cacheReadTokens: toCount(chunk.usage.cacheReadTokens),
1159
+ cacheWriteTokens: toCount(chunk.usage.cacheWriteTokens),
1160
+ reasoningTokens: toCount(chunk.usage.reasoningTokens),
1161
+ }
1162
+ } else if (chunk.type === 'finish') {
1163
+ finishKind = typeof chunk.reason === 'string' ? chunk.reason : (chunk.reason?.kind ?? '')
1164
+ failure = chunk.reason?.failure ?? null
1165
+ } else if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
1166
+ else if (chunk.type === 'tool-call-delta' && typeof chunk.argumentsDelta === 'string') toolDeltas += chunk.argumentsDelta
1167
+ else if (chunk.type === 'block-end' && chunk.block !== null && typeof chunk.block === 'object'
1168
+ && chunk.block.type === 'tool-call' && typeof chunk.block.arguments === 'string' && toolArgs === '') {
1169
+ toolArgs = chunk.block.arguments
1170
+ }
1171
+ }
1172
+ if (finishKind === 'error' || finishKind === 'aborted') {
1173
+ thrown = Object.assign(new Error(failure?.message ?? finishKind), { code: failure?.code ?? finishKind.toUpperCase() })
1174
+ throw thrown
1175
+ }
1176
+ } catch (error) {
1177
+ thrown = thrown ?? error
1178
+ throw error
1179
+ } finally {
1180
+ if (typeof onUsage === 'function') {
1181
+ const code = (thrown !== null && typeof thrown === 'object' ? thrown.code : undefined) ?? failure?.code ?? ''
1182
+ const status = thrown !== null || finishKind === 'error' || finishKind === 'aborted'
1183
+ ? 'failed'
1184
+ : (usage === null ? 'unmetered' : 'ok')
1185
+ try {
1186
+ onUsage({
1187
+ startedAt,
1188
+ durationMs: Date.now() - startedAt,
1189
+ model,
1190
+ provider,
1191
+ reasoningEffort: effort ?? null,
1192
+ finish: finishKind,
1193
+ status,
1194
+ code,
1195
+ usage,
1196
+ })
1197
+ } catch {
1198
+ // a sink bug must never fail a model call
1199
+ }
1029
1200
  }
1030
1201
  }
1031
1202
  if (toolArgs.length > 0) return toolArgs
1032
1203
  if (toolDeltas.length > 0) return toolDeltas
1033
- if (text.length === 0 && finish.length > 0 && finish !== 'stop') {
1034
- console.warn('[tacit] model call ended without an answer (finish: ' + finish + ', maxTokens: ' + String(maxTokens) + ')')
1204
+ if (text.length === 0 && finishKind.length > 0 && finishKind !== 'stop') {
1205
+ console.warn('[tacit] model call ended without an answer (finish: ' + finishKind + ', maxTokens: ' + String(maxTokens) + ')')
1035
1206
  }
1036
1207
  return text
1037
1208
  }
package/lib/index.js CHANGED
@@ -87,13 +87,15 @@ export function apply(ctx, config) {
87
87
  const directives = store.profile().directives
88
88
  const count = (status) => directives.filter((entry) => entry.status === status).length
89
89
  console.info('[tacit] loaded — directives: ' + count('active') + ' active, ' + count('candidate') + ' candidates, '
90
- + count('retired') + ' retired; steering ' + (cfg.steerAgent ? 'on' : 'off') + '; auto-analysis '
90
+ + count('queued') + ' queued, ' + count('retired') + ' retired; steering ' + (cfg.steerAgent ? 'on' : 'off') + '; auto-analysis '
91
91
  + (cfg.autoAnalyze ? 'on (cap ' + cfg.autoDailyBudget + '/day)' : 'off'))
92
92
  } catch {
93
93
  // Logging must never keep the plugin from loading.
94
94
  }
95
95
 
96
96
  ctx.effect(() => () => {
97
- // 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()
98
100
  }, 'tacit: dispose')
99
101
  }
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ /**
4
+ * dsh-tacit — the price table behind the usage tracker.
5
+ *
6
+ * Wraps the pure `lib/pricing.js` with one optional input: the sibling
7
+ * `dsh-cost-meter` plugin's `costMeter` service. When that service is
8
+ * installed and hands over a usable state, its prices win; otherwise the
9
+ * bundled DeepSeek list prices apply. The service is fully duck-typed and
10
+ * never trusted: `refresh()` never throws, never blocks longer than
11
+ * `timeoutMs`, and any failure (absent, throwing, hanging, junk) leaves the
12
+ * source on the bundled table with a human-readable `error`.
13
+ *
14
+ * A model call must never wait on this — the service refreshes it in the
15
+ * background and every `priceCall` reads whatever snapshot is current.
16
+ */
17
+
18
+ import { priceCall as priceCallWith, normalizeCostMeterState, tierAt, PRICES_AS_OF, BUNDLED_PRICES } from './pricing.js'
19
+ import { COACH_MODELS } from './schema.js'
20
+
21
+ /** A `{cacheHit, cacheMiss, output}` triple as a fresh object (never a reference into a shared table). */
22
+ function copyTriple(triple) {
23
+ return { cacheHit: triple.cacheHit, cacheMiss: triple.cacheMiss, output: triple.output }
24
+ }
25
+
26
+ /** A normalized snapshot is only worth using if it actually carries a price. */
27
+ function hasPrices(snapshot) {
28
+ if (snapshot === null || typeof snapshot !== 'object') return false
29
+ const models = snapshot.models !== null && typeof snapshot.models === 'object' ? Object.keys(snapshot.models) : []
30
+ const providers = snapshot.providers !== null && typeof snapshot.providers === 'object' ? Object.keys(snapshot.providers) : []
31
+ return models.length > 0 || providers.length > 0
32
+ }
33
+
34
+ /** Whatever was thrown/rejected, as a short message. */
35
+ function messageOf(error) {
36
+ if (error !== null && typeof error === 'object' && typeof error.message === 'string' && error.message.length > 0) return error.message
37
+ return String(error)
38
+ }
39
+
40
+ /** Reject after `ms`; the timer is unref'd so a pending refresh never holds the process open. */
41
+ function rejectAfter(ms) {
42
+ let timer = null
43
+ const promise = new Promise((_resolve, reject) => {
44
+ timer = setTimeout(() => reject(new Error(`costMeter getState() timed out after ${ms}ms`)), ms)
45
+ if (typeof timer?.unref === 'function') timer.unref()
46
+ })
47
+ return { promise, cancel: () => { if (timer !== null) clearTimeout(timer) } }
48
+ }
49
+
50
+ /**
51
+ * The price source the tracker and the reports read from.
52
+ * `now`/`timeoutMs` are injectable so tests can drive the clock and the
53
+ * hang path without waiting five seconds.
54
+ */
55
+ export function createPricingSource(ctx, { now = Date.now, timeoutMs = 5000 } = {}) {
56
+ const state = { snapshot: null, source: 'bundled', refreshedAt: 0, error: '' }
57
+
58
+ /** Drop back to the bundled table, remembering why. */
59
+ function fallBack(error) {
60
+ state.snapshot = null
61
+ state.source = 'bundled'
62
+ state.refreshedAt = 0
63
+ state.error = error
64
+ }
65
+
66
+ async function refresh() {
67
+ const service = ctx !== null && typeof ctx === 'object' && typeof ctx.get === 'function' ? ctx.get('costMeter') : undefined
68
+ if (service === undefined || service === null || typeof service.getState !== 'function') {
69
+ fallBack('the costMeter service is not available — using bundled list prices')
70
+ return
71
+ }
72
+ const timeout = rejectAfter(timeoutMs)
73
+ let raw
74
+ try {
75
+ raw = await Promise.race([Promise.resolve(service.getState()), timeout.promise])
76
+ } catch (error) {
77
+ fallBack(messageOf(error))
78
+ return
79
+ } finally {
80
+ timeout.cancel()
81
+ }
82
+ const snapshot = normalizeCostMeterState(raw)
83
+ if (!hasPrices(snapshot)) {
84
+ fallBack('the costMeter state carried no usable prices — using bundled list prices')
85
+ return
86
+ }
87
+ state.snapshot = snapshot
88
+ state.source = 'costMeter'
89
+ state.refreshedAt = now()
90
+ state.error = ''
91
+ }
92
+
93
+ /** {@link priceCallWith} against the current snapshot (bundled when there is none). */
94
+ function priceCall(args) {
95
+ return priceCallWith({ ...args, table: state.snapshot })
96
+ }
97
+
98
+ /**
99
+ * What the Pricing card shows about the source itself. `tierNow` is read
100
+ * off the same snapshot `priceCall` prices against — a cost-meter table
101
+ * that turns peak pricing off, shifts the windows, or dates them into the
102
+ * future must not leave the card quoting the bundled schedule.
103
+ */
104
+ function status() {
105
+ const snapshot = state.snapshot
106
+ const tierNow = snapshot === null
107
+ ? tierAt(now())
108
+ : tierAt(now(), { windows: snapshot.windows, effectiveAtMs: snapshot.effectiveAtMs, peakEnabled: snapshot.peakEnabled !== false })
109
+ return {
110
+ source: state.source,
111
+ asOf: typeof snapshot?.asOf === 'string' ? snapshot.asOf : PRICES_AS_OF,
112
+ refreshedAt: state.refreshedAt,
113
+ tierNow,
114
+ error: state.error,
115
+ }
116
+ }
117
+
118
+ /** `{model: {offPeak, peak}}` for both coach models — snapshot first, bundled per model otherwise. */
119
+ function rates() {
120
+ const models = state.snapshot?.models
121
+ const out = {}
122
+ for (const model of COACH_MODELS) {
123
+ const entry = models !== null && typeof models === 'object' ? models[model] : undefined
124
+ const source = entry !== null && typeof entry === 'object' && entry.offPeak !== undefined && entry.peak !== undefined
125
+ ? entry
126
+ : BUNDLED_PRICES[model]
127
+ out[model] = { offPeak: copyTriple(source.offPeak), peak: copyTriple(source.peak) }
128
+ }
129
+ return out
130
+ }
131
+
132
+ return { refresh, priceCall, status, rates }
133
+ }