@goodandready/dsh-usage-guard 0.1.5 → 0.1.7

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
@@ -112,6 +112,20 @@ Logs informative diagnostic warnings naming the exact session, turn, step, raw p
112
112
 
113
113
  ---
114
114
 
115
+ ## 🚀 Changed in v0.1.7
116
+
117
+ * **Design Alignment with `dsh-clinebot` (#6)**:
118
+ - Idempotent `ensureCss()` outside component render with `<style id="dsh-usage-guard-full-css" data-dsh-plugin="dsh-usage-guard">`.
119
+ - Wrapped settings card in an `ErrorBoundary` to gracefully contain render errors and offer a "Retry" mechanism without breaking DSH Settings.
120
+ - Native DSH theme tokens for card styling (`.ug-section-card`, `.ug-field-card`, `.ug-stat-box`, `.ug-badge-ok`, `.ug-badge-warn`, `.ug-btn-primary`).
121
+ - Added protection status telemetry grid displaying current operational mode and target Cordis service.
122
+ - Built-in `makeT(dict, fallback)` supporting Russian and English locales with template interpolation.
123
+ - Added `refreshMirrorUntilVisible(ctx)` with an unref timer to guarantee host settings scope visibility.
124
+ * **Parser Stability & Cache Aliases Hardening**:
125
+ - Added recognition of nested OpenAI cache details (`prompt_tokens_details.cachedTokens`, `prompt_tokens_details.cacheCreationTokens`).
126
+ - Hardened number coercion and sanitization against `Infinity`, `-Infinity`, `NaN`, and malformed strings.
127
+ - Defensive error handling when accessing live settings scopes.
128
+
115
129
  ## 🚀 Changed in v0.1.5
116
130
 
117
131
  * **Strict `settings.plugin.item` Registration (#3)**:
package/README.ru.md CHANGED
@@ -112,6 +112,20 @@ graph LR
112
112
 
113
113
  ---
114
114
 
115
+ ## 🚀 Изменения в версии v0.1.7 (Changed in v0.1.7)
116
+
117
+ * **Единый стиль по референсу `dsh-clinebot` (#6)**:
118
+ - Идемпотентный вызов `ensureCss()` вынесен за пределы цикла рендера (`#dsh-usage-guard-full-css` с атрибутом `data-dsh-plugin="dsh-usage-guard"`).
119
+ - Карточка настроек защищена компонентом `ErrorBoundary`: ошибки интерфейса не роняют панель настроек DSH и сопровождаются кнопкой «Retry».
120
+ - Полное использование токенов темы DSH для карточек, полей и бейджей (`.ug-section-card`, `.ug-field-card`, `.ug-stat-box`, `.ug-badge-ok`, `.ug-badge-warn`, `.ug-btn-primary`).
121
+ - Добавлен блок телеметрии статуса защиты и целевой службы `sessionProjections`.
122
+ - Внедрена функция локализации `makeT(ru, en)` с поддержкой интерполяции переменных `{var}` и fallback.
123
+ - Фоновая синхронизация зеркала настроек `refreshMirrorUntilVisible(ctx)` с безопасным unref-таймером.
124
+ * **Аудит стабильности и расширение синонимов расхода**:
125
+ - Добавлена поддержка вложенных структур кэша OpenAI (`prompt_tokens_details.cachedTokens`, `prompt_tokens_details.cacheCreationTokens`).
126
+ - Усилена функция приведения чисел (`coerceNumber`) против `Infinity`, `-Infinity`, `NaN` и мусорных строк.
127
+ - Безопасная обработка настроек ядра и перехват исключений хоста.
128
+
115
129
  ## 🚀 Изменения в версии v0.1.5 (Changed in v0.1.5)
116
130
 
117
131
  * **Строгая регистрация в `settings.plugin.item` (#3)**:
package/lib/client.js CHANGED
@@ -1,106 +1,252 @@
1
+ // Plugin Settings Card (settings.plugin.item).
1
2
  window.__ModuleLoader__.load({
2
3
  id: '@goodandready/dsh-usage-guard',
3
4
  factory: (require) => {
4
- var module = { exports: {} };
5
+ const module = { exports: {} };
5
6
  const React = require('react');
6
7
  const NS = 'dsh-usage-guard';
7
8
 
8
- let ChevronIcon = null;
9
- try {
10
- const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
11
- ChevronIcon = primitives && primitives.IconChevronDownOutline14;
12
- } catch (_) {
13
- ChevronIcon = null;
9
+ const en = {
10
+ title: 'Usage Guard',
11
+ subtitle: 'Token-usage sanitizer & session crash protection',
12
+ header_desc: 'Guards session history projections from corrupted or missing token counters across all LLM providers.',
13
+ badge_active: 'Active Guard',
14
+ badge_report_only: 'Report Only',
15
+ 'settings.loading': 'Loading Usage Guard settings…',
16
+ 'settings.unavailable': 'Settings scope unavailable (host namespace is not ready).',
17
+ 'settings.retry': 'Retry',
18
+ saved: 'Settings saved successfully',
19
+ save: 'Save Changes',
20
+ saving: 'Saving…',
21
+ repair: 'Auto-repair token counters',
22
+ repairDesc: 'Replace missing, non-numeric, or malformed counters with 0 before summing to prevent NaN history lockups.',
23
+ report: 'Log diagnostic warnings',
24
+ reportDesc: 'Log a detailed warning naming the session turn, step, and raw sample whenever damaged usage arrives.',
25
+ stats_title: 'Protection Status',
26
+ stats_mode: 'Current Mode',
27
+ stats_mode_repair: 'Active Repair (Safe)',
28
+ stats_mode_report: 'Audit Only (Unsafe)',
29
+ stats_scope: 'Scope Target',
30
+ stats_scope_val: 'sessionProjections',
31
+ };
32
+
33
+ const ru = {
34
+ title: 'Usage Guard',
35
+ subtitle: 'Санитайзер расхода токенов и защита от сбоев сессий',
36
+ header_desc: 'Защищает проекции истории сессий от повреждённых или отсутствующих счётчиков токенов всех LLM-провайдеров.',
37
+ badge_active: 'Защита активна',
38
+ badge_report_only: 'Только аудит',
39
+ 'settings.loading': 'Загрузка настроек Usage Guard…',
40
+ 'settings.unavailable': 'Настройки недоступны (пространство хоста ещё не готово).',
41
+ 'settings.retry': 'Повторить попытку',
42
+ saved: 'Настройки успешно сохранены',
43
+ save: 'Сохранить изменения',
44
+ saving: 'Сохранение…',
45
+ repair: 'Автоматическая починка счётчиков',
46
+ repairDesc: 'Подставлять 0 вместо пропущенных или нечисловых счётчиков до сложения ядра, предотвращая зависание истории с ошибкой NaN.',
47
+ report: 'Диагностические предупреждения',
48
+ reportDesc: 'Выводить в системный журнал подробную строку с указанием сессии, хода, шага и сырой порции при обнаружении повреждений.',
49
+ stats_title: 'Статус защиты',
50
+ stats_mode: 'Текущий режим',
51
+ stats_mode_repair: 'Активная починка (Безопасно)',
52
+ stats_mode_report: 'Только аудит (Небезопасно)',
53
+ stats_scope: 'Целевая служба',
54
+ stats_scope_val: 'sessionProjections',
55
+ };
56
+
57
+ function makeT(dict, fallback) {
58
+ return function t(key, vars) {
59
+ let val = (dict && dict[key]) || (fallback && fallback[key]) || key;
60
+ if (vars && typeof val === 'string') {
61
+ for (const k of Object.keys(vars)) {
62
+ val = val.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k]));
63
+ }
64
+ }
65
+ return val;
66
+ };
14
67
  }
15
68
 
16
- function FallbackChevron(props) {
17
- return React.createElement('svg', {
18
- width: 14,
19
- height: 14,
20
- viewBox: '0 0 14 14',
21
- fill: 'none',
22
- stroke: 'currentColor',
23
- strokeWidth: 1.5,
24
- style: props.style,
25
- className: props.className,
26
- 'aria-hidden': true,
27
- }, React.createElement('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' }));
69
+ function FallbackChevron() {
70
+ return React.createElement(
71
+ 'svg',
72
+ { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': true },
73
+ React.createElement('path', {
74
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
75
+ stroke: 'currentColor',
76
+ strokeWidth: 1.5,
77
+ strokeLinecap: 'round',
78
+ strokeLinejoin: 'round',
79
+ })
80
+ );
28
81
  }
29
- const Chevron = ChevronIcon || FallbackChevron;
82
+ const Chevron = FallbackChevron;
83
+
84
+ function ensureCss() {
85
+ if (typeof document === 'undefined') return;
86
+ if (document.getElementById('dsh-usage-guard-full-css')) return;
87
+ const style = document.createElement('style');
88
+ style.id = 'dsh-usage-guard-full-css';
89
+ style.dataset.dshPlugin = NS;
90
+ style.textContent = `
91
+ .ug-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:14px;list-style:none}
92
+ .ug-head-btn{background:none;border:none;cursor:pointer;display:flex;align-items:center;width:100%;padding:0;text-align:left}
93
+ .ug-head-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary)}
94
+ .ug-head-sub{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:2px}
95
+
96
+ .ug-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}
97
+ .ug-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}
98
+ .ug-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}
99
+ .ug-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}
100
+
101
+ .ug-body{display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:16px;margin-top:4px}
102
+ .ug-field-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}
103
+ .ug-row{display:flex;align-items:center;justify-content:space-between;gap:12px;cursor:pointer}
104
+ .ug-row-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}
105
+ .ug-field-desc{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.4}
106
+
107
+ .ug-checkbox{width:18px;height:18px;accent-color:var(--dsw-alias-state-brand-primary, #6366f1);cursor:pointer}
108
+
109
+ .ug-stats-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:10px}
110
+ .ug-stat-box{padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:2px}
111
+ .ug-stat-val{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
112
+ .ug-stat-lbl{font-size:11px;color:var(--dsw-alias-label-secondary)}
113
+
114
+ .ug-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 16px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}
115
+ .ug-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
116
+ .ug-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
117
+ .ug-btn-primary:hover:not(:disabled){opacity:0.9}
118
+ .ug-btn:disabled{opacity:0.5;cursor:not-allowed}
30
119
 
31
- function PluginCard({ ctx }) {
32
- const [expanded, setExpanded] = React.useState(false);
33
- const [snap, setSnap] = React.useState({ status: 'loading', value: null, writable: false });
120
+ .ug-alert-ok{padding:10px 14px;border-radius:8px;background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);font-size:13px;border:1px solid rgba(16,185,129,0.2)}
121
+ .ug-alert-bad{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px;border:1px solid rgba(239,68,68,0.2)}
122
+ `;
123
+ document.head.appendChild(style);
124
+ }
125
+
126
+ function createErrorBoundary() {
127
+ if (!React || typeof React.Component !== 'function') {
128
+ return function NoopBoundary(props) { return props?.children || null; };
129
+ }
130
+ return class ErrorBoundary extends React.Component {
131
+ constructor(props) {
132
+ super(props);
133
+ this.state = { hasError: false, error: null };
134
+ }
135
+ static getDerivedStateFromError(error) {
136
+ return { hasError: true, error };
137
+ }
138
+ componentDidCatch(error, errorInfo) {
139
+ console.error('[dsh-usage-guard] UI Error:', error, errorInfo);
140
+ }
141
+ render() {
142
+ if (this.state.hasError) {
143
+ return React.createElement(
144
+ 'div',
145
+ { className: 'ug-alert-bad', style: { margin: '12px 0' } },
146
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '4px' } }, '⚠️ Usage Guard UI Error:'),
147
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
148
+ React.createElement(
149
+ 'button',
150
+ {
151
+ type: 'button',
152
+ className: 'ug-btn',
153
+ style: { marginTop: '8px', fontSize: '12px', padding: '4px 10px' },
154
+ onClick: () => this.setState({ hasError: false, error: null }),
155
+ },
156
+ 'Retry'
157
+ )
158
+ );
159
+ }
160
+ return this.props?.children || null;
161
+ }
162
+ };
163
+ }
164
+ const ErrorBoundary = createErrorBoundary();
165
+
166
+ function SettingsPage({ ctx, t }) {
167
+ const [snap, setSnap] = React.useState({ status: 'loading', value: {} });
34
168
  const [draft, setDraft] = React.useState({ repair: true, report: true });
35
169
  const [saving, setSaving] = React.useState(false);
36
- const [msg, setMsg] = React.useState('');
37
-
38
- const initializedRef = React.useRef(false);
170
+ const [msg, setMsg] = React.useState({ text: '', ok: true });
39
171
  const isDirtyRef = React.useRef(false);
40
172
  const msgTimerRef = React.useRef(null);
41
173
 
42
174
  React.useEffect(() => {
43
- return () => {
44
- if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
45
- };
46
- }, []);
47
-
48
- React.useEffect(() => {
49
- let off = null;
175
+ let active = true;
50
176
  try {
51
- const scope = ctx.settingsScope && ctx.settingsScope.bind ? ctx.settingsScope.bind({ namespace: NS }) : null;
177
+ const scope = ctx?.settingsScope && typeof ctx.settingsScope.bind === 'function'
178
+ ? ctx.settingsScope.bind({ namespace: NS })
179
+ : null;
52
180
  if (!scope) {
53
- setSnap({ status: 'unavailable', value: null, writable: true });
54
- return;
181
+ setSnap({ status: 'unavailable', value: {} });
182
+ return () => {};
55
183
  }
56
- const update = () => {
57
- const s = scope.getSnapshot ? scope.getSnapshot() : { status: 'unavailable', value: null };
58
- setSnap(s);
59
- if (s.status === 'ready' && s.value && (!initializedRef.current || !isDirtyRef.current)) {
60
- initializedRef.current = true;
61
- setDraft({
62
- repair: s.value.repair ?? true,
63
- report: s.value.report ?? true,
64
- });
184
+ const read = async () => {
185
+ try {
186
+ const res = await scope.get();
187
+ if (active && res && typeof res === 'object') {
188
+ setSnap({ status: 'ready', value: res });
189
+ if (!isDirtyRef.current) {
190
+ setDraft({
191
+ repair: res.repair !== false,
192
+ report: res.report !== false,
193
+ });
194
+ }
195
+ }
196
+ } catch (_) {
197
+ if (active) setSnap({ status: 'unavailable', value: {} });
65
198
  }
66
199
  };
67
- update();
68
- if (scope.subscribe) off = scope.subscribe(update);
200
+ read();
201
+ const unwatch = typeof scope.watch === 'function'
202
+ ? scope.watch((next) => {
203
+ if (!active || !next || typeof next !== 'object') return;
204
+ setSnap({ status: 'ready', value: next });
205
+ if (!isDirtyRef.current) {
206
+ setDraft({
207
+ repair: next.repair !== false,
208
+ report: next.report !== false,
209
+ });
210
+ }
211
+ })
212
+ : () => {};
213
+ return () => {
214
+ active = false;
215
+ try { unwatch(); } catch (_) {}
216
+ };
69
217
  } catch (_) {
70
- setSnap({ status: 'unavailable', value: null, writable: true });
218
+ setSnap({ status: 'unavailable', value: {} });
219
+ return () => {};
71
220
  }
72
- return () => {
73
- try { off && off(); } catch {}
74
- };
75
221
  }, [ctx]);
76
222
 
77
- const t = (() => {
78
- try {
79
- return ctx.locale && ctx.locale.bind ? ctx.locale.bind(NS) : (k) => k;
80
- } catch (_) {
81
- return (k) => k;
82
- }
83
- })();
223
+ React.useEffect(() => {
224
+ return () => {
225
+ if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
226
+ };
227
+ }, []);
84
228
 
85
- const showMessage = (text, autoDismiss = false) => {
229
+ const showMessage = (text, ok = true) => {
86
230
  if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
87
- setMsg(text);
88
- if (autoDismiss) {
89
- msgTimerRef.current = setTimeout(() => {
90
- setMsg('');
91
- }, 3000);
231
+ setMsg({ text, ok });
232
+ msgTimerRef.current = setTimeout(() => {
233
+ setMsg({ text: '', ok: true });
234
+ }, 4000);
235
+ };
236
+
237
+ const handleToggle = (field, checked) => {
238
+ isDirtyRef.current = true;
239
+ if (msg.text) {
240
+ if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
241
+ setMsg({ text: '', ok: true });
92
242
  }
243
+ setDraft((prev) => ({ ...prev, [field]: checked }));
93
244
  };
94
245
 
95
246
  const onSave = async () => {
247
+ if (!ctx?.settingsScope || typeof ctx.settingsScope.bind !== 'function') return;
96
248
  setSaving(true);
97
- setMsg('');
98
249
  try {
99
- if (!ctx.settingsScope || !ctx.settingsScope.bind) {
100
- showMessage('settingsScope unavailable', false);
101
- setSaving(false);
102
- return;
103
- }
104
250
  const scope = ctx.settingsScope.bind({ namespace: NS });
105
251
  const errs = [];
106
252
  for (const k of ['repair', 'report']) {
@@ -114,7 +260,7 @@ window.__ModuleLoader__.load({
114
260
  showMessage(errs.join('; '), false);
115
261
  } else {
116
262
  isDirtyRef.current = false;
117
- showMessage(t('saved') || 'Saved', true);
263
+ showMessage(t('saved'), true);
118
264
  }
119
265
  } catch (e) {
120
266
  showMessage(String(e && e.message ? e.message : e), false);
@@ -122,130 +268,288 @@ window.__ModuleLoader__.load({
122
268
  setSaving(false);
123
269
  };
124
270
 
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
- };
271
+ if (snap.status === 'loading') {
272
+ return React.createElement(
273
+ 'div',
274
+ { className: 'ug-field-card', style: { color: 'var(--dsw-alias-label-secondary)' } },
275
+ t('settings.loading')
276
+ );
277
+ }
278
+
279
+ if (snap.status === 'unavailable') {
280
+ return React.createElement(
281
+ 'div',
282
+ { className: 'ug-alert-bad' },
283
+ t('settings.unavailable')
284
+ );
285
+ }
286
+
287
+ const isRepair = draft.repair !== false;
288
+
289
+ return React.createElement(
290
+ 'div',
291
+ { className: 'ug-body' },
133
292
 
134
- const isRepairActive = draft.repair !== false;
135
- const badgeLabel = isRepairActive ? (t('active') || 'ACTIVE') : (t('reportOnly') || 'REPORT ONLY');
136
- const badgeClass = isRepairActive ? 'ug-badge ug-badge-green' : 'ug-badge ug-badge-yellow';
137
-
138
- return React.createElement('div', { className: 'ug-card' },
139
- React.createElement('style', {'data-dsh-plugin': 'dsh-usage-guard'}, `
140
- .ug-card { border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-3); border-radius:12px; list-style:none }
141
- .ug-head { appearance:none; width:100%; font:inherit; color:inherit; text-align:left; cursor:pointer; background:0 0; border:0; border-radius:12px; display:flex; align-items:center; gap:12px; padding:14px 16px }
142
- .ug-title { color:var(--dsw-alias-label-primary); font-size:15px; font-weight:600; line-height:1.4 }
143
- .ug-sub { color:var(--dsw-alias-label-secondary); font-size:13px }
144
- .ug-body { border-top:1px solid var(--dsw-alias-border-l2); margin:0 16px; padding-bottom:8px }
145
- .ug-field { display:flex; flex-direction:column; gap:6px; padding:12px 0 }
146
- .ug-row { display:flex; align-items:center; justify-content:space-between; gap:12px; cursor:pointer; font-weight:500; color:var(--dsw-alias-label-primary) }
147
- .ug-checkbox { width:16px; height:16px; accent-color:var(--dsw-alias-color-primary, #6366f1); cursor:pointer }
148
- .ug-foot { border-top:1px solid var(--dsw-alias-border-l2); display:flex; justify-content:flex-end; align-items:center; gap:8px; padding:12px 0 4px }
149
- .ug-save { appearance:none; font:inherit; cursor:pointer; border:1px solid transparent; border-radius:8px; padding:5px 14px; font-size:13px; background:var(--dsw-alias-label-primary); color:var(--dsw-alias-bg-layer-3) }
150
- .ug-save:disabled { opacity:0.6; cursor:not-allowed }
151
- .ug-chev { margin-left:auto; flex:none; color:var(--dsw-alias-label-tertiary); transition:transform .16s }
152
- .ug-chev-open { transform:rotate(180deg) }
153
- .ug-badge { display:inline-flex; align-items:center; gap:6px; font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; border:1px solid var(--dsw-alias-border-l2); margin-left:auto }
154
- .ug-badge-green { background:var(--dsw-alias-color-success, #2da44e); color:#fff; border-color:transparent }
155
- .ug-badge-yellow { background:var(--dsw-alias-color-warning, #d9a400); color:#fff; border-color:transparent }
156
- `),
157
- React.createElement('button', {
158
- className: 'ug-head',
159
- onClick: () => setExpanded(!expanded),
160
- 'aria-expanded': expanded,
161
- },
162
- React.createElement('span', { className: 'ug-title' }, t('title') || 'Usage Guard'),
163
- React.createElement('span', { className: 'ug-sub' }, t('subtitle') || 'Token-usage sanitizer & session crash protection'),
164
- React.createElement('span', { className: badgeClass }, badgeLabel),
165
- React.createElement(Chevron, { className: 'ug-chev' + (expanded ? ' ug-chev-open' : ''), 'aria-hidden': true })
293
+ // Telemetry / Protection status banner
294
+ React.createElement(
295
+ 'div',
296
+ { className: 'ug-stats-grid' },
297
+ React.createElement(
298
+ 'div',
299
+ { className: 'ug-stat-box' },
300
+ React.createElement('span', { className: 'ug-stat-lbl' }, t('stats_mode')),
301
+ React.createElement(
302
+ 'span',
303
+ {
304
+ className: 'ug-stat-val',
305
+ style: { color: isRepair ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warning-primary)' },
306
+ },
307
+ isRepair ? t('stats_mode_repair') : t('stats_mode_report')
308
+ )
309
+ ),
310
+ React.createElement(
311
+ 'div',
312
+ { className: 'ug-stat-box' },
313
+ React.createElement('span', { className: 'ug-stat-lbl' }, t('stats_scope')),
314
+ React.createElement('span', { className: 'ug-stat-val' }, t('stats_scope_val'))
315
+ )
316
+ ),
317
+
318
+ // Field 1: repair
319
+ React.createElement(
320
+ 'div',
321
+ { className: 'ug-field-card' },
322
+ React.createElement(
323
+ 'label',
324
+ { className: 'ug-row', htmlFor: 'ug-repair-toggle' },
325
+ React.createElement('span', { className: 'ug-row-title' }, t('repair')),
326
+ React.createElement('input', {
327
+ id: 'ug-repair-toggle',
328
+ type: 'checkbox',
329
+ className: 'ug-checkbox',
330
+ checked: !!draft.repair,
331
+ onChange: (e) => handleToggle('repair', e.target.checked),
332
+ })
333
+ ),
334
+ React.createElement('div', { className: 'ug-field-desc' }, t('repairDesc'))
166
335
  ),
167
- expanded ? React.createElement('div', { className: 'ug-body' },
168
- snap.status === 'loading' ? React.createElement('div', { className: 'ug-field', style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } }, t('loading') || 'Loading…') : null,
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,
170
- snap.status === 'ready' ? React.createElement('div', null,
171
- React.createElement('div', { className: 'ug-field' },
172
- React.createElement('label', { className: 'ug-row', htmlFor: 'ug-repair-toggle' },
173
- React.createElement('span', null, t('repair') || 'Auto-repair token counters'),
174
- React.createElement('input', {
175
- id: 'ug-repair-toggle',
176
- type: 'checkbox',
177
- className: 'ug-checkbox',
178
- checked: !!draft.repair,
179
- onChange: (e) => handleToggle('repair', e.target.checked),
180
- })
181
- ),
182
- React.createElement('div', { className: 'ug-sub' }, t('repairDesc') || 'Replace missing or non-numeric counters with 0 before summing to prevent NaN history lockups')
183
- ),
184
- React.createElement('div', { className: 'ug-field' },
185
- React.createElement('label', { className: 'ug-row', htmlFor: 'ug-report-toggle' },
186
- React.createElement('span', null, t('report') || 'Log diagnostic warnings'),
187
- React.createElement('input', {
188
- id: 'ug-report-toggle',
189
- type: 'checkbox',
190
- className: 'ug-checkbox',
191
- checked: !!draft.report,
192
- onChange: (e) => handleToggle('report', e.target.checked),
193
- })
194
- ),
195
- React.createElement('div', { className: 'ug-sub' }, t('reportDesc') || 'Log a line naming the turn, step, and raw sample whenever damaged usage arrives')
196
- ),
197
- React.createElement('div', { className: 'ug-foot' },
198
- msg ? React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginRight: 'auto' } }, msg) : null,
199
- React.createElement('button', {
200
- className: 'ug-save',
201
- onClick: onSave,
202
- disabled: saving,
203
- }, saving ? (t('saving') || 'Saving…') : (t('save') || 'Save'))
336
+
337
+ // Field 2: report
338
+ React.createElement(
339
+ 'div',
340
+ { className: 'ug-field-card' },
341
+ React.createElement(
342
+ 'label',
343
+ { className: 'ug-row', htmlFor: 'ug-report-toggle' },
344
+ React.createElement('span', { className: 'ug-row-title' }, t('report')),
345
+ React.createElement('input', {
346
+ id: 'ug-report-toggle',
347
+ type: 'checkbox',
348
+ className: 'ug-checkbox',
349
+ checked: !!draft.report,
350
+ onChange: (e) => handleToggle('report', e.target.checked),
351
+ })
352
+ ),
353
+ React.createElement('div', { className: 'ug-field-desc' }, t('reportDesc'))
354
+ ),
355
+
356
+ // Feedback message
357
+ msg.text
358
+ ? React.createElement(
359
+ 'div',
360
+ { className: msg.ok ? 'ug-alert-ok' : 'ug-alert-bad' },
361
+ msg.text
204
362
  )
205
- ) : null
206
- ) : null
363
+ : null,
364
+
365
+ // Actions
366
+ React.createElement(
367
+ 'div',
368
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' } },
369
+ React.createElement(
370
+ 'button',
371
+ {
372
+ type: 'button',
373
+ className: 'ug-btn ug-btn-primary',
374
+ onClick: onSave,
375
+ disabled: saving,
376
+ },
377
+ saving ? t('saving') : t('save')
378
+ )
379
+ )
207
380
  );
208
381
  }
209
382
 
210
- module.exports.inject = ['slots', 'locale', 'settingsScope'];
211
- module.exports.apply = function apply(ctx) {
212
- try {
213
- ctx.locale.register(NS, {
214
- en: {
215
- title: 'Usage Guard',
216
- subtitle: 'Token-usage sanitizer & session crash protection',
217
- active: 'ACTIVE',
218
- reportOnly: 'REPORT ONLY',
219
- saved: 'Saved',
220
- loading: 'Loading…',
221
- unavailable: 'Settings scope unavailable',
222
- save: 'Save',
223
- saving: 'Saving…',
224
- repair: 'Auto-repair token counters',
225
- repairDesc: 'Replace missing or non-numeric counters with 0 before summing to prevent NaN history lockups.',
226
- report: 'Log diagnostic warnings',
227
- reportDesc: 'Log a line naming the turn, step, and raw sample whenever damaged usage arrives.',
383
+ // Accordion PluginCard for settings.plugin.item
384
+ function PluginCard(props) {
385
+ const [open, setOpen] = React.useState(false);
386
+ const t = props?.t || makeT(ru, en);
387
+
388
+ React.useEffect(() => {
389
+ ensureCss();
390
+ }, []);
391
+
392
+ const isRepairActive = props?.repair !== false;
393
+ const badgeText = isRepairActive ? t('badge_active') : t('badge_report_only');
394
+ const badgeClass = isRepairActive ? 'ug-badge ug-badge-ok' : 'ug-badge ug-badge-warn';
395
+
396
+ return React.createElement(
397
+ 'li',
398
+ { className: 'ug-section-card' },
399
+ React.createElement(
400
+ 'button',
401
+ {
402
+ type: 'button',
403
+ className: 'ug-head-btn',
404
+ 'aria-expanded': open,
405
+ onClick: () => setOpen((v) => !v),
228
406
  },
229
- });
230
- } catch (_) {}
231
-
232
- // Register exclusively in settings.plugin.item (DSH Settings → Plugins tab).
233
- // Custom top-level sidebar sections (settings.section) are forbidden for simple plugin settings cards.
234
- try {
235
- if (ctx.slots && typeof ctx.slots.register === 'function') {
236
- ctx.slots.register(
407
+ React.createElement(
408
+ 'div',
409
+ { style: { flex: 1, paddingRight: '12px' } },
410
+ React.createElement('div', { className: 'ug-head-title' }, t('title')),
411
+ React.createElement('div', { className: 'ug-head-sub' }, t('subtitle'))
412
+ ),
413
+ React.createElement('span', { className: badgeClass, style: { marginRight: '12px' } }, badgeText),
414
+ React.createElement(
415
+ 'span',
237
416
  {
238
- name: 'settings.plugin.item',
239
- key: NS,
240
- locale: NS,
241
- inject: () => ({ ctx }),
417
+ style: {
418
+ transform: open ? 'rotate(180deg)' : 'none',
419
+ transition: 'transform .16s ease',
420
+ display: 'inline-flex',
421
+ color: 'var(--dsw-alias-label-tertiary)',
422
+ },
242
423
  },
243
- PluginCard,
244
- );
424
+ React.createElement(Chevron)
425
+ )
426
+ ),
427
+ open
428
+ ? React.createElement(
429
+ ErrorBoundary,
430
+ null,
431
+ React.createElement(SettingsPage, {
432
+ ...props,
433
+ ctx: (props && props.ctx) || ctx,
434
+ t,
435
+ })
436
+ )
437
+ : null
438
+ );
439
+ }
440
+
441
+ function refreshMirrorUntilVisible(ctx) {
442
+ const visible = () => {
443
+ try {
444
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope;
445
+ const view = s?.describe?.()?.getSnapshot?.()?.view;
446
+ return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS);
447
+ } catch (_) {
448
+ return false;
245
449
  }
246
- } catch (_) {}
247
- };
450
+ };
451
+ if (visible()) return () => {};
452
+ let tries = 0;
453
+ const timer = setInterval(() => {
454
+ if (visible() || tries >= 15) {
455
+ clearInterval(timer);
456
+ return;
457
+ }
458
+ tries += 1;
459
+ try {
460
+ const s = (ctx?.get && ctx.get('lanSettings')) || ctx?.settingsScope;
461
+ s?.describe?.()?.load?.();
462
+ } catch (_) {}
463
+ }, 1000);
464
+ if (timer && typeof timer.unref === 'function') {
465
+ timer.unref();
466
+ }
467
+ return () => clearInterval(timer);
468
+ }
469
+
470
+ let ctx = null;
471
+
472
+ function apply(c) {
473
+ ctx = c;
474
+ const addLocale = (locale, dictionary) => {
475
+ try {
476
+ return ctx.locale.register(NS, locale, dictionary);
477
+ } catch (_) {
478
+ return () => {};
479
+ }
480
+ };
481
+
482
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
483
+ if (typeof ctx.effect === 'function') {
484
+ ctx.effect(() => {
485
+ const undo = [addLocale('en', en), addLocale('ru', ru)];
486
+ return () => {
487
+ for (const off of undo) off();
488
+ };
489
+ }, 'dsh-usage-guard: dictionaries');
490
+ } else {
491
+ addLocale('en', en);
492
+ addLocale('ru', ru);
493
+ }
494
+ }
248
495
 
496
+ if (typeof ctx.effect === 'function') {
497
+ ctx.effect(
498
+ () => refreshMirrorUntilVisible(ctx),
499
+ 'dsh-usage-guard: re-read the settings mirror until namespace appears',
500
+ );
501
+ }
502
+
503
+ function registerSlotWhenReady(slotName, registerFn) {
504
+ if (!ctx.slots) return;
505
+ if (typeof ctx.slots.inject === 'function') {
506
+ try {
507
+ ctx.slots.inject(slotName, () => {
508
+ try {
509
+ return registerFn();
510
+ } catch (err) {
511
+ console.warn('[dsh-usage-guard] Error registering slot ' + slotName + ':', err);
512
+ }
513
+ });
514
+ return;
515
+ } catch (err) {
516
+ console.warn('[dsh-usage-guard] Failed to inject slot ' + slotName + ':', err);
517
+ }
518
+ }
519
+ if (typeof ctx.slots.register === 'function') {
520
+ try {
521
+ registerFn();
522
+ } catch (err) {
523
+ console.warn('[dsh-usage-guard] Failed direct registration for ' + slotName + ':', err);
524
+ }
525
+ }
526
+ }
527
+
528
+ registerSlotWhenReady('settings.plugin.item', () =>
529
+ ctx.slots.register(
530
+ {
531
+ name: 'settings.plugin.item',
532
+ key: NS,
533
+ locale: NS,
534
+ inject: () => ({ ctx }),
535
+ },
536
+ (props) =>
537
+ React.createElement(
538
+ ErrorBoundary,
539
+ null,
540
+ React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx })
541
+ )
542
+ )
543
+ );
544
+ }
545
+
546
+ module.exports = {
547
+ apply,
548
+ inject: ['slots', 'locale', 'settingsScope'],
549
+ makeT,
550
+ ensureCss,
551
+ createErrorBoundary,
552
+ };
249
553
  return module.exports;
250
554
  },
251
- });
555
+ });
package/lib/index.js CHANGED
@@ -52,13 +52,20 @@ export function apply(ctx, config) {
52
52
  try {
53
53
  live = Config(structuredClone(config ?? {})) ?? config
54
54
  } catch (_) {
55
- live = Config({}) ?? {}
55
+ live = Config({}) ?? { repair: true, report: true }
56
56
  }
57
57
 
58
58
  ctx.inject(['settings'], (sctx) => {
59
- const scope = sctx.settings.register(name, Config, { base: config })
60
- live = scope.get() ?? live
61
- sctx.effect(() => scope.watch((next) => { live = next ?? live }))
59
+ try {
60
+ const scope = sctx.settings.register(name, Config, { base: config })
61
+ try {
62
+ const fresh = scope.get()
63
+ if (fresh && typeof fresh === 'object') live = fresh
64
+ } catch (_) {}
65
+ sctx.effect(() => scope.watch((next) => {
66
+ if (next && typeof next === 'object') live = next
67
+ }))
68
+ } catch (_) {}
62
69
  })
63
70
 
64
71
  // О каждом сочетании сессии, хода, шага и набора полей жалуемся один раз:
@@ -90,7 +97,7 @@ export function apply(ctx, config) {
90
97
  return event
91
98
  }
92
99
 
93
- if (live.report !== false) {
100
+ if (live && live.report !== false) {
94
101
  const sessionTag = event?.sessionId ?? event?.data?.sessionId ?? event?.data?.turn ?? 'global'
95
102
  const seen = `${String(sessionTag)}/${String(found.turn)}/${String(found.step)}/${bad.join(',')}`
96
103
  if (!told.has(seen)) {
@@ -108,7 +115,7 @@ export function apply(ctx, config) {
108
115
  }
109
116
  }
110
117
 
111
- const result = live.repair === false ? event : healed(event, bad)
118
+ const result = live && live.repair === false ? event : healed(event, bad)
112
119
  eventCache.set(event, result)
113
120
  return result
114
121
  }
@@ -119,4 +126,4 @@ export function apply(ctx, config) {
119
126
  'dsh-usage-guard: обезвреживание порций расхода',
120
127
  )
121
128
  })
122
- }
129
+ }
package/lib/usage.js CHANGED
@@ -1,15 +1,23 @@
1
- // Счётчики токенов: что проверяем и как чиним.
1
+ // Как устроена порция расхода в событиях сессии.
2
2
  //
3
- // Харнесс складывает расход в четыре ведра:
3
+ // Харнесс слушает шину событий сессии и собирает статистику по двум типам
4
+ // записей:
5
+ //
6
+ // 1. Потоковые порции ответа ассистента:
7
+ // `assistant/chunk` -> `event.data.chunk.usage`
8
+ //
9
+ // 2. Финальное сообщение ассистента:
10
+ // `assistant/message` -> `event.data.usage`
11
+ //
12
+ // В обоих случаях ядро ждёт четыре поля:
4
13
  //
5
14
  // uncachedInputTokens: usage.inputTokens,
6
15
  // outputTokens: usage.outputTokens,
7
16
  // cacheReadTokens: usage.cacheReadTokens ?? 0,
8
17
  // cacheWriteTokens: usage.cacheWriteTokens ?? 0,
9
18
  //
10
- // Первые два он берёт без проверки. Сложение `total += undefined` даёт NaN,
11
- // а проверка схемы в ядре требует неотрицательное целое число (zod.int().nonnegative()).
12
- // Из-за этого одна битая порция валит не просто
19
+ // Если первые два поля пропущены, не являются числом или содержат нечисловую строку,
20
+ // их сложение с нулём даёт NaN. Из-за этого одна битая порция валит не просто
13
21
  // счётчик, а всю историю сессии.
14
22
  export const FIELDS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
15
23
 
@@ -65,6 +73,7 @@ function sound(value) {
65
73
  /**
66
74
  * Безопасное приведение значения к корректному неотрицательному целому числу.
67
75
  * Округляет дробные числа и парсит числовые строки (в т.ч. с десятичной точкой).
76
+ * Исключает NaN, Infinity, отрицательные величины и мусорные строки.
68
77
  */
69
78
  function coerceNumber(value) {
70
79
  if (typeof value === 'number') {
@@ -134,14 +143,21 @@ export function borrowed(usage, field) {
134
143
  if (num !== undefined) return num
135
144
  }
136
145
 
137
- // 2. Поиск во вложенных деталях кэша (OpenAI-style: prompt_tokens_details.cached_tokens)
146
+ // 2. Поиск во вложенных деталях кэша (OpenAI-style: prompt_tokens_details.cached_tokens / cachedTokens)
138
147
  if (field === 'cacheReadTokens' && usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
139
148
  const details = usage.prompt_tokens_details
140
- const num = coerceNumber(details.cached_tokens ?? details.cachedTokens)
149
+ const num = coerceNumber(details.cached_tokens ?? details.cachedTokens ?? details.cache_read_tokens)
150
+ if (num !== undefined) return num
151
+ }
152
+
153
+ // 3. Поиск во вложенных деталях создания кэша (cache_creation_input_tokens)
154
+ if (field === 'cacheWriteTokens' && usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
155
+ const details = usage.prompt_tokens_details
156
+ const num = coerceNumber(details.cache_creation_tokens ?? details.cacheCreationTokens)
141
157
  if (num !== undefined) return num
142
158
  }
143
159
 
144
- // 3. Само поле, если оно было передано валидной строкой или нецелым числом (напр. "123" или 15.4)
160
+ // 4. Само поле, если оно было передано валидной строкой или нецелым числом (напр. "123" или 15.4)
145
161
  const selfNum = coerceNumber(usage[field])
146
162
  if (selfNum !== undefined) return selfNum
147
163
 
@@ -206,4 +222,4 @@ export function complaint(found, bad) {
206
222
  .map((field) => (found.usage && borrowed(found.usage, field) !== undefined ? `${field} recovered via alias` : `${field} zeroed`))
207
223
  .join('; ')
208
224
  return `malformed usage sample — ${where}; received: ${safeStringify(found.usage)}; ${taken}`
209
- }
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-usage-guard",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",