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/README.md +5 -5
- package/client/client.js +1793 -99
- package/docs/README.zh.md +3 -3
- package/lib/analyze.js +214 -43
- package/lib/index.js +4 -2
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +14 -1
- package/lib/schema.js +195 -11
- package/lib/service.js +431 -153
- package/lib/store.js +224 -3
- package/lib/usage.js +763 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
351
|
-
'
|
|
352
|
-
'
|
|
353
|
-
'
|
|
354
|
-
'
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 ? {
|
|
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 :
|
|
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) ===
|
|
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) !==
|
|
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
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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 &&
|
|
1034
|
-
console.warn('[tacit] model call ended without an answer (finish: ' +
|
|
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
|
-
//
|
|
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
|
+
}
|