@goodandready/dsh-usage-guard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-voice contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # dsh-usage-guard
2
+
3
+ A one-purpose patch for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh):
4
+ **one malformed token-usage sample should not take a whole session history down with it.**
5
+
6
+ ## The failure
7
+
8
+ Open a session and get this instead of your conversation:
9
+
10
+ ```
11
+ history unavailable for session "session-…":
12
+ { "expected": "number", "code": "invalid_type", "received": "NaN",
13
+ "path": [ "uncachedInputTokens" ],
14
+ "message": "Invalid input: expected number, received NaN" }
15
+ ```
16
+
17
+ Not a wrong number in a counter — no history at all. And it stays that way,
18
+ because the session summary is recomputed by replaying the log every time.
19
+
20
+ ## The cause
21
+
22
+ The harness folds provider-reported usage into four buckets, and guards two of
23
+ the four:
24
+
25
+ ```js
26
+ const bucketsFrom = (usage) => ({
27
+ uncachedInputTokens: usage.inputTokens, // unguarded
28
+ outputTokens: usage.outputTokens, // unguarded
29
+ cacheReadTokens: usage.cacheReadTokens ?? 0, // guarded
30
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,// guarded
31
+ })
32
+ ```
33
+
34
+ Cache fields are optional because plenty of providers never send them. The two
35
+ that are not guarded are just as optional in practice: providers disagree about
36
+ the shape of a usage report, and some send only part of it. One sample without
37
+ `inputTokens` turns the running total into `NaN`, the schema refuses it, and the
38
+ whole history goes with it.
39
+
40
+ ## What this plugin does
41
+
42
+ It steps in front of the fold. A counter that is missing, `null`, `NaN` or not a
43
+ number at all is repaired before the harness adds it up.
44
+
45
+ Zero is the last resort, not the first move. Providers disagree about what to
46
+ call the same number — `inputTokens`, `input_tokens`, `input`, `promptTokens` —
47
+ and the harness knows one spelling, so the rest read as absent even though the
48
+ figure is sitting right there. The plugin looks under the usual other names
49
+ first and only falls back to zero when there is genuinely nothing to take.
50
+
51
+ Because the fold is also where replay goes, **sessions that are already broken
52
+ open again by themselves** — no session file is rewritten, nothing on disk is
53
+ touched, and removing the plugin puts everything back exactly as it was.
54
+
55
+ Zero in place of a missing counter is a known lie. But the choice is not between
56
+ an exact total and an approximate one — it is between an approximate total and a
57
+ conversation you cannot read.
58
+
59
+ It also names the culprit. Each damaged sample is reported once, with the turn,
60
+ the step and the raw object as it arrived:
61
+
62
+ ```
63
+ [dsh-usage-guard] usage sample out of shape — turn 1, step 1;
64
+ arrived as: {"outputTokens":14,"cacheReadTokens":1024}; inputTokens zeroed
65
+ [dsh-usage-guard] usage sample out of shape — turn 3, step 1;
66
+ arrived as: {"input":22533,"output":20}; inputTokens taken from an alias; outputTokens taken from an alias
67
+ ```
68
+
69
+ That line is the point of the reporting half: providers that send incomplete
70
+ usage are worth knowing about, and a bare `NaN` never told you which one it was.
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ dsh plugin --profile web add @goodandready/dsh-usage-guard
76
+ ```
77
+
78
+ Restart the harness afterwards. There is no UI and nothing to configure to get
79
+ the fix — it works from the moment it loads.
80
+
81
+ ## Settings
82
+
83
+ In `$DSH_HOME/settings.yaml` under `dsh-usage-guard:`, or in the profile:
84
+
85
+ | Field | Default | Description |
86
+ |---|---|---|
87
+ | `repair` | `true` | Replace a missing or non-numeric counter with zero before the fold. Off means reporting only — the fold sees the sample as it came, and a broken one keeps the history unreadable. |
88
+ | `report` | `true` | Log the turn, the step and the raw sample when a damaged one arrives. Reported once per turn, step and field set, not once per replay. |
89
+
90
+ Turning `repair` off is for one situation: you want to see the failure happen
91
+ rather than have it papered over.
92
+
93
+ ## How it attaches
94
+
95
+ Session projections live in an ordinary registry service, keyed by name, and
96
+ each entry holds the `apply` the harness actually calls. The plugin wraps those.
97
+
98
+ It wraps **all** of them, not one. Usage is read in three separate places —
99
+ `tokenUsage` folds the four buckets, `contextPressure` computes its own sum for
100
+ the prompt size, `contextBreakdown` its own for occupancy — and every one of
101
+ them trips over the same missing counter. Keeping a list of keys in step with
102
+ someone else's code is a standing debt; the guard is an identity for an event
103
+ that carries no damaged usage, so a projection that never looks at usage pays
104
+ one comparison and gets the very same event object it would have got anyway.
105
+
106
+ Load order does not matter: whatever is already registered is wrapped
107
+ immediately, and whatever registers later is caught as it lands in the registry.
108
+
109
+ Nothing is patched by editing files, so a harness upgrade replaces the code this
110
+ plugin wraps rather than fighting with it. If a later version guards those two
111
+ fields itself, this plugin becomes a no-op that only reports — and can be
112
+ removed.
113
+
114
+ ## Structure
115
+
116
+ ```
117
+ dsh-usage-guard/
118
+ ├── package.json # dsh bundle metadata + peerDependencies
119
+ ├── cordis.patch.yml # bundle layer: inserts the plugin row
120
+ ├── lib/index.js # the plugin: settings, reporting, attachment
121
+ ├── lib/usage.js # what counts as damaged and what it becomes — pure
122
+ ├── lib/patch.js # how the guard gets in front of the fold — pure
123
+ ├── test/ # unit tests, no harness required
124
+ ├── README.md
125
+ └── LICENSE # MIT
126
+ ```
127
+
128
+ Host half only — no browser code. No npm runtime dependencies, no build step,
129
+ plain ESM.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,7 @@
1
+ # dsh-usage-guard bundle layer: applied automatically when the package is
2
+ # installed as a profile bundle (package.json declares dsh.bundle.patch).
3
+ # Override any field in the profile's own cordis.patch.yml or in
4
+ # $DSH_HOME/settings.yaml under `dsh-usage-guard:`.
5
+ - insert:
6
+ - id: dsh-usage-guard
7
+ name: '@goodandready/dsh-usage-guard'
package/lib/index.js ADDED
@@ -0,0 +1,88 @@
1
+ // dsh-usage-guard — заплатка на счёт токенов.
2
+ //
3
+ // Харнесс складывает расход в четыре ведра, и два из них берёт без оглядки:
4
+ //
5
+ // uncachedInputTokens: usage.inputTokens, // без подстраховки
6
+ // outputTokens: usage.outputTokens, // без подстраховки
7
+ // cacheReadTokens: usage.cacheReadTokens ?? 0,
8
+ // cacheWriteTokens: usage.cacheWriteTokens ?? 0,
9
+ //
10
+ // Стоит провайдеру прислать порцию без первых двух, и сложение даёт NaN.
11
+ // Дальше проверка схемы отвергает всю выжимку сессии, а наружу это выходит
12
+ // так:
13
+ //
14
+ // history unavailable for session "...": expected number, received NaN
15
+ //
16
+ // Не «счётчик соврал», а «история не открывается вовсе» — и остаётся такой
17
+ // навсегда, потому что выжимка считается проигрыванием журнала заново.
18
+ //
19
+ // Отсюда и лечение. Плагин не переписывает журналы на диске: он встаёт перед
20
+ // сложением и подставляет ноль вместо пропажи. Проигрывание идёт через ту же
21
+ // точку, поэтому уже испорченные сессии открываются снова — сами, без починки
22
+ // файлов.
23
+ //
24
+ // Ноль вместо пропущенного счётчика — заведомо неправда. Но выбор не между
25
+ // точным счётом и приблизительным, а между приблизительным и нечитаемой
26
+ // историей.
27
+
28
+ import z from '@deepseek-ai/schemastery'
29
+
30
+ import { patchRegistry } from './patch.js'
31
+ import { complaint, damage, healed, usageOf } from './usage.js'
32
+
33
+ export const name = 'dsh-usage-guard'
34
+ export const inject = ['sessionProjections']
35
+
36
+ export const Config = z.object({
37
+ repair: z
38
+ .boolean()
39
+ .default(true)
40
+ .description('Replace a missing or non-numeric token counter with zero before the harness adds it up. '
41
+ + 'Off means reporting only: the counters reach the fold as they came, and a broken sample keeps the '
42
+ + 'session history unreadable.'),
43
+ report: z
44
+ .boolean()
45
+ .default(true)
46
+ .description('Log a line naming the turn, the step and the raw sample whenever a damaged one arrives. '
47
+ + 'Each turn, step and field set is reported once, not once per replay.'),
48
+ })
49
+
50
+ export function apply(ctx, config) {
51
+ let live = Config(structuredClone(config ?? {})) ?? config
52
+
53
+ ctx.inject(['settings'], (sctx) => {
54
+ const scope = sctx.settings.register(name, Config, { base: config })
55
+ live = scope.get() ?? live
56
+ sctx.effect(() => scope.watch((next) => { live = next ?? live }))
57
+ })
58
+
59
+ // О каждом сочетании хода, шага и набора полей жалуемся один раз: выжимка
60
+ // считается проигрыванием журнала заново, и без этого одна и та же порция
61
+ // въезжала бы в журнал при каждом открытии истории.
62
+ const told = new Set()
63
+
64
+ const guard = (event) => {
65
+ const found = usageOf(event)
66
+ if (!found) return event
67
+ const bad = damage(found.usage)
68
+ if (bad.length === 0) return event
69
+
70
+ if (live.report !== false) {
71
+ const seen = `${String(found.turn)}/${String(found.step)}/${bad.join(',')}`
72
+ if (!told.has(seen)) {
73
+ told.add(seen)
74
+ // eslint-disable-next-line no-console
75
+ console.warn(`[dsh-usage-guard] ${complaint(found, bad)}`)
76
+ }
77
+ }
78
+
79
+ return live.repair === false ? event : healed(event, bad)
80
+ }
81
+
82
+ ctx.inject(['sessionProjections'], (pctx) => {
83
+ pctx.effect(
84
+ () => patchRegistry(pctx.sessionProjections, guard),
85
+ 'dsh-usage-guard: обезвреживание порций расхода',
86
+ )
87
+ })
88
+ }
package/lib/patch.js ADDED
@@ -0,0 +1,76 @@
1
+ // Как заплатка встаёт перед сложением.
2
+ //
3
+ // Реестр проекций сессии — служба с открытой картой записей. Каждая запись
4
+ // держит обезличенную копию того, что зарегистрировал владелец, и зовёт `apply`
5
+ // именно у копии. Значит, подменять надо её, а не то, что владелец отдал при
6
+ // регистрации.
7
+ //
8
+ // Порядок загрузки не гарантирован, и на деле проекция ядра заводится позже
9
+ // нас. Подменять при этом метод `register` у службы бесполезно: до неё
10
+ // добираются через посредника, и замена, поставленная на посредника, до
11
+ // настоящего вызова не доезжает. Поэтому цепляемся за саму карту записей —
12
+ // обычный Map, один и тот же для всех, кто до него дотянулся.
13
+ //
14
+ // Оборачиваются все проекции, а не одна «нужная». Порцию расхода читает не
15
+ // только счёт токенов: давление на контекст считает её своей формулой, разбор
16
+ // занятости — своей, и каждая спотыкается об один и тот же пропущенный
17
+ // счётчик. Список ключей пришлось бы держать в согласии с чужим кодом, а
18
+ // обезвреживатель для здорового события возвращает ровно то же событие — тем,
19
+ // кто расхода не касается, он обходится в одно сравнение.
20
+
21
+ /** Метка на подменённой функции: второй раз оборачивать нечего. */
22
+ export const PATCHED = Symbol.for('dsh-usage-guard.patched')
23
+
24
+ /**
25
+ * Обернуть складывание одной записи реестра.
26
+ *
27
+ * @returns функция возврата к исходному состоянию.
28
+ */
29
+ export function wrapApply(def, guard) {
30
+ if (!def || typeof def.apply !== 'function' || def.apply[PATCHED]) return () => {}
31
+ const original = def.apply
32
+ const patched = (state, event) => original(state, guard(event))
33
+ patched[PATCHED] = true
34
+ def.apply = patched
35
+ return () => {
36
+ if (def.apply === patched) def.apply = original
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Поставить заплатку на реестр проекций.
42
+ *
43
+ * @param registry - служба `sessionProjections`.
44
+ * @param guard - обезвреживатель события; получает событие, возвращает его же
45
+ * или исправленное.
46
+ * @returns функция, снимающая всё поставленное, в обратном порядке.
47
+ */
48
+ export function patchRegistry(registry, guard) {
49
+ const undo = []
50
+ const map = registry && typeof registry === 'object' ? registry.registrations : undefined
51
+ if (!map || typeof map.set !== 'function' || typeof map.values !== 'function') return () => {}
52
+
53
+ // Успели завестись раньше нас — оборачиваем прямо сейчас.
54
+ for (const entry of map.values()) {
55
+ if (entry && entry.def) undo.push(wrapApply(entry.def, guard))
56
+ }
57
+
58
+ // Заведутся позже — поймаем на укладке в карту.
59
+ const set = map.set
60
+ if (!set[PATCHED]) {
61
+ const patched = function (key, value) {
62
+ if (value && value.def) undo.push(wrapApply(value.def, guard))
63
+ return set.call(this, key, value)
64
+ }
65
+ patched[PATCHED] = true
66
+ map.set = patched
67
+ undo.push(() => {
68
+ if (map.set === patched) delete map.set
69
+ })
70
+ }
71
+
72
+ return () => {
73
+ for (const step of undo.reverse()) step()
74
+ undo.length = 0
75
+ }
76
+ }
package/lib/usage.js ADDED
@@ -0,0 +1,130 @@
1
+ // Чистая часть заплатки: что считать испорченной порцией расхода и во что её
2
+ // превращать. Ни контекста, ни сети — поэтому проверяется тестами целиком.
3
+
4
+ /**
5
+ * Поля, из которых харнесс собирает свои четыре ведра.
6
+ *
7
+ * Два первых он берёт как есть, два вторых подстраховывает нулём. Отсюда и весь
8
+ * сыр-бор: провайдер, приславший порцию без `inputTokens`, роняет не свой
9
+ * счётчик, а всю историю сессии.
10
+ */
11
+ export const FIELDS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
12
+
13
+ /**
14
+ * Как то же самое число называют другие.
15
+ *
16
+ * Единого имени у расхода нет: одни присылают `inputTokens`, другие
17
+ * `input_tokens`, третьи просто `input`. Харнесс знает одно написание, поэтому
18
+ * остальные для него — пустое место, хотя число лежит рядом. Прежде чем
19
+ * подставлять ноль, стоит поискать его здесь: ноль — это признание поражения, а
20
+ * не первый ход.
21
+ */
22
+ const ALIASES = {
23
+ inputTokens: ['input_tokens', 'input', 'promptTokens', 'prompt_tokens'],
24
+ outputTokens: ['output_tokens', 'output', 'completionTokens', 'completion_tokens'],
25
+ cacheReadTokens: ['cache_read_tokens', 'cachedTokens', 'cached_tokens', 'cache_read_input_tokens'],
26
+ cacheWriteTokens: ['cache_write_tokens', 'cacheCreationTokens', 'cache_creation_input_tokens'],
27
+ }
28
+
29
+ /** Годное значение счётчика: конечное число, а не `undefined`, `null` или `NaN`. */
30
+ function sound(value) {
31
+ return typeof value === 'number' && Number.isFinite(value)
32
+ }
33
+
34
+ /**
35
+ * Достать порцию расхода из события сессии — по тем же двум местам, где её ищет
36
+ * харнесс.
37
+ *
38
+ * @returns `{ usage, turn, step, at }`, где `at` — путь до порции внутри
39
+ * события, или `null`, если расхода в событии нет.
40
+ */
41
+ export function usageOf(event) {
42
+ if (!event || typeof event !== 'object') return null
43
+ const data = event.data
44
+ if (!data || typeof data !== 'object') return null
45
+
46
+ if (event.type === 'assistant/chunk' && data.chunk && data.chunk.type === 'usage') {
47
+ return { usage: data.chunk.usage, turn: data.turn, step: data.step, at: 'chunk' }
48
+ }
49
+ if (event.type === 'assistant/message' && data.usage !== undefined) {
50
+ return { usage: data.usage, turn: data.turn, step: data.step, at: 'message' }
51
+ }
52
+ return null
53
+ }
54
+
55
+ /**
56
+ * Какие счётчики порции испорчены.
57
+ *
58
+ * Отсутствие кэш-полей нарушением не считается: их не присылает добрая половина
59
+ * провайдеров, и харнесс сам подставляет там ноль. Ругаемся только на то, что
60
+ * действительно ломает сложение, — на пропущенные или нечисловые значения тех
61
+ * полей, которые харнесс берёт без оглядки.
62
+ */
63
+ export function damage(usage) {
64
+ if (!usage || typeof usage !== 'object') return FIELDS.slice(0, 2)
65
+ const bad = []
66
+ for (const field of FIELDS) {
67
+ const value = usage[field]
68
+ const optional = field !== 'inputTokens' && field !== 'outputTokens'
69
+ if (value === undefined && optional) continue
70
+ if (!sound(value)) bad.push(field)
71
+ }
72
+ return bad
73
+ }
74
+
75
+ /** Число под одним из привычных чужих имён — или `undefined`, если его там нет. */
76
+ export function borrowed(usage, field) {
77
+ for (const alias of ALIASES[field] ?? []) {
78
+ if (sound(usage[alias])) return usage[alias]
79
+ }
80
+ return undefined
81
+ }
82
+
83
+ /**
84
+ * Порция с обезвреженными счётчиками.
85
+ *
86
+ * Сначала ищем число под чужим именем, и только если его нет — ставим ноль.
87
+ * Ноль здесь заведомо неправда, но выбор не между точным счётом и
88
+ * приблизительным, а между приблизительным и нечитаемой историей. Остальные
89
+ * поля порции остаются как были: чиним арифметику, а не переписываем ответ
90
+ * провайдера.
91
+ */
92
+ export function repaired(usage, bad) {
93
+ const fixed = usage && typeof usage === 'object' ? { ...usage } : {}
94
+ for (const field of bad) {
95
+ const found = usage && typeof usage === 'object' ? borrowed(usage, field) : undefined
96
+ fixed[field] = found === undefined ? 0 : found
97
+ }
98
+ return fixed
99
+ }
100
+
101
+ /**
102
+ * Событие с обезвреженной порцией — или то же самое событие, если чинить
103
+ * нечего.
104
+ *
105
+ * Копия поверхностная и ровно по тому пути, где лежит расход: складыванию
106
+ * достаётся исправное событие, а всё остальное в нём — та же ссылка, что и
107
+ * была.
108
+ */
109
+ export function healed(event, bad) {
110
+ const found = usageOf(event)
111
+ if (!found || bad.length === 0) return event
112
+ const usage = repaired(found.usage, bad)
113
+
114
+ if (found.at === 'chunk') {
115
+ return {
116
+ ...event,
117
+ data: { ...event.data, chunk: { ...event.data.chunk, usage } },
118
+ }
119
+ }
120
+ return { ...event, data: { ...event.data, usage } }
121
+ }
122
+
123
+ /** Короткая строка для журнала: по ней видно, кого звать к ответу. */
124
+ export function complaint(found, bad) {
125
+ const where = `ход ${String(found.turn)}, шаг ${String(found.step)}`
126
+ const taken = bad
127
+ .map((field) => (found.usage && borrowed(found.usage, field) !== undefined ? `${field} взят по синониму` : `${field} обнулён`))
128
+ .join('; ')
129
+ return `порция расхода не по форме — ${where}; пришло: ${JSON.stringify(found.usage)}; ${taken}`
130
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@goodandready/dsh-usage-guard",
3
+ "version": "0.1.0",
4
+ "description": "Keeps a malformed token-usage sample from taking a whole session history down with it: a missing or non-numeric counter becomes zero before the harness adds it up, so the fold never yields NaN and already-broken sessions open again.",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "token-usage",
10
+ "session-history",
11
+ "patch"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/GooDAnDReaDY/dsh-usage-guard.git"
16
+ },
17
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-usage-guard",
18
+ "bugs": {
19
+ "url": "https://github.com/GooDAnDReaDY/dsh-usage-guard/issues"
20
+ },
21
+ "type": "module",
22
+ "main": "./lib/index.js",
23
+ "exports": {
24
+ ".": "./lib/index.js",
25
+ "./package.json": "./package.json",
26
+ "./cordis.patch.yml": "./cordis.patch.yml"
27
+ },
28
+ "files": [
29
+ "lib/",
30
+ "cordis.patch.yml",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "dsh": {
39
+ "bundle": {
40
+ "patch": "./cordis.patch.yml"
41
+ }
42
+ },
43
+ "peerDependencies": {
44
+ "@deepseek-ai/cordis": "^4.0.1",
45
+ "@deepseek-ai/schemastery": "^3.18.1"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test test/*.test.mjs"
49
+ }
50
+ }