@goodandready/dsh-usage-guard 0.1.4 → 0.1.6
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 +32 -0
- package/README.ru.md +32 -0
- package/README.zh.md +18 -0
- package/lib/client.js +484 -203
- package/lib/index.js +14 -7
- package/lib/usage.js +25 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,6 +112,38 @@ Logs informative diagnostic warnings naming the exact session, turn, step, raw p
|
|
|
112
112
|
|
|
113
113
|
---
|
|
114
114
|
|
|
115
|
+
## 🚀 Changed in v0.1.6
|
|
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
|
+
|
|
129
|
+
## 🚀 Changed in v0.1.5
|
|
130
|
+
|
|
131
|
+
* **Strict `settings.plugin.item` Registration (#3)**:
|
|
132
|
+
Completely removed the deprecated fallback registration into `settings.section`. In compliance with DSH Plugin Authoring guidelines, settings are rendered exclusively in the "Settings → Plugins" tab (`settings.plugin.item`) with key `dsh-usage-guard` without cluttering the global sidebar.
|
|
133
|
+
* **Refined Client Architecture**:
|
|
134
|
+
Cleaned up the browser bundle to register strictly one slot entry with zero runtime fallback delays.
|
|
135
|
+
|
|
136
|
+
## 🚀 Changed in v0.1.4
|
|
137
|
+
|
|
138
|
+
* **Style Isolation with `data-dsh-plugin`**:
|
|
139
|
+
Dynamic `<style>` element is explicitly tagged with `data-dsh-plugin="dsh-usage-guard"`, preventing style purging during neighbor plugin reloads or HMR.
|
|
140
|
+
* **Direct Slot Registration**:
|
|
141
|
+
Replaced broken invocation pattern with standard `ctx.slots.register('settings.plugin.item', ...)`.
|
|
142
|
+
* **Canonical Localization Standard**:
|
|
143
|
+
Streamlined client registration to strictly register the canonical English locale dictionary, delegating localized user interfaces to DSH translation plugins.
|
|
144
|
+
* **Safe Configuration Loading & English Diagnostics**:
|
|
145
|
+
Protected `Config()` initialization with `try...catch` and migrated diagnostic log messages to English.
|
|
146
|
+
|
|
115
147
|
## 🚀 Changed in v0.1.3
|
|
116
148
|
|
|
117
149
|
* **Fractional Token Protection (Floats & Decimals)**:
|
package/README.ru.md
CHANGED
|
@@ -112,6 +112,38 @@ graph LR
|
|
|
112
112
|
|
|
113
113
|
---
|
|
114
114
|
|
|
115
|
+
## 🚀 Изменения в версии v0.1.6 (Changed in v0.1.6)
|
|
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
|
+
|
|
129
|
+
## 🚀 Изменения в версии v0.1.5 (Changed in v0.1.5)
|
|
130
|
+
|
|
131
|
+
* **Строгая регистрация в `settings.plugin.item` (#3)**:
|
|
132
|
+
Полностью удалена устаревшая ветка регистрации в боковом меню `settings.section`. По канону DSH Plugin Authoring карточка настроек размещается исключительно во вкладке «Настройки → Плагины» (`settings.plugin.item`) с ключом `dsh-usage-guard`, не занимая ограниченный ресурс бокового списка.
|
|
133
|
+
* **Очистка клиентской архитектуры**:
|
|
134
|
+
Регистрация интерфейса упрощена до единственного прямого вызова без задержек и fallback-таймеров.
|
|
135
|
+
|
|
136
|
+
## 🚀 Изменения в версии v0.1.4 (Changed in v0.1.4)
|
|
137
|
+
|
|
138
|
+
* **Изоляция стилей через `data-dsh-plugin`**:
|
|
139
|
+
Динамический тег `<style>` помечен атрибутом `data-dsh-plugin="dsh-usage-guard"`, предотвращая сброс стилей карточки при HMR или обновлении соседних плагинов.
|
|
140
|
+
* **Прямая регистрация слота**:
|
|
141
|
+
Заменён некорректный вызов `ctx.slots.inject()` на прямой `ctx.slots.register('settings.plugin.item', ...)`.
|
|
142
|
+
* **Канонический стандарт локализации**:
|
|
143
|
+
Клиентский модуль регистрирует только канонический словарь `en`, передавая управление переводами системному translation-плагину.
|
|
144
|
+
* **Безопасная инициализация и англоязычные логи**:
|
|
145
|
+
Вызов `Config()` защищён блоком `try...catch`, а системные предупреждения в консоли переведены на английский язык.
|
|
146
|
+
|
|
115
147
|
## 🚀 Изменения в версии v0.1.3 (Changed in v0.1.3)
|
|
116
148
|
|
|
117
149
|
* **Защита от дробных токенов (Floats & Decimals)**:
|
package/README.zh.md
CHANGED
|
@@ -85,6 +85,24 @@ graph LR
|
|
|
85
85
|
|
|
86
86
|
---
|
|
87
87
|
|
|
88
|
+
## 🚀 v0.1.5 版本更新说明 (Changed in v0.1.5)
|
|
89
|
+
|
|
90
|
+
* **规范设置卡片插槽注册 (#3)**:
|
|
91
|
+
彻底移除废弃的顶层侧边栏 `settings.section` 回退逻辑。严格遵循 DSH Plugin Authoring 规范,卡片仅注册至“设置 → 插件”标签页(`settings.plugin.item`),键名与命名空间一致为 `dsh-usage-guard`,不再占用全局侧边栏资源。
|
|
92
|
+
* **精简前端注册流**:
|
|
93
|
+
客户端模块仅保留标准单一插槽注册,无任何额外回退延时。
|
|
94
|
+
|
|
95
|
+
## 🚀 v0.1.4 版本更新说明 (Changed in v0.1.4)
|
|
96
|
+
|
|
97
|
+
* **样式隔离标记 `data-dsh-plugin`**:
|
|
98
|
+
动态 `<style>` 标签附加 `data-dsh-plugin="dsh-usage-guard"` 属性,避免被相邻插件的热重载清理机制误删。
|
|
99
|
+
* **修复插槽直接注册**:
|
|
100
|
+
替换无效的 `ctx.slots.inject()` 调用为直接标准 `ctx.slots.register('settings.plugin.item', ...)`。
|
|
101
|
+
* **规范化单一英文字典**:
|
|
102
|
+
客户端仅保留基础英语字典,翻译工作完全交由 DSH 核心翻译插件运行时接管。
|
|
103
|
+
* **安全配置初始化与全英文诊断**:
|
|
104
|
+
`Config()` 初始化增加 `try...catch` 防护,控制台输出全面切换为标准英语日志。
|
|
105
|
+
|
|
88
106
|
## 🚀 v0.1.3 版本更新说明 (Changed in v0.1.3)
|
|
89
107
|
|
|
90
108
|
* **浮点与小数 Token 防崩保护 (Floats & Decimals)**:
|
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
|
-
|
|
5
|
+
const module = { exports: {} };
|
|
5
6
|
const React = require('react');
|
|
6
7
|
const NS = 'dsh-usage-guard';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
);
|
|
81
|
+
}
|
|
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}
|
|
119
|
+
|
|
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);
|
|
14
124
|
}
|
|
15
125
|
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
};
|
|
28
163
|
}
|
|
29
|
-
const
|
|
164
|
+
const ErrorBoundary = createErrorBoundary();
|
|
30
165
|
|
|
31
|
-
function
|
|
32
|
-
const [
|
|
33
|
-
const [snap, setSnap] = React.useState({ status: 'loading', value: null, writable: false });
|
|
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
|
-
|
|
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
|
|
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:
|
|
54
|
-
return;
|
|
181
|
+
setSnap({ status: 'unavailable', value: {} });
|
|
182
|
+
return () => {};
|
|
55
183
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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:
|
|
218
|
+
setSnap({ status: 'unavailable', value: {} });
|
|
219
|
+
return () => {};
|
|
71
220
|
}
|
|
72
|
-
return () => {
|
|
73
|
-
try { off && off(); } catch {}
|
|
74
|
-
};
|
|
75
221
|
}, [ctx]);
|
|
76
222
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
})();
|
|
223
|
+
React.useEffect(() => {
|
|
224
|
+
return () => {
|
|
225
|
+
if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
|
|
226
|
+
};
|
|
227
|
+
}, []);
|
|
84
228
|
|
|
85
|
-
const showMessage = (text,
|
|
229
|
+
const showMessage = (text, ok = true) => {
|
|
86
230
|
if (msgTimerRef.current) clearTimeout(msgTimerRef.current);
|
|
87
|
-
setMsg(text);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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')
|
|
263
|
+
showMessage(t('saved'), true);
|
|
118
264
|
}
|
|
119
265
|
} catch (e) {
|
|
120
266
|
showMessage(String(e && e.message ? e.message : e), false);
|
|
@@ -122,153 +268,288 @@ window.__ModuleLoader__.load({
|
|
|
122
268
|
setSaving(false);
|
|
123
269
|
};
|
|
124
270
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
133
288
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
React.createElement(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 })
|
|
289
|
+
return React.createElement(
|
|
290
|
+
'div',
|
|
291
|
+
{ className: 'ug-body' },
|
|
292
|
+
|
|
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
|
+
)
|
|
166
316
|
),
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
),
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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'))
|
|
335
|
+
),
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
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',
|
|
239
416
|
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
},
|
|
244
423
|
},
|
|
245
|
-
|
|
246
|
-
)
|
|
247
|
-
|
|
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;
|
|
248
449
|
}
|
|
249
|
-
}
|
|
250
|
-
|
|
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();
|
|
251
466
|
}
|
|
467
|
+
return () => clearInterval(timer);
|
|
468
|
+
}
|
|
252
469
|
|
|
253
|
-
|
|
470
|
+
let ctx = null;
|
|
471
|
+
|
|
472
|
+
function apply(c) {
|
|
473
|
+
ctx = c;
|
|
474
|
+
const addLocale = (locale, dictionary) => {
|
|
254
475
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
}
|
|
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);
|
|
267
517
|
}
|
|
268
|
-
}
|
|
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
|
+
}
|
|
269
526
|
}
|
|
270
|
-
};
|
|
271
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
|
+
};
|
|
272
553
|
return module.exports;
|
|
273
554
|
},
|
|
274
|
-
});
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|