@goodandready/dsh-usage-guard 0.1.0 → 0.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 dsh-voice contributors
3
+ Copyright (c) 2026 GooDAnDReaDY
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,133 +1,159 @@
1
- # dsh-usage-guard
1
+ # 📦 @goodandready/dsh-usage-guard
2
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.**
3
+ <div align="center">
5
4
 
6
- ## The failure
5
+ <h3>Session Token-Usage Sanitizer, History Crash Guard & Arithmetic Protection for DeepSeek Harness</h3>
7
6
 
8
- Open a session and get this instead of your conversation:
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-usage-guard"><img src="https://img.shields.io/npm/v/@goodandready/dsh-usage-guard.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
9
13
 
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
- ```
14
+ <p align="center">
15
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/All_Author_Projects-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="All Author Projects"></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="README.md"><b>🇬🇧 English</b></a> •
20
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
+ </p>
16
23
 
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.
24
+ </div>
19
25
 
20
- ## The cause
26
+ ---
21
27
 
22
- The harness folds provider-reported usage into four buckets, and guards two of
23
- the four:
28
+ ## ⚡ The Root Problem: How Upstream Providers Poison Session History
24
29
 
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
- })
30
+ In **DeepSeek Harness**, session projections aggregate cumulative token usage across four core buckets:
31
+
32
+ ```javascript
33
+ uncachedInputTokens: usage.inputTokens, // No safety fallback in DSH core!
34
+ outputTokens: usage.outputTokens, // No safety fallback in DSH core!
35
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
36
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
32
37
  ```
33
38
 
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
+ While DSH core guards `cacheReadTokens` and `cacheWriteTokens` with `?? 0`, it takes `inputTokens` and `outputTokens` **as raw numbers without safety checks**.
39
40
 
40
- ## What this plugin does
41
+ When third-party providers, local inference servers, custom proxy gateways, or community routers return non-standard payloads, missing fields, or `NaN`, standard JavaScript arithmetic (`total += NaN`) instantly converts the session's cumulative token sum into `NaN`.
41
42
 
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.
43
+ Subsequently, DSH schema validation fatally rejects the entire session digest:
44
+ ```
45
+ history unavailable for session "<session-id>": expected number, received NaN
46
+ ```
44
47
 
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.
48
+ Because session history in DSH is computed dynamically by **replaying the event log**, a single malformed token packet permanently bricks the entire conversation history from being opened ever again.
49
+
50
+ ```mermaid
51
+ graph LR
52
+ subgraph Malformed [Upstream Provider Stream]
53
+ API[LLM Output Stream] -->|Returns prompt_tokens / NaN / null| Event[Session Event Chunk]
54
+ end
55
+
56
+ subgraph Unprotected [Without dsh-usage-guard]
57
+ Event --> DSHMath[DSH Cumulative Arithmetic]
58
+ DSHMath -->|total += NaN| Poison[🚨 Cumulative Total becomes NaN]
59
+ Poison --> SchemaFail[Schema Validation Rejection]
60
+ SchemaFail --> DeadHistory[💥 Session History Permanently Unreadable]
61
+ end
62
+
63
+ subgraph Guarded [With dsh-usage-guard Active]
64
+ Event --> Patch[sessionProjections Interceptor]
65
+ Patch --> AliasCheck{Alias Borrowing Layer}
66
+ AliasCheck -->|Maps prompt_tokens -> inputTokens| Restored[Restored Number]
67
+ AliasCheck -->|If missing / NaN| ZeroFallback[Safe 0 Fallback]
68
+ Restored --> SafeMath[Clean Arithmetic Execution]
69
+ ZeroFallback --> SafeMath
70
+ SafeMath --> ValidHistory[✅ 100% Intact & Recovered Session History]
71
+ end
72
+
73
+ style Malformed fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
74
+ style Unprotected fill:#311b1b,stroke:#f38ba8,stroke-width:2px,color:#cdd6f4
75
+ style Guarded fill:#181825,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
76
+ ```
50
77
 
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.
78
+ ---
54
79
 
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.
80
+ ## ✨ Key Features & Architectural Defense
58
81
 
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:
82
+ ### 1. Instant Replay Recovery for Existing Corrupted Sessions
83
+ The plugin does **not** alter or rewrite log files on disk. Instead, it hooks the projection fold at runtime. Because session replay passes through this exact interception point, **all previously broken or locked sessions are instantly restored and readable immediately upon installing the plugin**.
61
84
 
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
- ```
85
+ ### 2. Comprehensive Alias Borrowing Lexicon (`borrowed`)
86
+ Before substituting zero, `dsh-usage-guard` scans an extensive dictionary of industry-standard field aliases:
68
87
 
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.
88
+ | Target DSH Field | Recognized Vendor Aliases |
89
+ |---|---|
90
+ | `inputTokens` | `input_tokens`, `input`, `promptTokens`, `prompt_tokens`, `promptTokenCount`, `prompt_eval_count` |
91
+ | `outputTokens` | `output_tokens`, `output`, `completionTokens`, `completion_tokens`, `candidatesTokenCount`, `eval_count` |
92
+ | `cacheReadTokens` | `cache_read_tokens`, `cachedTokens`, `cached_tokens`, `cache_read_input_tokens`, `cachedContentTokenCount`, `prompt_tokens_details.cached_tokens` |
93
+ | `cacheWriteTokens` | `cache_write_tokens`, `cacheCreationTokens`, `cache_creation_input_tokens` |
71
94
 
72
- ## Install
95
+ ### 3. Finite Non-Negative Soundness Validation (`sound`)
96
+ Strictly validates `typeof value === 'number' && Number.isFinite(value) && value >= 0` to filter out `NaN`, `Infinity`, `null`, `undefined`, negative error codes (e.g. `-1`), and malformed strings before they reach arithmetic operations.
73
97
 
74
- ```bash
75
- dsh plugin --profile web add @goodandready/dsh-usage-guard
76
- ```
98
+ ### 4. Safe Zero Fallback (`repaired`)
99
+ If a counter cannot be resolved from aliases, it is safely initialized to `0`. The choice is not between exact and approximate numbers, but between approximate token counts and an unreadable dead session.
77
100
 
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.
101
+ ### 5. In-Memory Registry Monkey-Patching (`lib/patch.js`)
102
+ * **Pre-existing Projections**: Wraps all `.apply` methods currently registered in `sessionProjections.registrations` while preserving full `this` context.
103
+ * **Late-Binding Projections**: Traps future projection registrations via `map.set` wrapping, guaranteeing 100% coverage regardless of plugin loading order.
104
+ * **Universal Projection Protection**: Protects not only token counters, but also context pressure calculators and busy-state analyzers.
105
+ * **Zero Performance Overhead**: Uses shallow event cloning only along the usage path; all other event data references remain untouched.
80
106
 
81
- ## Settings
107
+ ### 6. Deduplicated Diagnostic Reporting (`told`)
108
+ Logs informative diagnostic warnings naming the exact session, turn, step, raw payload, and recovery action (e.g. `inputTokens borrowed from alias` vs `inputTokens zeroed`). Incidents are deduplicated in memory so logs are not flooded during replays, and the cache is capped at 1,000 items to prevent memory leaks.
82
109
 
83
- In `$DSH_HOME/settings.yaml` under `dsh-usage-guard:`, or in the profile:
110
+ ---
84
111
 
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. |
112
+ ## 🚀 Changed in v0.1.1
89
113
 
90
- Turning `repair` off is for one situation: you want to see the failure happen
91
- rather than have it papered over.
114
+ * **Negative Number Protection (`nonnegative`)**:
115
+ In v0.1.0, negative counters like `-1` (sometimes returned by proxy gateways during rate limits or faults) passed finite-number checks and crashed DSH schema validation (`z.number().int().nonnegative()`). In v0.1.1, `sound()` strictly requires `value >= 0`. Any negative number is treated as corrupted and safely zeroed out.
116
+ * **Safe Coercion of Stringified Numbers**:
117
+ If an upstream provider delivers valid token counts formatted as strings (e.g. `inputTokens: "1540"`), v0.1.1 coerces them into true numbers rather than resetting them to zero.
118
+ * **Expanded Ecosystem Aliases**:
119
+ - **Google Gemini API**: added `promptTokenCount`, `candidatesTokenCount`, and `cachedContentTokenCount`.
120
+ - **Ollama native API**: added `prompt_eval_count` and `eval_count`.
121
+ - **OpenAI prompt caching**: added nested resolution of `prompt_tokens_details.cached_tokens`.
122
+ * **Preservation of `this` Context in Projections**:
123
+ In `lib/patch.js`, `wrapApply` now dispatches via `original.call(this, state, guard(event))`, ensuring complete compatibility with class-based projection handlers.
124
+ * **Crash-Proof Diagnostic Logging**:
125
+ In `complaint()`, object serialization is now safely protected with `try...catch` against circular structures and `BigInt` values.
126
+ * **Session-Aware Deduplication & Memory Bound**:
127
+ Warning deduplication now incorporates `sessionId` to avoid cross-session warning suppression. `told` cache size is bounded to 1,000 entries to prevent memory growth in long-running processes.
92
128
 
93
- ## How it attaches
129
+ ---
94
130
 
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.
131
+ ## 📦 Quick Installation
97
132
 
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.
133
+ ```bash
134
+ dsh plugin --profile web add @goodandready/dsh-usage-guard
135
+ ```
105
136
 
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.
137
+ > [!IMPORTANT]
138
+ > Restart DSH Web UI after installation (`systemctl --user restart dsh-web`) to activate protection and instantly revive any previously locked sessions.
108
139
 
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.
140
+ ---
113
141
 
114
- ## Structure
142
+ ## ⚙️ Configuration Reference (`settings.yaml`)
115
143
 
144
+ ```yaml
145
+ dsh-usage-guard:
146
+ repair: true
147
+ report: true
116
148
  ```
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
149
 
128
- Host half only — no browser code. No npm runtime dependencies, no build step,
129
- plain ESM.
150
+ | Parameter | Type | Default | Description |
151
+ |---|---|---|---|
152
+ | `repair` | `boolean` | `true` | Replace missing or non-numeric token counters with zero before arithmetic accumulation |
153
+ | `report` | `boolean` | `true` | Log diagnostic warning lines naming turn, step, and raw sample when damaged metrics arrive |
154
+
155
+ ---
130
156
 
131
- ## License
157
+ ## 📄 License
132
158
 
133
- MIT
159
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.ru.md ADDED
@@ -0,0 +1,156 @@
1
+ # 📦 @goodandready/dsh-usage-guard
2
+
3
+ <div align="center">
4
+
5
+ <h3>Санитайзер расхода токенов, защита от сбоев истории и сохранение сессий для DeepSeek Harness</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-usage-guard"><img src="https://img.shields.io/npm/v/@goodandready/dsh-usage-guard.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/Все_проекты_автора-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="Все проекты автора"></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="README.md"><b>🇬🇧 English</b></a> •
20
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
+ </p>
23
+
24
+ </div>
25
+
26
+ ---
27
+
28
+ ## ⚡ В чём суть проблемы: как провайдеры ломают историю сессий
29
+
30
+ В ядре **DeepSeek Harness** подсчёт суммарного расхода токенов складывается из четырёх счётчиков:
31
+
32
+ ```javascript
33
+ uncachedInputTokens: usage.inputTokens, // Без подстраховки в ядре DSH!
34
+ outputTokens: usage.outputTokens, // Без подстраховки в ядре DSH!
35
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
36
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
37
+ ```
38
+
39
+ Поля кэша ядро подстраховывает через `?? 0`, а первые два берёт **как есть, без проверки на число**.
40
+
41
+ Когда сторонний провайдер, локальный движок инференса, шлюз или роутер присылает нестандартные названия полей, пустые значения или `NaN`, стандартное сложение (`total += usage.inputTokens`) превращает сумму расхода сессии в `NaN`.
42
+
43
+ После этого валидация схемы в ядре отвергает выжимку сессии:
44
+ ```
45
+ history unavailable for session "<session-id>": expected number, received NaN
46
+ ```
47
+
48
+ Поскольку история сессий в DSH рассчитывается на лету **путём повторного проигрывания журнала событий**, одна-единственная битая порция токенов навсегда блокирует открытие диалога в веб-интерфейсе.
49
+
50
+ ```mermaid
51
+ graph LR
52
+ subgraph Malformed [Поток данных провайдера]
53
+ API[Ответ модели] -->|Присылает prompt_tokens / NaN / null| Event[Событие сессии]
54
+ end
55
+
56
+ subgraph Unprotected [Без dsh-usage-guard]
57
+ Event --> DSHMath[Сложение расхода в ядре DSH]
58
+ DSHMath -->|total += NaN| Poison[🚨 Сумма расхода становится NaN]
59
+ Poison --> SchemaFail[Отказ проверки схемы]
60
+ SchemaFail --> DeadHistory[💥 История сессии заблокирована навсегда]
61
+ end
62
+
63
+ subgraph Guarded [С активным dsh-usage-guard]
64
+ Event --> Patch[Перехватчик sessionProjections]
65
+ Patch --> AliasCheck{Поиск по синонимам}
66
+ AliasCheck -->|prompt_tokens -> inputTokens| Restored[Восстановленное число]
67
+ AliasCheck -->|Если число отсутствует| ZeroFallback[Безопасный 0]
68
+ Restored --> SafeMath[Корректное сложение]
69
+ ZeroFallback --> SafeMath
70
+ SafeMath --> ValidHistory[✅ 100% Восстановленная и сохранная история]
71
+ end
72
+
73
+ style Malformed fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
74
+ style Unprotected fill:#311b1b,stroke:#f38ba8,stroke-width:2px,color:#cdd6f4
75
+ style Guarded fill:#181825,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
76
+ ```
77
+
78
+ ---
79
+
80
+ ## ✨ Ключевые возможности и механизмы защиты
81
+
82
+ ### 1. Мгновенное восстановление ранее сломанных сессий
83
+ Плагин **не модифицирует** файлы журналов на диске. Он встаёт перед операцией сложения в памяти. Так как проигрывание сессий идёт через эту же точку, **все ранее повреждённые сессии начинают открываться снова сразу после установки плагина**.
84
+
85
+ ### 2. Полный словарь синонимов полей (`borrowed`)
86
+ Прежде чем подставлять ноль, плагин ищет значения в общепринятых полях других API:
87
+
88
+ | Целевое поле DSH | Распознаваемые синонимы |
89
+ |---|---|
90
+ | `inputTokens` | `input_tokens`, `input`, `promptTokens`, `prompt_tokens`, `promptTokenCount`, `prompt_eval_count` |
91
+ | `outputTokens` | `output_tokens`, `output`, `completionTokens`, `completion_tokens`, `candidatesTokenCount`, `eval_count` |
92
+ | `cacheReadTokens` | `cache_read_tokens`, `cachedTokens`, `cached_tokens`, `cache_read_input_tokens`, `cachedContentTokenCount`, `prompt_tokens_details.cached_tokens` |
93
+ | `cacheWriteTokens` | `cache_write_tokens`, `cacheCreationTokens`, `cache_creation_input_tokens` |
94
+
95
+ ### 3. Проверка на конечное неотрицательное число (`sound`)
96
+ Проверка `typeof value === 'number' && Number.isFinite(value) && value >= 0` гарантирует отсечение `NaN`, `Infinity`, `null`, `undefined`, отрицательных чисел (напр. `-1` при ошибках прокси) и строк.
97
+
98
+ ### 4. Безопасная подстановка нуля (`repaired`)
99
+ Если число найти не удалось, подставляется `0`. Выбор делается в пользу приблизительного счёта вместо полностью заблокированной истории.
100
+
101
+ ### 5. Безопасный перехват в памяти (`lib/patch.js`)
102
+ * **Существующие проекции**: оборачивает все методы `.apply` в `sessionProjections.registrations` с полным сохранением контекста `this`.
103
+ * **Поздние проекции**: перехватывает будущие регистрации через хук `map.set`.
104
+ * **Комплексная защита**: защищает не только токены, но и формулы давления на контекст и разбор занятости.
105
+ * **Нулевой оверхед**: поверхностное клонирование применяется только к объекту расхода.
106
+
107
+ ### 6. Дедуплицированное журналирование (`told`)
108
+ Выводит предупреждение с идентификатором сессии, номером хода, шагом и способом починки (взят по синониму или обнулён). Одинаковые инциденты не спамят в лог при повторных проигрываниях, а размер кэша ограничен 1000 записей для предотвращения утечек памяти.
109
+
110
+ ---
111
+
112
+ ## 🚀 Изменения в версии v0.1.1 (Changed in v0.1.1)
113
+
114
+ * **Защита от отрицательных чисел (`nonnegative`)**:
115
+ В v0.1.0 значение `-1` (возвращаемое некоторыми шлюзами при ошибках) проходило проверку конечности числа и ломало схему DSH (`z.number().int().nonnegative()`). Начиная с v0.1.1 функция `sound()` строго требует `value >= 0`. Отрицательные значения теперь признаются повреждёнными и безопасно обнуляются.
116
+ * **Безопасная коэрция числовых строк (Stringified Numbers)**:
117
+ Если провайдер возвращает валидное количество токенов строкой (например, `inputTokens: "1540"`), плагин в v0.1.1 больше не сбрасывает его в ноль, а безопасно приводит к настоящему числу (`1540`).
118
+ * **Расширенная совместимость с экосистемами моделей**:
119
+ - **Google Gemini API**: добавлены `promptTokenCount`, `candidatesTokenCount`, `cachedContentTokenCount`.
120
+ - **Ollama native API**: добавлены `prompt_eval_count`, `eval_count`.
121
+ - **OpenAI prompt caching**: добавлена поддержка вложенного объекта `prompt_tokens_details.cached_tokens`.
122
+ * **Сохранение контекста вызова `this` в проекциях**:
123
+ В `lib/patch.js` метод `wrapApply` переписан с сохранением `this` (`original.call(this, state, guard(event))`), обеспечивая полную совместимость с объектно-ориентированными проекциями.
124
+ * **Отказоустойчивое логирование без риска краша**:
125
+ В функции `complaint()` сериализация данных экранирована `try...catch`, предотвращая падение рантайма при наличии циклических структур или `BigInt`.
126
+ * **Изолированная межсессионная дедупликация**:
127
+ Ключ дедупликации предупреждений дополнен контекстом сессии (`sessionId`), что предотвращает ложное подавление сообщений об ошибках в последующих диалогах. Множество `told` ограничено 1000 элементами для защиты от утечек памяти.
128
+
129
+ ---
130
+
131
+ ## 📦 Быстрая установка
132
+
133
+ ```bash
134
+ dsh plugin --profile web add @goodandready/dsh-usage-guard
135
+ ```
136
+
137
+ ---
138
+
139
+ ## ⚙️ Параметры конфигурации (`settings.yaml`)
140
+
141
+ ```yaml
142
+ dsh-usage-guard:
143
+ repair: true
144
+ report: true
145
+ ```
146
+
147
+ | Параметр | Тип | По умолчанию | Описание |
148
+ |---|---|---|---|
149
+ | `repair` | `boolean` | `true` | Заменять пропущенные и нечисловые значения на ноль до сложения |
150
+ | `report` | `boolean` | `true` | Логировать информацию о поврежденных порциях в консоль |
151
+
152
+ ---
153
+
154
+ ## 📄 Лицензия
155
+
156
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/README.zh.md ADDED
@@ -0,0 +1,114 @@
1
+ # 📦 @goodandready/dsh-usage-guard
2
+
3
+ <div align="center">
4
+
5
+ <h3>DeepSeek Harness 会话用量数据清洗、历史记录防崩与 Token 算术保护插件</h3>
6
+
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@goodandready/dsh-usage-guard"><img src="https://img.shields.io/npm/v/@goodandready/dsh-usage-guard.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/作者全部项目-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="作者全部项目"></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="README.md"><b>🇬🇧 English</b></a> •
20
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
+ </p>
23
+
24
+ </div>
25
+
26
+ ---
27
+
28
+ ## ⚡ 核心痛点深度剖析:上游格式异常如何导致会话历史永久损坏
29
+
30
+ 在 **DeepSeek Harness** 核心架构中,会话投影累加 4 类 Token 计数:
31
+
32
+ ```javascript
33
+ uncachedInputTokens: usage.inputTokens, // 核心源码无默认兜底保护!
34
+ outputTokens: usage.outputTokens, // 核心源码无默认兜底保护!
35
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
36
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
37
+ ```
38
+
39
+ 当第三方服务商、本地引擎或网关返回非标字段、空值或 `NaN` 时,累加逻辑 (`total += usage.inputTokens`) 会将累计用量污染为 `NaN`,导致架构模式校验致命拒绝:
40
+ ```
41
+ history unavailable for session "<session-id>": expected number, received NaN
42
+ ```
43
+
44
+ 由于 DSH 历史记录是通过重放事件流动态计算生成的,**单个损坏的数据块会导致整个历史对话永久白屏且无法重新打开**。
45
+
46
+ ```mermaid
47
+ graph LR
48
+ subgraph Malformed [上游返回异常格式]
49
+ API[模型输出流] -->|返回 prompt_tokens / NaN / null| Event[会话事件]
50
+ end
51
+
52
+ subgraph Unprotected [未开启防护]
53
+ Event --> DSHMath[DSH 原生累加运算]
54
+ DSHMath -->|total += NaN| Poison[🚨 累计用量全变为 NaN]
55
+ Poison --> SchemaFail[模式校验致命失败]
56
+ SchemaFail --> DeadHistory[💥 历史记录永久锁定损坏]
57
+ end
58
+
59
+ subgraph Guarded [开启 dsh-usage-guard]
60
+ Event --> Patch[sessionProjections 拦截器]
61
+ Patch --> AliasCheck{同义名字典检索}
62
+ AliasCheck -->|prompt_tokens -> inputTokens| Restored[恢复有效数字]
63
+ AliasCheck -->|缺失字段| ZeroFallback[安全 0 值兜底]
64
+ Restored --> SafeMath[安全累加计算]
65
+ ZeroFallback --> SafeMath
66
+ SafeMath --> ValidHistory[✅ 100% 修复并完好读取会话历史]
67
+ end
68
+
69
+ style Malformed fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
70
+ style Unprotected fill:#311b1b,stroke:#f38ba8,stroke-width:2px,color:#cdd6f4
71
+ style Guarded fill:#181825,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
72
+ ```
73
+
74
+ ---
75
+
76
+ ## ✨ 核心亮点与保护机制
77
+
78
+ 1. **已损坏历史记录免修文件即刻复活**:不修改磁盘日志,在内存重放链路拦截修复;
79
+ 2. **主流别名字典智能提取 (`borrowed`)**:覆盖 `prompt_tokens`、`completion_tokens`、`cached_tokens`、`promptTokenCount`、`prompt_eval_count` 等;
80
+ 3. **非负有限数字安全性校验 (`sound`)**:剔除 `NaN`、`Infinity`、负数错误码(如 `-1`)与非法格式;
81
+ 4. **安全 0 值兜底 (`repaired`)**:彻底阻断算术污染;
82
+ 5. **内存投影注册表动态切入 (`lib/patch.js`)**:无缝覆盖前置与后置投影,完整保留 `this` 上下文;
83
+ 6. **防刷屏告警与内存保护 (`told`)**:集成会话识别与容量上限(1,000 项),杜绝内存泄漏与误抑制。
84
+
85
+ ---
86
+
87
+ ## 🚀 v0.1.1 版本更新说明 (Changed in v0.1.1)
88
+
89
+ * **负数穿透防护 (`nonnegative`)**:
90
+ 在 v0.1.0 中,部分代理网关返回的 `-1` 能通过有限数检测,进而导致 DSH 模式校验报错。v0.1.1 严格要求 `value >= 0`,负数将被识别为损坏并安全置 0。
91
+ * **数字字符串安全转换 (Stringified Numbers)**:
92
+ 部分上游返回的字符串数字(如 `inputTokens: "1540"`)在 v0.1.1 中将安全转换为真正数值,不再误重置为 0。
93
+ * **主流模型生态别名扩充**:
94
+ - **Google Gemini API**:新增 `promptTokenCount`、`candidatesTokenCount`、`cachedContentTokenCount`。
95
+ - **Ollama native API**:新增 `prompt_eval_count`、`eval_count`。
96
+ - **OpenAI 缓存详情**:新增嵌套对象 `prompt_tokens_details.cached_tokens` 支持。
97
+ * **投影函数 `this` 上下文维持**:
98
+ 修复 `wrapApply` 调用时缺失 `this` 的问题,确保与对象方法式投影兼容。
99
+ * **日志防崩防护与跨会话隔离**:
100
+ 日志格式化引入 `try...catch` 兜底,防止循环引用导致服务崩溃;告警去重引入会话维度标记,杜绝跨会话误抑制。
101
+
102
+ ---
103
+
104
+ ## 📦 安装指南
105
+
106
+ ```bash
107
+ dsh plugin --profile web add @goodandready/dsh-usage-guard
108
+ ```
109
+
110
+ ---
111
+
112
+ ## 📄 开源协议
113
+
114
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
package/lib/index.js CHANGED
@@ -56,10 +56,12 @@ export function apply(ctx, config) {
56
56
  sctx.effect(() => scope.watch((next) => { live = next ?? live }))
57
57
  })
58
58
 
59
- // О каждом сочетании хода, шага и набора полей жалуемся один раз: выжимка
60
- // считается проигрыванием журнала заново, и без этого одна и та же порция
61
- // въезжала бы в журнал при каждом открытии истории.
59
+ // О каждом сочетании сессии, хода, шага и набора полей жалуемся один раз:
60
+ // выжимка считается проигрыванием журнала заново, и без этого одна и та же
61
+ // порция въезжала бы в журнал при каждом открытии истории.
62
+ // Ограничиваем размер (до 1000 записей), чтобы исключить утечку памяти.
62
63
  const told = new Set()
64
+ const MAX_TOLD = 1000
63
65
 
64
66
  const guard = (event) => {
65
67
  const found = usageOf(event)
@@ -68,11 +70,19 @@ export function apply(ctx, config) {
68
70
  if (bad.length === 0) return event
69
71
 
70
72
  if (live.report !== false) {
71
- const seen = `${String(found.turn)}/${String(found.step)}/${bad.join(',')}`
73
+ const sessionTag = event?.sessionId ?? event?.data?.sessionId ?? event?.data?.turn ?? 'global'
74
+ const seen = `${String(sessionTag)}/${String(found.turn)}/${String(found.step)}/${bad.join(',')}`
72
75
  if (!told.has(seen)) {
76
+ if (told.size >= MAX_TOLD) {
77
+ told.clear()
78
+ }
73
79
  told.add(seen)
74
- // eslint-disable-next-line no-console
75
- console.warn(`[dsh-usage-guard] ${complaint(found, bad)}`)
80
+ try {
81
+ // eslint-disable-next-line no-console
82
+ console.warn(`[dsh-usage-guard] ${complaint(found, bad)}`)
83
+ } catch {
84
+ // Ошибка вывода не должна ломать обработку событий
85
+ }
76
86
  }
77
87
  }
78
88
 
@@ -85,4 +95,4 @@ export function apply(ctx, config) {
85
95
  'dsh-usage-guard: обезвреживание порций расхода',
86
96
  )
87
97
  })
88
- }
98
+ }
package/lib/patch.js CHANGED
@@ -22,14 +22,16 @@
22
22
  export const PATCHED = Symbol.for('dsh-usage-guard.patched')
23
23
 
24
24
  /**
25
- * Обернуть складывание одной записи реестра.
25
+ * Обернуть складывание одной записи реестра с сохранением контекста this.
26
26
  *
27
27
  * @returns функция возврата к исходному состоянию.
28
28
  */
29
29
  export function wrapApply(def, guard) {
30
30
  if (!def || typeof def.apply !== 'function' || def.apply[PATCHED]) return () => {}
31
31
  const original = def.apply
32
- const patched = (state, event) => original(state, guard(event))
32
+ const patched = function (state, event) {
33
+ return original.call(this, state, guard(event))
34
+ }
33
35
  patched[PATCHED] = true
34
36
  def.apply = patched
35
37
  return () => {
@@ -73,4 +75,4 @@ export function patchRegistry(registry, guard) {
73
75
  for (const step of undo.reverse()) step()
74
76
  undo.length = 0
75
77
  }
76
- }
78
+ }
package/lib/usage.js CHANGED
@@ -20,15 +20,58 @@ export const FIELDS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheW
20
20
  * не первый ход.
21
21
  */
22
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'],
23
+ inputTokens: [
24
+ 'input_tokens',
25
+ 'input',
26
+ 'promptTokens',
27
+ 'prompt_tokens',
28
+ 'promptTokenCount',
29
+ 'prompt_eval_count',
30
+ ],
31
+ outputTokens: [
32
+ 'output_tokens',
33
+ 'output',
34
+ 'completionTokens',
35
+ 'completion_tokens',
36
+ 'candidatesTokenCount',
37
+ 'eval_count',
38
+ ],
39
+ cacheReadTokens: [
40
+ 'cache_read_tokens',
41
+ 'cachedTokens',
42
+ 'cached_tokens',
43
+ 'cache_read_input_tokens',
44
+ 'cachedContentTokenCount',
45
+ ],
46
+ cacheWriteTokens: [
47
+ 'cache_write_tokens',
48
+ 'cacheCreationTokens',
49
+ 'cache_creation_input_tokens',
50
+ ],
27
51
  }
28
52
 
29
- /** Годное значение счётчика: конечное число, а не `undefined`, `null` или `NaN`. */
53
+ /**
54
+ * Годное значение счётчика: конечное неотрицательное число.
55
+ * Значения меньше нуля (-1 и т.д.) или нечисловые не признаются годными,
56
+ * так как схема DSH требует nonnegative integer.
57
+ */
30
58
  function sound(value) {
31
- return typeof value === 'number' && Number.isFinite(value)
59
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
60
+ }
61
+
62
+ /**
63
+ * Приведение значения к корректному неотрицательному числу.
64
+ * Поддерживает числа и строковые представления целых неотрицательных чисел (напр. "1540").
65
+ */
66
+ function coerceNumber(value) {
67
+ if (typeof value === 'number') {
68
+ return Number.isFinite(value) && value >= 0 ? value : undefined
69
+ }
70
+ if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
71
+ const num = Number(value.trim())
72
+ return Number.isFinite(num) && num >= 0 ? num : undefined
73
+ }
74
+ return undefined
32
75
  }
33
76
 
34
77
  /**
@@ -57,8 +100,8 @@ export function usageOf(event) {
57
100
  *
58
101
  * Отсутствие кэш-полей нарушением не считается: их не присылает добрая половина
59
102
  * провайдеров, и харнесс сам подставляет там ноль. Ругаемся только на то, что
60
- * действительно ломает сложение, — на пропущенные или нечисловые значения тех
61
- * полей, которые харнесс берёт без оглядки.
103
+ * действительно ломает сложение, — на пропущенные, нечисловые или отрицательные
104
+ * значения тех полей, которые харнесс берёт без оглядки, либо на невалидные кэш-поля.
62
105
  */
63
106
  export function damage(usage) {
64
107
  if (!usage || typeof usage !== 'object') return FIELDS.slice(0, 2)
@@ -72,11 +115,30 @@ export function damage(usage) {
72
115
  return bad
73
116
  }
74
117
 
75
- /** Число под одним из привычных чужих имён — или `undefined`, если его там нет. */
118
+ /**
119
+ * Число под одним из привычных чужих имён, вложенных структур или строковых чисел.
120
+ * Возвращает валидное неотрицательное число или `undefined`, если его там нет.
121
+ */
76
122
  export function borrowed(usage, field) {
123
+ if (!usage || typeof usage !== 'object') return undefined
124
+
125
+ // 1. Поиск по известным синонимам
77
126
  for (const alias of ALIASES[field] ?? []) {
78
- if (sound(usage[alias])) return usage[alias]
127
+ const num = coerceNumber(usage[alias])
128
+ if (num !== undefined) return num
129
+ }
130
+
131
+ // 2. Поиск во вложенных деталях кэша (OpenAI-style: prompt_tokens_details.cached_tokens)
132
+ if (field === 'cacheReadTokens' && usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
133
+ const details = usage.prompt_tokens_details
134
+ const num = coerceNumber(details.cached_tokens ?? details.cachedTokens)
135
+ if (num !== undefined) return num
79
136
  }
137
+
138
+ // 3. Само поле, если оно было передано валидной строкой (напр. "123")
139
+ const selfNum = coerceNumber(usage[field])
140
+ if (selfNum !== undefined) return selfNum
141
+
80
142
  return undefined
81
143
  }
82
144
 
@@ -120,11 +182,24 @@ export function healed(event, bad) {
120
182
  return { ...event, data: { ...event.data, usage } }
121
183
  }
122
184
 
185
+ /** Безопасная сериализация для журнала: предотвращает сбои на циклических объектах и BigInt. */
186
+ function safeStringify(value) {
187
+ try {
188
+ return JSON.stringify(value)
189
+ } catch {
190
+ try {
191
+ return String(value)
192
+ } catch {
193
+ return '[не сериализуется]'
194
+ }
195
+ }
196
+ }
197
+
123
198
  /** Короткая строка для журнала: по ней видно, кого звать к ответу. */
124
199
  export function complaint(found, bad) {
125
200
  const where = `ход ${String(found.turn)}, шаг ${String(found.step)}`
126
201
  const taken = bad
127
202
  .map((field) => (found.usage && borrowed(found.usage, field) !== undefined ? `${field} взят по синониму` : `${field} обнулён`))
128
203
  .join('; ')
129
- return `порция расхода не по форме — ${where}; пришло: ${JSON.stringify(found.usage)}; ${taken}`
130
- }
204
+ return `порция расхода не по форме — ${where}; пришло: ${safeStringify(found.usage)}; ${taken}`
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-usage-guard",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
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
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -29,6 +29,8 @@
29
29
  "lib/",
30
30
  "cordis.patch.yml",
31
31
  "README.md",
32
+ "README.ru.md",
33
+ "README.zh.md",
32
34
  "LICENSE"
33
35
  ],
34
36
  "license": "MIT",
@@ -47,4 +49,4 @@
47
49
  "scripts": {
48
50
  "test": "node --test test/*.test.mjs"
49
51
  }
50
- }
52
+ }