@goodandready/dsh-usage-guard 0.1.2 → 0.1.3

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 CHANGED
@@ -92,23 +92,41 @@ Before substituting zero, `dsh-usage-guard` scans an extensive dictionary of ind
92
92
  | `cacheReadTokens` | `cache_read_tokens`, `cachedTokens`, `cached_tokens`, `cache_read_input_tokens`, `cachedContentTokenCount`, `prompt_tokens_details.cached_tokens` |
93
93
  | `cacheWriteTokens` | `cache_write_tokens`, `cacheCreationTokens`, `cache_creation_input_tokens` |
94
94
 
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.
95
+ ### 3. Finite Non-Negative Integer Soundness Validation (`sound`)
96
+ Strictly validates `typeof value === 'number' && Number.isFinite(value) && value >= 0 && Number.isInteger(value)` to filter out `NaN`, `Infinity`, `null`, `undefined`, negative error codes (e.g. `-1`), non-integer floats, and malformed strings.
97
97
 
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.
98
+ ### 4. Safe Zero Fallback & Float Rounding (`repaired`)
99
+ If a counter cannot be resolved from aliases, it is safely initialized to `0`. Fractional tokens or decimal strings are safely rounded via `Math.round()`, strictly satisfying the core DSH contract `z.number().int().nonnegative()`.
100
100
 
101
101
  ### 5. In-Memory Registry Monkey-Patching (`lib/patch.js`)
102
102
  * **Pre-existing Projections**: Wraps all `.apply` methods currently registered in `sessionProjections.registrations` while preserving full `this` context.
103
103
  * **Late-Binding Projections**: Traps future projection registrations via `map.set` wrapping, guaranteeing 100% coverage regardless of plugin loading order.
104
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.
105
+ * **Zero Performance Overhead**: Uses shallow event cloning only along the usage path, while a high-performance `WeakMap` cache ensures single execution across all 10–15 parallel DSH projections.
106
106
 
107
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.
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 bounded to 1,000 entries with O(1) FIFO eviction.
109
109
 
110
110
  ### 7. Native Web UI Settings Card (`lib/client.js`)
111
- * Mounts into the native Settings tab under `Settings → Plugins → Plugin Settings` (`settings.plugin.item`) with real-time status badge and full localization.
111
+ * Mounts into the native Settings tab under `Settings → Plugins → Plugin Settings` (`settings.plugin.item`) with real-time status badge, auto-dismissing save feedback, and full localization.
112
+
113
+ ---
114
+
115
+ ## 🚀 Changed in v0.1.3
116
+
117
+ * **Fractional Token Protection (Floats & Decimals)**:
118
+ - DeepSeek Harness `@deepseek-ai/dsh-token-meter` projection schema enforces strict integers (`z.number().int().nonnegative()`). Fractional tokens (e.g. `42.5` or `"1540.2"` produced by routing proxies or weighted estimators) previously broke Zod schema validation.
119
+ - `sound()` now strictly validates `Number.isInteger(value)`.
120
+ - Floating-point numbers and decimal strings are now safely rounded to non-negative integers via `Math.round()` (`42.6` $\rightarrow$ `43`), protecting session history from schema rejections.
121
+ * **High-Performance WeakMap Cache (`guard`)**:
122
+ - DSH executes 10–15 parallel projection folds for every session event.
123
+ - A `WeakMap<event, guardedEvent>` cache sanitizes each incoming event exactly once on the first projection, returning the cached normalized reference to all subsequent projections in $O(1)$ without re-parsing or memory leak risks.
124
+ * **Strict O(1) FIFO Eviction in `told` Warning Cache**:
125
+ - Replaced bulk `told.clear()` with individual oldest key eviction `told.delete(oldest)` at 1,000 entries, maintaining continuous deduplication without sudden re-logging storms.
126
+ * **Web UI Settings Card UX & A11y Polish**:
127
+ - Save status confirmation ("Saved") now automatically auto-dismisses after 3 seconds and clears immediately upon toggle adjustment.
128
+ - Added reactive external synchronization with server-side config changes when the user has no uncommitted draft.
129
+ - Enhanced accessibility: connected inputs to labels via `htmlFor`/`id` and tagged the chevron icon with `aria-hidden="true"`.
112
130
 
113
131
  ---
114
132
 
package/README.ru.md CHANGED
@@ -92,23 +92,41 @@ graph LR
92
92
  | `cacheReadTokens` | `cache_read_tokens`, `cachedTokens`, `cached_tokens`, `cache_read_input_tokens`, `cachedContentTokenCount`, `prompt_tokens_details.cached_tokens` |
93
93
  | `cacheWriteTokens` | `cache_write_tokens`, `cacheCreationTokens`, `cache_creation_input_tokens` |
94
94
 
95
- ### 3. Проверка на конечное неотрицательное число (`sound`)
96
- Проверка `typeof value === 'number' && Number.isFinite(value) && value >= 0` гарантирует отсечение `NaN`, `Infinity`, `null`, `undefined`, отрицательных чисел (напр. `-1` при ошибках прокси) и строк.
95
+ ### 3. Проверка на конечное неотрицательное целое число (`sound`)
96
+ Проверка `typeof value === 'number' && Number.isFinite(value) && value >= 0 && Number.isInteger(value)` гарантирует отсечение `NaN`, `Infinity`, `null`, `undefined`, отрицательных чисел (напр. `-1`), нецелых дробей (floats) и строк.
97
97
 
98
- ### 4. Безопасная подстановка нуля (`repaired`)
99
- Если число найти не удалось, подставляется `0`. Выбор делается в пользу приблизительного счёта вместо полностью заблокированной истории.
98
+ ### 4. Безопасная подстановка нуля и округление (`repaired`)
99
+ Если число найти не удалось, подставляется `0`. Валидные дробные токены или числовые строки округляются до целых через `Math.round()`, строго удовлетворяя Zod-контракту ядра DSH `z.number().int().nonnegative()`.
100
100
 
101
101
  ### 5. Безопасный перехват в памяти (`lib/patch.js`)
102
102
  * **Существующие проекции**: оборачивает все методы `.apply` в `sessionProjections.registrations` с полным сохранением контекста `this`.
103
103
  * **Поздние проекции**: перехватывает будущие регистрации через хук `map.set`.
104
104
  * **Комплексная защита**: защищает не только токены, но и формулы давления на контекст и разбор занятости.
105
- * **Нулевой оверхед**: поверхностное клонирование применяется только к объекту расхода.
105
+ * **Нулевой оверхед**: поверхностное клонирование применяется только к объекту расхода, а `WeakMap` устраняет повторные вызовы при обходе 10–15 проекций DSH.
106
106
 
107
107
  ### 6. Дедуплицированное журналирование (`told`)
108
- Выводит предупреждение с идентификатором сессии, номером хода, шагом и способом починки (взят по синониму или обнулён). Одинаковые инциденты не спамят в лог при повторных проигрываниях, а размер кэша ограничен 1000 записей для предотвращения утечек памяти.
108
+ Выводит предупреждение с идентификатором сессии, номером хода, шагом и способом починки (взят по синониму или обнулён). Одинаковые инциденты не спамят в лог при повторных проигрываниях, а размер кэша ограничен 1000 записями с честным O(1) FIFO-вытеснением для предотвращения утечек памяти.
109
109
 
110
110
  ### 7. Нативная карточка в Web UI (`lib/client.js`)
111
- * Карточка настроек встроена во вкладку «Настройки → Плагины → Настройки плагинов» (`settings.plugin.item`) с динамическим бейджем статуса и полной локализацией.
111
+ * Карточка настроек встроена во вкладку «Настройки → Плагины → Настройки плагинов» (`settings.plugin.item`) с динамическим бейджем статуса, плавно исчезающим подтверждением сохранения и полной локализацией.
112
+
113
+ ---
114
+
115
+ ## 🚀 Изменения в версии v0.1.3 (Changed in v0.1.3)
116
+
117
+ * **Защита от дробных токенов (Floats & Decimals)**:
118
+ - Схема валидации DSH ядра `@deepseek-ai/dsh-token-meter` требует строго целые числа (`z.number().int().nonnegative()`). Дробные токены (например, `42.5` или `"1540.2"` при усреднении или конвертации весов моделями) ранее приводили к падению схемы Zod.
119
+ - В v0.1.3 функция `sound()` строго требует `Number.isInteger(value)`.
120
+ - Все дробные числа и числовые строки с плавающей точкой теперь безопасно и корректно округляются через `Math.round()` (`42.6` $\rightarrow$ `43`), сохраняя читаемость истории сессий.
121
+ * **Высокопроизводительное WeakMap-кэширование (`guard`)**:
122
+ - В DSH активно 10–15 параллельных проекций, каждая из которых вызывала перехватчик `apply()`.
123
+ - Внедрён микрокэш на базе `WeakMap<event, guardedEvent>`. Санитизация события выполняется ровно 1 раз при первой проекции, а остальные 14 проекций получают уже нормализованное событие за $O(1)$ без лишнего клонирования и без риска утечек памяти.
124
+ * **Честное FIFO-вытеснение в кэше логов (`told`)**:
125
+ - Заменена полная очистка `told.clear()` на удаление старейшей записи `told.delete(oldest)` за $O(1)$ при достижении лимита в 1000 записей, что предотвращает повторные лавины логов при открытии старых диалогов.
126
+ * **Улучшение UX и доступности Web UI карточки настроек**:
127
+ - Сообщение об успешном сохранении («Сохранено») теперь плавно исчезает через 3 секунды и мгновенно сбрасывается при любом изменении переключателей.
128
+ - Добавлена реактивная синхронизация настроек с фоновыми изменениями сервера при нередактируемом драфте.
129
+ - Улучшена доступность: добавлены атрибуты `id` и `htmlFor` к полям формы, а иконке шеврона проставлен `aria-hidden="true"`.
112
130
 
113
131
  ---
114
132
 
package/README.zh.md CHANGED
@@ -77,11 +77,29 @@ graph LR
77
77
 
78
78
  1. **已损坏历史记录免修文件即刻复活**:不修改磁盘日志,在内存重放链路拦截修复;
79
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
- 7. **原生 Web UI 设置卡片 (`lib/client.js`)**:嵌入 DSH 原生设置中心(`settings.plugin.item`),实时状态徽章与多语言支持。
80
+ 3. **严格非负整数有效性校验 (`sound`)**:剔除 `NaN`、`Infinity`、负数错误码(如 `-1`)、非整数浮点数与非法字符串;
81
+ 4. **安全 0 值兜底与浮点自动取整 (`repaired`)**:浮点数值与带小数数字字符串通过 `Math.round()` 转换为有效非负整数,彻底满足 Zod `z.number().int().nonnegative()` 模式校验;
82
+ 5. **内存投影注册表动态切入与 WeakMap 高性能缓存 (`lib/patch.js`, `lib/index.js`)**:无缝覆盖全部 10–15 个并发投影,同一事件仅处理一次,其余投影 $O(1)$ 快速返回;
83
+ 6. **防刷屏告警与 O(1) FIFO 内存保护 (`told`)**:容量上限为 1,000 项,达到上限时平滑淘汰最老记录,杜绝内存泄漏与误抑制;
84
+ 7. **原生 Web UI 设置卡片 (`lib/client.js`)**:嵌入 DSH 原生设置中心(`settings.plugin.item`),实时状态徽章、3 秒自动消失保存提示与多语言支持。
85
+
86
+ ---
87
+
88
+ ## 🚀 v0.1.3 版本更新说明 (Changed in v0.1.3)
89
+
90
+ * **浮点与小数 Token 防崩保护 (Floats & Decimals)**:
91
+ - DeepSeek Harness `@deepseek-ai/dsh-token-meter` 的投影模式严格要求整数 (`z.number().int().nonnegative()`)。部分路由网关返回的非整数计数(如 `42.5`)此前会导致 Zod 校验失败。
92
+ - `sound()` 函数在 v0.1.3 中严格要求 `Number.isInteger(value)`。
93
+ - 浮点数及带小数点的数字字符串通过 `Math.round()` 自动四舍五入为有效非负整数(`42.6` $\rightarrow$ `43`),确保会话历史永不白屏。
94
+ * **WeakMap 高性能事件缓存 (`guard`)**:
95
+ - 会话重放时有 10–15 个并发投影同时调用拦截器。
96
+ - 引入 `WeakMap<event, guardedEvent>` 微缓存:事件在首次调用时清洗并缓存,其余并发投影直接以 $O(1)$ 获取结果,消除重复克隆并杜绝内存泄漏。
97
+ * **日志告警缓存 FIFO 平滑淘汰 (`told`)**:
98
+ - 将达到 1,000 条上限时的全量清空 `told.clear()` 改为 $O(1)$ 单条最旧数据淘汰 `told.delete(oldest)`,避免重新打开历史会话时日志重复刷屏。
99
+ * **Web UI 设置卡片交互体验与无障碍优化**:
100
+ - 保存成功提示("已保存")支持 3 秒自动平滑淡出,且在修改任一复选框时立即重置。
101
+ - 支持在无未提交草稿时平滑同步服务端后台配置。
102
+ - 为表单项绑定 `id`/`htmlFor` 关联,并为折叠图标补充 `aria-hidden="true"` 无障碍属性。
85
103
 
86
104
  ---
87
105
 
package/lib/client.js CHANGED
@@ -22,6 +22,8 @@ window.__ModuleLoader__.load({
22
22
  stroke: 'currentColor',
23
23
  strokeWidth: 1.5,
24
24
  style: props.style,
25
+ className: props.className,
26
+ 'aria-hidden': true,
25
27
  }, React.createElement('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' }));
26
28
  }
27
29
  const Chevron = ChevronIcon || FallbackChevron;
@@ -34,6 +36,14 @@ window.__ModuleLoader__.load({
34
36
  const [msg, setMsg] = React.useState('');
35
37
 
36
38
  const initializedRef = React.useRef(false);
39
+ const isDirtyRef = React.useRef(false);
40
+ const msgTimerRef = React.useRef(null);
41
+
42
+ React.useEffect(() => {
43
+ return () => {
44
+ if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
45
+ };
46
+ }, []);
37
47
 
38
48
  React.useEffect(() => {
39
49
  let off = null;
@@ -46,7 +56,7 @@ window.__ModuleLoader__.load({
46
56
  const update = () => {
47
57
  const s = scope.getSnapshot ? scope.getSnapshot() : { status: 'unavailable', value: null };
48
58
  setSnap(s);
49
- if (s.status === 'ready' && s.value && !initializedRef.current) {
59
+ if (s.status === 'ready' && s.value && (!initializedRef.current || !isDirtyRef.current)) {
50
60
  initializedRef.current = true;
51
61
  setDraft({
52
62
  repair: s.value.repair ?? true,
@@ -72,12 +82,22 @@ window.__ModuleLoader__.load({
72
82
  }
73
83
  })();
74
84
 
85
+ const showMessage = (text, autoDismiss = false) => {
86
+ if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
87
+ setMsg(text);
88
+ if (autoDismiss) {
89
+ msgTimerRef.current = setTimeout(() => {
90
+ setMsg('');
91
+ }, 3000);
92
+ }
93
+ };
94
+
75
95
  const onSave = async () => {
76
96
  setSaving(true);
77
97
  setMsg('');
78
98
  try {
79
99
  if (!ctx.settingsScope || !ctx.settingsScope.bind) {
80
- setMsg('settingsScope unavailable');
100
+ showMessage('settingsScope unavailable', false);
81
101
  setSaving(false);
82
102
  return;
83
103
  }
@@ -91,16 +111,26 @@ window.__ModuleLoader__.load({
91
111
  }
92
112
  }
93
113
  if (errs.length) {
94
- setMsg(errs.join('; '));
114
+ showMessage(errs.join('; '), false);
95
115
  } else {
96
- setMsg(t('saved') || 'Saved');
116
+ isDirtyRef.current = false;
117
+ showMessage(t('saved') || 'Saved', true);
97
118
  }
98
119
  } catch (e) {
99
- setMsg(String(e && e.message ? e.message : e));
120
+ showMessage(String(e && e.message ? e.message : e), false);
100
121
  }
101
122
  setSaving(false);
102
123
  };
103
124
 
125
+ const handleToggle = (field, checked) => {
126
+ isDirtyRef.current = true;
127
+ if (msg) {
128
+ if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
129
+ setMsg('');
130
+ }
131
+ setDraft((prev) => ({ ...prev, [field]: checked }));
132
+ };
133
+
104
134
  const isRepairActive = draft.repair !== false;
105
135
  const badgeLabel = isRepairActive ? (t('active') || 'ACTIVE') : (t('reportOnly') || 'REPORT ONLY');
106
136
  const badgeClass = isRepairActive ? 'ug-badge ug-badge-green' : 'ug-badge ug-badge-yellow';
@@ -132,32 +162,34 @@ window.__ModuleLoader__.load({
132
162
  React.createElement('span', { className: 'ug-title' }, t('title') || 'Usage Guard'),
133
163
  React.createElement('span', { className: 'ug-sub' }, t('subtitle') || 'Token-usage sanitizer & session crash protection'),
134
164
  React.createElement('span', { className: badgeClass }, badgeLabel),
135
- React.createElement(Chevron, { className: 'ug-chev' + (expanded ? ' ug-chev-open' : '') })
165
+ React.createElement(Chevron, { className: 'ug-chev' + (expanded ? ' ug-chev-open' : ''), 'aria-hidden': true })
136
166
  ),
137
167
  expanded ? React.createElement('div', { className: 'ug-body' },
138
168
  snap.status === 'loading' ? React.createElement('div', { className: 'ug-field', style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } }, t('loading') || 'Loading…') : null,
139
169
  snap.status === 'unavailable' ? React.createElement('div', { className: 'ug-field', style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } }, t('unavailable') || 'Settings scope unavailable') : null,
140
170
  snap.status === 'ready' ? React.createElement('div', null,
141
171
  React.createElement('div', { className: 'ug-field' },
142
- React.createElement('label', { className: 'ug-row' },
172
+ React.createElement('label', { className: 'ug-row', htmlFor: 'ug-repair-toggle' },
143
173
  React.createElement('span', null, t('repair') || 'Auto-repair token counters'),
144
174
  React.createElement('input', {
175
+ id: 'ug-repair-toggle',
145
176
  type: 'checkbox',
146
177
  className: 'ug-checkbox',
147
178
  checked: !!draft.repair,
148
- onChange: (e) => setDraft({ ...draft, repair: e.target.checked }),
179
+ onChange: (e) => handleToggle('repair', e.target.checked),
149
180
  })
150
181
  ),
151
182
  React.createElement('div', { className: 'ug-sub' }, t('repairDesc') || 'Replace missing or non-numeric counters with 0 before summing to prevent NaN history lockups')
152
183
  ),
153
184
  React.createElement('div', { className: 'ug-field' },
154
- React.createElement('label', { className: 'ug-row' },
185
+ React.createElement('label', { className: 'ug-row', htmlFor: 'ug-report-toggle' },
155
186
  React.createElement('span', null, t('report') || 'Log diagnostic warnings'),
156
187
  React.createElement('input', {
188
+ id: 'ug-report-toggle',
157
189
  type: 'checkbox',
158
190
  className: 'ug-checkbox',
159
191
  checked: !!draft.report,
160
- onChange: (e) => setDraft({ ...draft, report: e.target.checked }),
192
+ onChange: (e) => handleToggle('report', e.target.checked),
161
193
  })
162
194
  ),
163
195
  React.createElement('div', { className: 'ug-sub' }, t('reportDesc') || 'Log a line naming the turn, step, and raw sample whenever damaged usage arrives')
package/lib/index.js CHANGED
@@ -59,22 +59,39 @@ export function apply(ctx, config) {
59
59
  // О каждом сочетании сессии, хода, шага и набора полей жалуемся один раз:
60
60
  // выжимка считается проигрыванием журнала заново, и без этого одна и та же
61
61
  // порция въезжала бы в журнал при каждом открытии истории.
62
- // Ограничиваем размер (до 1000 записей), чтобы исключить утечку памяти.
62
+ // Честное FIFO-вытеснение с ограничением в 1000 записей предотвращает утечку
63
+ // памяти при длительной работе без сброса всей накопленной дедупликации.
63
64
  const told = new Set()
64
65
  const MAX_TOLD = 1000
65
66
 
67
+ // Кэш санитизированных событий: каждое событие проходит через 10-15 проекций DSH.
68
+ // WeakMap позволяет обработать событие один раз, отдавая остальным проекциям
69
+ // готовый результат за O(1) без повторного парсинга и без утечек памяти.
70
+ const eventCache = new WeakMap()
71
+
66
72
  const guard = (event) => {
73
+ if (!event || typeof event !== 'object') return event
74
+ const cached = eventCache.get(event)
75
+ if (cached !== undefined) return cached
76
+
67
77
  const found = usageOf(event)
68
- if (!found) return event
78
+ if (!found) {
79
+ eventCache.set(event, event)
80
+ return event
81
+ }
69
82
  const bad = damage(found.usage)
70
- if (bad.length === 0) return event
83
+ if (bad.length === 0) {
84
+ eventCache.set(event, event)
85
+ return event
86
+ }
71
87
 
72
88
  if (live.report !== false) {
73
89
  const sessionTag = event?.sessionId ?? event?.data?.sessionId ?? event?.data?.turn ?? 'global'
74
90
  const seen = `${String(sessionTag)}/${String(found.turn)}/${String(found.step)}/${bad.join(',')}`
75
91
  if (!told.has(seen)) {
76
92
  if (told.size >= MAX_TOLD) {
77
- told.clear()
93
+ const oldest = told.values().next().value
94
+ told.delete(oldest)
78
95
  }
79
96
  told.add(seen)
80
97
  try {
@@ -86,7 +103,9 @@ export function apply(ctx, config) {
86
103
  }
87
104
  }
88
105
 
89
- return live.repair === false ? event : healed(event, bad)
106
+ const result = live.repair === false ? event : healed(event, bad)
107
+ eventCache.set(event, result)
108
+ return result
90
109
  }
91
110
 
92
111
  ctx.inject(['sessionProjections'], (pctx) => {
package/lib/usage.js CHANGED
@@ -1,13 +1,16 @@
1
- // Чистая часть заплатки: что считать испорченной порцией расхода и во что её
2
- // превращать. Ни контекста, ни сети — поэтому проверяется тестами целиком.
3
-
4
- /**
5
- * Поля, из которых харнесс собирает свои четыре ведра.
6
- *
7
- * Два первых он берёт как есть, два вторых подстраховывает нулём. Отсюда и весь
8
- * сыр-бор: провайдер, приславший порцию без `inputTokens`, роняет не свой
9
- * счётчик, а всю историю сессии.
10
- */
1
+ // Счётчики токенов: что проверяем и как чиним.
2
+ //
3
+ // Харнесс складывает расход в четыре ведра:
4
+ //
5
+ // uncachedInputTokens: usage.inputTokens,
6
+ // outputTokens: usage.outputTokens,
7
+ // cacheReadTokens: usage.cacheReadTokens ?? 0,
8
+ // cacheWriteTokens: usage.cacheWriteTokens ?? 0,
9
+ //
10
+ // Первые два он берёт без проверки. Сложение `total += undefined` даёт NaN,
11
+ // а проверка схемы в ядре требует неотрицательное целое число (zod.int().nonnegative()).
12
+ // Из-за этого одна битая порция валит не просто
13
+ // счётчик, а всю историю сессии.
11
14
  export const FIELDS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
12
15
 
13
16
  /**
@@ -51,25 +54,28 @@ const ALIASES = {
51
54
  }
52
55
 
53
56
  /**
54
- * Годное значение счётчика: конечное неотрицательное число.
55
- * Значения меньше нуля (-1 и т.д.) или нечисловые не признаются годными,
56
- * так как схема DSH требует nonnegative integer.
57
+ * Годное значение счётчика: конечное неотрицательное ЦЕЛОЕ число.
58
+ * Значения меньше нуля (-1 и т.д.), нечисловые, а также дробные числа (floats)
59
+ * не признаются годными, так как схема ядра DSH требует z.number().int().nonnegative().
57
60
  */
58
61
  function sound(value) {
59
- return typeof value === 'number' && Number.isFinite(value) && value >= 0
62
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && Number.isInteger(value)
60
63
  }
61
64
 
62
65
  /**
63
- * Приведение значения к корректному неотрицательному числу.
64
- * Поддерживает числа и строковые представления целых неотрицательных чисел (напр. "1540").
66
+ * Безопасное приведение значения к корректному неотрицательному целому числу.
67
+ * Округляет дробные числа и парсит числовые строки (в т.ч. с десятичной точкой).
65
68
  */
66
69
  function coerceNumber(value) {
67
70
  if (typeof value === 'number') {
68
- return Number.isFinite(value) && value >= 0 ? value : undefined
71
+ return Number.isFinite(value) && value >= 0 ? Math.round(value) : undefined
69
72
  }
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
+ if (typeof value === 'string') {
74
+ const trimmed = value.trim()
75
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
76
+ const num = Number(trimmed)
77
+ return Number.isFinite(num) && num >= 0 ? Math.round(num) : undefined
78
+ }
73
79
  }
74
80
  return undefined
75
81
  }
@@ -100,8 +106,8 @@ export function usageOf(event) {
100
106
  *
101
107
  * Отсутствие кэш-полей нарушением не считается: их не присылает добрая половина
102
108
  * провайдеров, и харнесс сам подставляет там ноль. Ругаемся только на то, что
103
- * действительно ломает сложение, — на пропущенные, нечисловые или отрицательные
104
- * значения тех полей, которые харнесс берёт без оглядки, либо на невалидные кэш-поля.
109
+ * действительно ломает сложение, — на пропущенные, нечисловые, нецелые или
110
+ * отрицательные значения.
105
111
  */
106
112
  export function damage(usage) {
107
113
  if (!usage || typeof usage !== 'object') return FIELDS.slice(0, 2)
@@ -116,8 +122,8 @@ export function damage(usage) {
116
122
  }
117
123
 
118
124
  /**
119
- * Число под одним из привычных чужих имён, вложенных структур или строковых чисел.
120
- * Возвращает валидное неотрицательное число или `undefined`, если его там нет.
125
+ * Число под одним из привычных чужих имён, вложенных структур или числовых строк/дробей.
126
+ * Возвращает валидное целое неотрицательное число или `undefined`, если его там нет.
121
127
  */
122
128
  export function borrowed(usage, field) {
123
129
  if (!usage || typeof usage !== 'object') return undefined
@@ -135,7 +141,7 @@ export function borrowed(usage, field) {
135
141
  if (num !== undefined) return num
136
142
  }
137
143
 
138
- // 3. Само поле, если оно было передано валидной строкой (напр. "123")
144
+ // 3. Само поле, если оно было передано валидной строкой или нецелым числом (напр. "123" или 15.4)
139
145
  const selfNum = coerceNumber(usage[field])
140
146
  if (selfNum !== undefined) return selfNum
141
147
 
@@ -145,11 +151,9 @@ export function borrowed(usage, field) {
145
151
  /**
146
152
  * Порция с обезвреженными счётчиками.
147
153
  *
148
- * Сначала ищем число под чужим именем, и только если его нет — ставим ноль.
149
- * Ноль здесь заведомо неправда, но выбор не между точным счётом и
150
- * приблизительным, а между приблизительным и нечитаемой историей. Остальные
151
- * поля порции остаются как были: чиним арифметику, а не переписываем ответ
152
- * провайдера.
154
+ * Сначала ищем число под чужим именем или нормализуем само поле (округление float/string),
155
+ * и только если его нет — ставим ноль.
156
+ * Чиним арифметику, а не переписываем остальной ответ провайдера.
153
157
  */
154
158
  export function repaired(usage, bad) {
155
159
  const fixed = usage && typeof usage === 'object' ? { ...usage } : {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-usage-guard",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",