@goodandready/dsh-cost-meter 0.8.1 → 0.8.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/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerPluginUpdater } from './updater.js'
1
2
  // dsh-cost-meter — host half.
2
3
  //
3
4
  // The harness prices nothing on purpose: pi-ai carries a `cost` field but
@@ -20,12 +21,38 @@ import { dirname, join } from 'node:path'
20
21
  import z from '@deepseek-ai/schemastery'
21
22
  import { z as zod } from 'zod'
22
23
 
23
- export const name = 'dsh-cost-meter'
24
+ export const name = '@goodandready/dsh-cost-meter'
24
25
  export const inject = ['webServer']
25
26
 
26
27
  const NS = 'dsh-cost-meter'
27
28
  const STATE_PATH = '/dsh-cost-meter/state'
28
29
  const REFRESH_PATH = '/dsh-cost-meter/refresh'
30
+ export function isLoopback(value) {
31
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
32
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
33
+ || address?.startsWith('127.') === true
34
+ || address?.startsWith('::ffff:127.') === true
35
+ }
36
+
37
+ export function isTrustedCaller(request) {
38
+ const remote = request?.socket?.remoteAddress || request?.connection?.remoteAddress
39
+ if (isLoopback(remote)) return true
40
+ const site = request?.headers?.['sec-fetch-site']
41
+ if (site === 'same-origin' || site === 'same-site') return true
42
+ if (site === 'cross-site') return false
43
+ const origin = request?.headers?.['origin']
44
+ const host = request?.headers?.['host']
45
+ if (origin && host) {
46
+ try {
47
+ const url = new URL(origin)
48
+ if (url.host === host) return true
49
+ } catch (e) {
50
+ /* invalid url */
51
+ }
52
+ }
53
+ return false
54
+ }
55
+
29
56
  const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
30
57
  const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
31
58
 
@@ -532,6 +559,11 @@ export function apply(ctx, config) {
532
559
  res.end(JSON.stringify({ error: 'method not allowed' }))
533
560
  return
534
561
  }
562
+ if (!isTrustedCaller(req)) {
563
+ res.writeHead(403, { 'Content-Type': 'application/json' })
564
+ res.end(JSON.stringify({ error: 'forbidden source origin' }))
565
+ return
566
+ }
535
567
  const now = Date.now()
536
568
  const COOLDOWN_MS = 30000
537
569
  if (now - lastManualRefreshAt < COOLDOWN_MS) {
@@ -627,4 +659,14 @@ export function apply(ctx, config) {
627
659
  }),
628
660
  'dsh-cost-meter: state route',
629
661
  )
662
+
663
+ // Route 3: /dsh-cost-meter/update (in-app one-click updater)
664
+ if (ctx.webServer?.register) {
665
+ ctx.effect(() => registerPluginUpdater(ctx, {
666
+ endpoint: '/dsh-cost-meter/update',
667
+ packageName: name,
668
+ manifestUrl: new URL('../package.json', import.meta.url),
669
+ }), 'dsh-cost-meter: updater')
670
+ }
671
+
630
672
  }
package/lib/updater.js ADDED
@@ -0,0 +1,270 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
9
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
10
+ const VERSION_CACHE_MS = 5 * 60_000
11
+ let latestCache = undefined
12
+
13
+ function header(request, name) {
14
+ const value = request?.headers?.[name]
15
+ return Array.isArray(value) ? value[0] : value
16
+ }
17
+
18
+ function isLoopback(value) {
19
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
20
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
21
+ || address?.startsWith('127.') === true
22
+ || address?.startsWith('::ffff:127.') === true
23
+ }
24
+
25
+ export function isTrustedUpdateRequest(request) {
26
+ if (header(request, UPDATE_HEADER) !== '1') return false
27
+ if (!isLoopback(request?.socket?.remoteAddress)) return false
28
+ const site = header(request, 'sec-fetch-site')
29
+ if (site !== undefined && site !== 'same-origin') return false
30
+ const origin = header(request, 'origin')
31
+ const host = header(request, 'host')
32
+ if (origin === undefined || host === undefined) return false
33
+ try {
34
+ const url = new URL(origin)
35
+ return (url.protocol === 'http:' || url.protocol === 'https:')
36
+ && isLoopback(url.hostname) && url.host === host
37
+ } catch (e) {
38
+ return false
39
+ }
40
+ }
41
+
42
+ function validProfileName(value) {
43
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
44
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
45
+ }
46
+
47
+ function profileNameFromArgv(argv) {
48
+ if (!Array.isArray(argv)) return undefined
49
+ for (let index = 2; index < argv.length; index += 1) {
50
+ if (argv[index] === '--profile') return argv[index + 1]
51
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
52
+ }
53
+ return argv[2] === 'web' ? 'web' : undefined
54
+ }
55
+
56
+ function findDshCliEntry() {
57
+ const value = process.argv[1]
58
+ if (typeof value !== 'string' || value.length === 0) return undefined
59
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
60
+ if (!existsSync(entry)) return undefined
61
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
62
+ const manifestPath = resolve(directory, 'package.json')
63
+ if (existsSync(manifestPath)) {
64
+ try {
65
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
66
+ const bin = typeof manifest.bin === 'string'
67
+ ? manifest.bin
68
+ : typeof manifest.bin === 'object' && manifest.bin !== null
69
+ ? manifest.bin.dsh
70
+ : undefined
71
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
72
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
73
+ } catch (e) {
74
+ /* continue */
75
+ }
76
+ }
77
+ const parent = dirname(directory)
78
+ if (parent === directory) return undefined
79
+ }
80
+ }
81
+
82
+ function detectRuntime() {
83
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
84
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'))
85
+ const selected = profileNameFromArgv(process.argv)
86
+ const profileName = validProfileName(selected)
87
+ ? selected
88
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
89
+ const cliEntry = findDshCliEntry()
90
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
91
+ }
92
+
93
+ function parseSemver(value) {
94
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || ''))
95
+ if (match === null) return undefined
96
+ return {
97
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
98
+ prerelease: match[4]?.split('.') ?? [],
99
+ }
100
+ }
101
+
102
+ function comparePrerelease(candidateParts, currentParts) {
103
+ if (candidateParts.length === 0 && currentParts.length > 0) return 1
104
+ if (candidateParts.length > 0 && currentParts.length === 0) return -1
105
+ if (candidateParts.length === 0 && currentParts.length === 0) return 0
106
+
107
+ const len = Math.max(candidateParts.length, currentParts.length)
108
+ for (let i = 0; i < len; i += 1) {
109
+ const a = candidateParts[i]
110
+ const b = currentParts[i]
111
+ if (a === undefined) return -1
112
+ if (b === undefined) return 1
113
+ if (a === b) continue
114
+
115
+ const aNum = /^\d+$/.test(a) ? Number(a) : undefined
116
+ const bNum = /^\d+$/.test(b) ? Number(b) : undefined
117
+
118
+ if (aNum !== undefined && bNum !== undefined) {
119
+ return aNum > bNum ? 1 : -1
120
+ }
121
+ if (aNum !== undefined && bNum === undefined) {
122
+ return -1
123
+ }
124
+ if (aNum === undefined && bNum !== undefined) {
125
+ return 1
126
+ }
127
+ return a.localeCompare(b) > 0 ? 1 : -1
128
+ }
129
+ return 0
130
+ }
131
+
132
+ export function isNewerVersion(currentValue, candidateValue) {
133
+ const current = parseSemver(currentValue)
134
+ const candidate = parseSemver(candidateValue)
135
+ if (current === undefined || candidate === undefined) return false
136
+ for (let index = 0; index < 3; index += 1) {
137
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
138
+ }
139
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
140
+ }
141
+
142
+ async function fetchLatestVersion(packageName, registry) {
143
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
144
+ return latestCache.version
145
+ }
146
+ try {
147
+ const reg = registry.replace(/\/+$/, '')
148
+ const response = await fetch(`${reg}/${encodeURIComponent(packageName)}/latest`, {
149
+ signal: AbortSignal.timeout(6000),
150
+ })
151
+ if (!response.ok) return undefined
152
+ const value = await response.json()
153
+ if (typeof value?.version !== 'string' || !value.version) return undefined
154
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
155
+ return value.version
156
+ } catch (e) {
157
+ return undefined
158
+ }
159
+ }
160
+
161
+ async function readCurrentVersion(manifestUrl) {
162
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
163
+ if (typeof value?.version !== 'string' || !value.version) throw new Error('Cannot read current plugin version.')
164
+ return value.version
165
+ }
166
+
167
+ export async function checkUpdateStatus(options, target = detectRuntime()) {
168
+ const current = await readCurrentVersion(options.manifestUrl)
169
+ const latest = await fetchLatestVersion(options.packageName, options.registry || 'https://registry.npmjs.org')
170
+ return {
171
+ packageName: options.packageName,
172
+ currentVersion: current,
173
+ ...(latest === undefined ? {} : { latestVersion: latest }),
174
+ latestCheckFailed: latest === undefined,
175
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
176
+ profileName: target.profileName,
177
+ canAutoUpdate: target.cliEntry !== undefined,
178
+ }
179
+ }
180
+
181
+ async function installExact(target, packageSpec, options) {
182
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
183
+ await new Promise((resolvePromise, reject) => {
184
+ const child = spawn(process.execPath, [
185
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
186
+ packageSpec,
187
+ `--registry=${options.registry || 'https://registry.npmjs.org/'}`,
188
+ ], {
189
+ cwd: target.profileDir,
190
+ windowsHide: true,
191
+ stdio: ['ignore', 'pipe', 'pipe'],
192
+ env: { ...process.env, NO_COLOR: '1' },
193
+ })
194
+ let detail = ''
195
+ child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
196
+ child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
197
+ const timer = setTimeout(() => {
198
+ child.kill()
199
+ reject(new Error('Update timed out.'))
200
+ }, UPDATE_TIMEOUT_MS)
201
+ child.once('error', (error) => { clearTimeout(timer); reject(error) })
202
+ child.once('exit', (code) => {
203
+ clearTimeout(timer)
204
+ if (code === 0) resolvePromise()
205
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
206
+ })
207
+ })
208
+ }
209
+
210
+ function writeResponseJson(response, statusCode, value) {
211
+ response.writeHead(statusCode, {
212
+ 'content-type': 'application/json; charset=utf-8',
213
+ 'cache-control': 'no-store',
214
+ })
215
+ response.end(JSON.stringify(value))
216
+ }
217
+
218
+ export function registerPluginUpdater(ctx, options) {
219
+ if (!ctx?.webServer?.register) return () => {}
220
+ let installing = false
221
+ return ctx.webServer.register({
222
+ kind: 'exact',
223
+ path: options.endpoint,
224
+ handler: async (request, response) => {
225
+ try {
226
+ const target = detectRuntime()
227
+ if (request.method === 'GET' || request.method === 'HEAD') {
228
+ const payload = await checkUpdateStatus(options, target)
229
+ writeResponseJson(response, 200, payload)
230
+ return
231
+ }
232
+ if (request.method !== 'POST') {
233
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
234
+ response.end()
235
+ return
236
+ }
237
+ if (!isTrustedUpdateRequest(request)) {
238
+ writeResponseJson(response, 403, { ok: false, error: 'Rejected non-local or cross-origin update request.' })
239
+ return
240
+ }
241
+ if (installing) {
242
+ writeResponseJson(response, 409, { ok: false, error: 'This plugin is already updating.' })
243
+ return
244
+ }
245
+ installing = true
246
+ try {
247
+ const before = await checkUpdateStatus(options, target)
248
+ if (before.latestVersion === undefined) {
249
+ writeResponseJson(response, 503, { ok: false, error: 'The latest version is temporarily unavailable.' })
250
+ return
251
+ }
252
+ if (!before.updateAvailable) {
253
+ writeResponseJson(response, 200, before)
254
+ return
255
+ }
256
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
257
+ writeResponseJson(response, 200, {
258
+ ...before,
259
+ updatedVersion: before.latestVersion,
260
+ restartRequired: true,
261
+ })
262
+ } finally {
263
+ installing = false
264
+ }
265
+ } catch (error) {
266
+ writeResponseJson(response, 500, { ok: false, error: String(error?.message || error) })
267
+ }
268
+ },
269
+ })
270
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "DeepSeek Harness plugin: a session cost chip in the conversation header - live token usage by bucket, peak/off-peak tariff, and a countdown to the next tariff switch.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,10 +11,11 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "files": [
14
- "lib",
14
+ "lib/",
15
15
  "cordis.patch.yml",
16
16
  "README.md",
17
- "docs",
17
+ "README.ru.md",
18
+ "README.zh.md",
18
19
  "LICENSE"
19
20
  ],
20
21
  "publishConfig": {
@@ -26,7 +27,9 @@
26
27
  },
27
28
  "client": {
28
29
  "platform": "web",
29
- "inject": []
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-locale"
32
+ ]
30
33
  }
31
34
  },
32
35
  "keywords": [
@@ -1,9 +0,0 @@
1
- # Публичный релиз и deployment
2
-
3
- Релизная версия повышается patch-изменением (v0.7.6). После merge в `main`
4
- создаются annotated tag, пакет в npm (`@goodandready/dsh-cost-meter`) и
5
- публичный GitHub release в `GooDAnDReaDY/dsh-cost-meter`.
6
-
7
- Production должен содержать exact package version из npm и не должен иметь
8
- локальной зависимости на исходный checkout. После restart проверяются
9
- service health, browser loader и `GET /dsh-cost-meter/state`.
@@ -1,111 +0,0 @@
1
- # DESIGN.md — dsh-cost-meter
2
-
3
- ## Product / Purpose
4
- - Назначение: Отображение стоимости активной сессии диалога в реальном времени, тарифов (пик / не-пик / единый) за 1M токенов, обратного отсчёта до смены окна, прозрачной детализации расходов по каждой задействованной модели и нативного управления конфигурацией плагина.
5
- - Аудитория: Пользователи и инженеры DeepSeek Harness, контролирующие потребление токенов и бюджет на LLM-провайдеров.
6
- - Статус: Активная публичная поставка (@goodandready/dsh-cost-meter).
7
-
8
- ## User Surfaces
9
- - Web/UI:
10
- - Компактный чип в слоте `conversation.session.header.utilities` (order: 10).
11
- - Выпадающая панель детализации (width: 320px, absolute positioning, click-outside dismissal).
12
- - Карточка настроек плагина в `settings.plugin.item` (слот `settings.plugin.item` с fallback на `settings.section`).
13
- - DSH UI / settings / slots:
14
- - Слот чипа: `conversation.session.header.utilities`.
15
- - Слот карточки настроек: `settings.plugin.item`.
16
- - Настройки: реестр DSH `settings.register("dsh-cost-meter", Config)` (валюта `currency`, множитель курса `usdRate`, таймзона `displayTimeZone`, опрос OpenRouter `useOpenRouter`, ручные ставки `prices`, сопоставление `modelMap`).
17
- - Локализация: регистрация словарей через `ctx.locale.register("dsh-cost-meter", { en, ru })` с поддержкой переключения языков через `useActiveLocale(ctx)`.
18
- - API:
19
- - HTTP `GET /dsh-cost-meter/state` (параметр `?models=...` для получения тарифов по задействованным моделям).
20
- - CLI:
21
- - Управление через CLI харнесса: `dsh plugin --profile web add @goodandready/dsh-cost-meter`.
22
- - Документация:
23
- - README.md, index.md, docs/testing/public-route.md, docs/testing/projection-lifecycle.md, docs/deployment/public-release.md.
24
-
25
- ## Visual Direction (Unified with dsh-clinebot)
26
- - Атмосфера: Строгий утилитарный нативный интерфейс DSH, высокая информационная плотность без визуального шума.
27
- - Утверждённые референсы: Эталонный плагин `@goodandready/dsh-clinebot`, дизайн-система DeepSeek Harness (`--dsw-alias-*`), карточки секций `.dcm-section-card`, бейджи статуса `.dcm-badge`, адаптивная сетка параметров `.dcm-grid-2`, чипы в шапке сессии.
28
- - Отказоустойчивость: Обязательный `ErrorBoundary` вокруг карточки настроек и поповера шапки (предотвращает исчезновение слотов ядра при исключениях рендеринга).
29
- - Синхронизация зеркал: `refreshMirrorUntilVisible(ctx)` для надёжного доступа к зеркалу `settingsScope`.
30
- - Атмосфера: Строгий утилитарный нативный интерфейс DSH, высокая информационная плотность без визуального шума.
31
- - Утверждённые референсы: Дизайн-система DeepSeek Harness (`--dsw-alias-*`), чипы в шапке сессии (`dsh-key-rotation`, `dsh-context-lens`), стандарт карточек настроек DSH.
32
- - Не копировать: Избыточные градиенты, внешние модальные окна, тяжелые тени, кастомные несогласованные шрифты.
33
-
34
- ## Foundations
35
- - Цвета и роли:
36
- - `--dsw-alias-label-primary`: основной текст, заголовки, суммы.
37
- - `--dsw-alias-label-tertiary`: второстепенные пояснения, метки столбцов, чип без тарифа.
38
- - `--dsw-alias-border-l2`: разделители секций и обводка панели.
39
- - `--dsw-alias-bg-layer-2` / `--dsw-alias-bg-base`: подложка всплывающей панели и карточки настроек.
40
- - `--dsw-alias-state-success-primary` / `--dsw-alias-state-success-tertiary`: подсветка льготного окна (не-пик).
41
- - `--dsw-alias-state-warn-primary` / `--dsw-alias-state-warn-tertiary`: подсветка полного тарифа (пик).
42
- - Типографика:
43
- - Системный стек шрифтов DSH, размер 12px, интерлиньяж 1.5.
44
- - Обязательно `fontVariantNumeric: "tabular-nums"` для всех числовых колонок, токенов и таймеров.
45
- - Сетка и отступы:
46
- - Чип: высота 22px, padding: 0 8px, gap: 6px, border-radius: 6px.
47
- - Панель: padding: 12px 14px, border-radius: 10px, ширина 320px.
48
- - Карточка настроек: нативный складной контейнер (`.dcm-card`) с шапкой, статусами-бейджами (`dcm-badge-ok`, `dcm-badge-warn`, `dcm-badge-bad`), 3 структурированными секциями (`Основные параметры`, `Каталог OpenRouter`, `Диагностика и статус`), защитой ввода и кнопкой сохранения (`dcm-btn-primary`).
49
-
50
- ## Components & States
51
- - Чип:
52
- - `Unknown Tariff`: отображает «тариф ?» в приглушённом цвете `label-tertiary`.
53
- - `Flat Tariff`: фон `--dsw-alias-state-warn-tertiary`, текст `--dsw-alias-state-warn-primary`, заголовок «Единый тариф».
54
- - `Off-Peak`: зелёная плашка успеха (льготный тариф).
55
- - `Peak`: оранжевая плашка предупреждения (полный тариф).
56
- - Таймер: время до смены тарифа с пониженной непрозрачностью (`opacity: 0.75`).
57
- - Панель:
58
- - Заголовок с текущей моделью и статусом окна.
59
- - Сетка тарифов за 1M токенов (не-пик / пик / единая ставка).
60
- - Блок «Из чего сложилась сумма»:
61
- - Список всех моделей сессии с токенами и суммарной стоимостью.
62
- - Для модели без тарифа отображается статус `не указана стоимость` (`rate_unspecified`).
63
- - Итог сессии: `Сессия: ≈ <валюта><сумма>`.
64
- - Отсутствие мусорных служебных плашек (строка источника OpenRouter исключена).
65
- - Карточка настроек (`CostMeterCard`):
66
- - Сворачиваемый заголовок с шевроном и иконкой счётчика.
67
- - Статус снимка: `ready` (зелёный бейдж), `loading` (нейтральный), `unavailable` (серый).
68
- - Поля: выбор символа валюты (`currency`), курс к USD (`usdRate`), таймзона отображения (`displayTimeZone`).
69
- - Локализованные лейблы и плейсхолдеры на русском и английском языках.
70
-
71
- ## User Flows
72
- 1. Пользователь ведёт диалог с ассистентом: чип в шапке инкрементально обновляет потраченную сумму прямо во время стриминга ответа ассистента.
73
- 2. Пользователь нажимает на чип: открывается всплывающая панель с тарифной сеткой и расходами по каждой модели сессии.
74
- 3. Если модель не тарифицирована: в списке моделей выводится статус `не указана стоимость`.
75
- 4. Пользователь открывает настройки DSH (`Настройки` -> `Плагины`): карточка `dsh-cost-meter` позволяет изменить валюту, курс и таймзону с мгновенным сохранением в `settingsScope`.
76
-
77
- ## Do / Don't
78
- - Do:
79
- - Использовать CSS-переменные DSH для согласованности с тёмной и светлой темами.
80
- - Всегда разделять расходы по задействованным моделям.
81
- - Использовать табличные цифры (`tabular-nums`) для предотвращения дёргания вёрстки при стриме.
82
- - Использовать `ctx.locale.register` и `useActiveLocale` для всех строк интерфейса.
83
- - Don't:
84
- - Не выводить техническую служебную информацию о каталоге OpenRouter в интерфейс пользователя.
85
- - Не заменять отсутствие тарифа обманчивым нулём или невнятным прочерком — писать прямо `не указана стоимость`.
86
- - Не допускать утечек локальных путей и инфраструктурных данных в коде или документации.
87
-
88
- ## History & Decisions
89
- - 2026-09-12 (v0.8.1):
90
- - Оптимизация производительности расчёта тарифов: замена 1440-минутного цикла на алгоритм O(K) по точкам перехода и мемоизация экстремумов через WeakMap.
91
- - Серверный кэш разрешения тарифов resolveTariffCache (Map) с автоматической очисткой при обновлении каталога.
92
- - Поддержка ETag и ответа 304 Not Modified на эндпоинте /dsh-cost-meter/state.
93
- - Кнопка ручной синхронизации каталога цен в секции OpenRouter с защитой от флуда (cooldown 30 сек).
94
- - Интеллектуальные подсказки экономии: плашка скорого наступления льготного тарифа (скидка 50%) и отсчёт времени.
95
- - Поддержка настраиваемого порога бюджета сессии (budgetThreshold) с предупреждающей подсветкой чипа.
96
- - Кнопка быстрого копирования текстового отчёта о расходах в буфер обмена.
97
- - Таблица нормализации алиасов вендоров (z-ai -> zai, deepseek-official -> deepseek, gemini -> google).
98
- - 2026-09-05 (v0.7.9):
99
- - Hotfix: исправлен ReferenceError (t is not defined) в объекте словаря ru (lib/client.js) (#15).
100
- - Добавлен автоматический тест на чистое исполнение фабрики client loader (`test/public-route.test.mjs`).
101
- - 2026-09-05 (v0.7.4):
102
- - Добавлена нативная карточка настроек в слот `settings.plugin.item` (`#12`).
103
- - Реализована полная локализация клиентской части через `ctx.locale.register` (`#2`).
104
- - Реализовано provider-aware разрешение тарифов OpenRouter vs DeepSeek (`#3`).
105
- - Добавлены всеобъемлющие регрессионные тесты на проекцию и сопоставление (`test/projection-lifecycle.test.mjs`, `#1`).
106
- - Закрыт issue о согласовании публичного scope пакета (`#13`).
107
- - 2026-09-05 (v0.7.3):
108
- - Удалена строка источника цен OpenRouter.
109
- - Список моделей в блоке «Из чего сложилась сумма» унифицирован и отображается для всех задействованных моделей сессии.
110
- - Для нетарифицированных моделей введён явный статус «не указана стоимость».
111
- - Пакет переведён на публичный scope `@goodandready/dsh-cost-meter`.
@@ -1,17 +0,0 @@
1
- # Findings: Issue #25 (v0.8.1)
2
-
3
- ## 1. Клиентские вычисления расписания
4
- - В `lib/client.js`: `extremes()` и `minutesUntilChange()` вызывались на каждом тике таймера (15 сек) и перерисовывали 1440-минутный цикл:
5
- `for (let minute = 0; minute < 1440; minute++)` с поиском в `windows`.
6
- - В сутках конечное число окон $K$ (обычно 2–4). Смена тарифа может происходить только на границах интервалов: `window.start` и `window.end`.
7
- - Замена на $O(K)$ даёт мгновенный расчёт без аллокации сотен объектов в секунду при активных сессиях.
8
-
9
- ## 2. Разрешение тарифов на сервере
10
- - `resolveTariff(cfg, catalog.index, route)` перебирает регулярные выражения и суффиксы.
11
- - Кэширование в `Map<string, ResolvedTariff>` с очисткой при обновлении каталога полностью снимает нагрузку на CPU при частом пуллинге `/state` клиентами DSH.
12
-
13
- ## 3. Поддержка ETag
14
- - Для `/dsh-cost-meter/state` генерация слабого ETag (`W/"<hash>"`) на основе `updatedAt`, `fetchedAt` и счетчиков токенов позволяет клиентам передавать `If-None-Match` и получать `304 Not Modified`, экономя полосу пропускания и парсинг JSON.
15
-
16
- ## 4. Алиасы вендоров
17
- - Разные провайдеры в DSH могут передавать вендоров как `z-ai`, `zai`, `google`, `gemini`, `deepseek-ai`, `deepseek`. Единая нормализация исключит ситуации fallback на generic тариф.
@@ -1,12 +0,0 @@
1
- # Progress: Issue #25 (v0.8.1)
2
-
3
- - **2026-09-12 19:10**:
4
- - Создана Gitea Issue #25.
5
- - Создан worktree `.worktrees/issue-25` от `origin/main` (ветка `feat/issue-25-perf-and-smart-features`).
6
- - Инициализированы планы в `docs/plans/`.
7
-
8
- - **2026-09-12 19:16**:
9
- - Реализованы оптимизации в `lib/index.js` (resolveTariffCache, ETag / 304, POST /refresh, budgetThreshold, VENDOR_ALIASES).
10
- - Реализованы улучшения в `lib/client.js` (O(K) minutesUntilChange, memoized extremes, кнопка синхронизации с cooldown, порог бюджета, плашка экономии, копирование сводки).
11
- - Добавлены тесты в `test/v081-perf-smart.test.mjs` (все 20 тестов успешно пройдены).
12
- - Версия поднята до `0.8.1`.
@@ -1,31 +0,0 @@
1
- # Task Plan: Issue #25 (v0.8.1) — Оптимизация производительности, ручная синхронизация каталога и подсказки экономии
2
-
3
- ## Цели
4
- 1. **Скорость**:
5
- - Оптимизация алгоритма расчета времени смены тарифа (`minutesUntilChange`) и экстремумов (`extremes`) с $O(1440)$ до $O(K)$ без полного минутного цикла.
6
- - Серверный кэш `resolveTariffCache` для быстрого разрешения моделей.
7
- - Поддержка ETag / 304 Not Modified на эндпоинте `/dsh-cost-meter/state`.
8
- 2. **Качество и управление**:
9
- - Маршрут `POST /dsh-cost-meter/refresh` с rate limit (cooldown 30 сек) и кнопка «Синхронизировать сейчас» в настройках.
10
- - Таблица нормализации алиасов вендоров (Smart Vendor Aliases).
11
- - Индикация stale-кэша каталога в UI.
12
- 3. **Экономия**:
13
- - Подсказка в поповере о приближении льготного тарифа (50% скидка).
14
- - Конфигурируемый порог сессии `budgetThreshold` с предупреждающей подсветкой чипа.
15
- - Кнопка «Скопировать сводку» в поповере.
16
- 4. **Стандарты и релиз**:
17
- - 100% прохождение тестов `npm test`.
18
- - Проверка размера файлов (< 256 KiB).
19
- - Тестирование на test server (MiniPC :3082), затем на MiniAI candidate (:3080).
20
- - Запрос подтверждения пользователя -> публикация v0.8.1 в npm/GitHub -> rollout на прод.
21
-
22
- ## Статус выполнения
23
- - [x] Issue #25 создана
24
- - [x] Worktree `.worktrees/issue-25` развёрнут
25
- - [x] Реализация серверной части (`lib/index.js`)
26
- - [x] Реализация клиентской части (`lib/client.js`)
27
- - [x] Добавление unit-тестов в `test/`
28
- - [x] Обновление документации (`DESIGN.md`, `README.md`, `README.ru.md`)
29
- - [ ] Тестирование на стендах (:3082, :3080)
30
- - [ ] Публикация релиза v0.8.1
31
- - [ ] Закрытие Issue #25 и очистка worktree
@@ -1,28 +0,0 @@
1
- # Регрессионное тестирование проекции тарифов и жизненного цикла
2
-
3
- Тесты проверяют логику тарификации, парсинг временных окон UTC, сопоставление моделей и provider-aware разрешение ставок.
4
-
5
- ## Запуск тестов
6
-
7
- ```bash
8
- npm test
9
- ```
10
-
11
- ## Проверяемые сценарии (`test/projection-lifecycle.test.mjs`)
12
-
13
- 1. **`hhmmToMinutes` и `parseWindow`**:
14
- - Корректный разбор диапазонов `HH:MM-HH:MM` в минуты от начала суток UTC.
15
- - Граничные условия: переход через полночь (`23:00-02:00`), пустые или невалидные строки.
16
-
17
- 2. **`slotOf`**:
18
- - Вычисление 30-минутного слота суток (`0..47`) на основе переданного timestamp.
19
- - Независимость накопления расхода от текущего момента времени (сохранение исторической ставки слота).
20
-
21
- 3. **`indexCatalog` и `matchModel`**:
22
- - Индексация моделей OpenRouter с поддержкой поиска без учета регистра.
23
- - Нормализация суффиксов вендоров и версий (например, `vendor/model:variant`).
24
-
25
- 4. **`resolveTariff` (Provider-aware)**:
26
- - Если указан `provider: "openrouter"`, плагин в первую очередь обращается к каталогу OpenRouter, даже если имя модели содержит `deepseek`.
27
- - Если провайдер не `openrouter` (или `deepseek`), DeepSeek модели тарифицируются по официальной зашитой сетке (пик / не-пик).
28
- - Ручные ставки `prices` из конфигурации имеют высший приоритет над внешними каталогами.
@@ -1,12 +0,0 @@
1
- # Проверка публичного маршрута
2
-
3
- 1. Выполнить `npm test`.
4
- 2. Проверить `npm pack --dry-run` и отсутствие локальных установочных ссылок,
5
- абсолютных infra-путей, IP-адресов и секретов в поставляемых файлах.
6
- 3. Собрать tarball из проверенного Git worktree.
7
- 4. Установить tarball на изолированный тестовый DSH web server (MiniPC).
8
- 5. Для browser half проверить loader id `@goodandready/dsh-cost-meter`, HTTP route
9
- состояния `GET /dsh-cost-meter/state` и smoke-сценарий тарификации (список моделей,
10
- расчёт стоимости, статус «не указана стоимость»); для host half — bundle identity и host smoke.
11
- 6. Выполнить cleanup тестовой установки и убедиться, что постоянные плагины
12
- сохранили работоспособность.