@goodandready/dsh-usage-guard 0.1.7 → 0.1.9

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
@@ -8,26 +8,13 @@
8
8
  // cacheWriteTokens: usage.cacheWriteTokens ?? 0,
9
9
  //
10
10
  // Стоит провайдеру прислать порцию без первых двух, и сложение даёт NaN.
11
- // Дальше проверка схемы отвергает всю выжимку сессии, а наружу это выходит
12
- // так:
13
- //
14
- // history unavailable for session "...": expected number, received NaN
15
- //
16
- // Не «счётчик соврал», а «история не открывается вовсе» — и остаётся такой
17
- // навсегда, потому что выжимка считается проигрыванием журнала заново.
18
- //
19
- // Отсюда и лечение. Плагин не переписывает журналы на диске: он встаёт перед
20
- // сложением и подставляет ноль вместо пропажи. Проигрывание идёт через ту же
21
- // точку, поэтому уже испорченные сессии открываются снова — сами, без починки
22
- // файлов.
23
- //
24
- // Ноль вместо пропущенного счётчика — заведомо неправда. Но выбор не между
25
- // точным счётом и приблизительным, а между приблизительным и нечитаемой
26
- // историей.
11
+ // Дальше проверка схемы отвергает всю выжимку сессии.
27
12
 
28
13
  import z from '@deepseek-ai/schemastery'
29
14
 
30
15
  import { patchRegistry } from './patch.js'
16
+ import { telemetry } from './telemetry.js'
17
+ import { registerPluginUpdater } from './updater.js'
31
18
  import { complaint, damage, healed, usageOf } from './usage.js'
32
19
 
33
20
  export const name = 'dsh-usage-guard'
@@ -45,6 +32,10 @@ export const Config = z.object({
45
32
  .default(true)
46
33
  .description('Log a line naming the turn, the step and the raw sample whenever a damaged one arrives. '
47
34
  + 'Each turn, step and field set is reported once, not once per replay.'),
35
+ maxStepTokens: z
36
+ .natural()
37
+ .default(0)
38
+ .description('Maximum allowed token count for a single turn step. Spikes exceeding this ceiling are clamped to prevent integer overflow and runaway stats. Set to 0 to disable.'),
48
39
  })
49
40
 
50
41
  export function apply(ctx, config) {
@@ -52,7 +43,7 @@ export function apply(ctx, config) {
52
43
  try {
53
44
  live = Config(structuredClone(config ?? {})) ?? config
54
45
  } catch (_) {
55
- live = Config({}) ?? { repair: true, report: true }
46
+ live = Config({}) ?? { repair: true, report: true, maxStepTokens: 0 }
56
47
  }
57
48
 
58
49
  ctx.inject(['settings'], (sctx) => {
@@ -68,17 +59,36 @@ export function apply(ctx, config) {
68
59
  } catch (_) {}
69
60
  })
70
61
 
71
- // О каждом сочетании сессии, хода, шага и набора полей жалуемся один раз:
72
- // выжимка считается проигрыванием журнала заново, и без этого одна и та же
73
- // порция въезжала бы в журнал при каждом открытии истории.
74
- // Честное FIFO-вытеснение с ограничением в 1000 записей предотвращает утечку
75
- // памяти при длительной работе без сброса всей накопленной дедупликации.
62
+ // Register one-click updater and telemetry endpoints if webServer is available
63
+ ctx.inject(['webServer'], (wctx) => {
64
+ try {
65
+ wctx.effect(() => registerPluginUpdater(wctx, {
66
+ endpoint: '/api/dsh-usage-guard/update',
67
+ packageName: '@goodandready/dsh-usage-guard',
68
+ manifestUrl: new URL('../package.json', import.meta.url),
69
+ }), 'dsh-usage-guard: plugin updater route')
70
+
71
+ wctx.effect(() => wctx.webServer.register({
72
+ kind: 'exact',
73
+ path: '/api/dsh-usage-guard/telemetry',
74
+ handler: async (req, res) => {
75
+ if (req.method === 'GET' || req.method === 'HEAD') {
76
+ res.writeHead(200, {
77
+ 'content-type': 'application/json; charset=utf-8',
78
+ 'cache-control': 'no-store',
79
+ })
80
+ res.end(req.method === 'HEAD' ? undefined : JSON.stringify(telemetry.getSnapshot()))
81
+ return
82
+ }
83
+ res.writeHead(405, { allow: 'GET, HEAD' })
84
+ res.end()
85
+ },
86
+ }), 'dsh-usage-guard: telemetry route')
87
+ } catch (_) {}
88
+ })
89
+
76
90
  const told = new Set()
77
91
  const MAX_TOLD = 1000
78
-
79
- // Кэш санитизированных событий: каждое событие проходит через 10-15 проекций DSH.
80
- // WeakMap позволяет обработать событие один раз, отдавая остальным проекциям
81
- // готовый результат за O(1) без повторного парсинга и без утечек памяти.
82
92
  const eventCache = new WeakMap()
83
93
 
84
94
  const guard = (event) => {
@@ -91,31 +101,54 @@ export function apply(ctx, config) {
91
101
  eventCache.set(event, event)
92
102
  return event
93
103
  }
94
- const bad = damage(found.usage)
104
+ const bad = damage(found.usage, { maxStepTokens: live?.maxStepTokens })
95
105
  if (bad.length === 0) {
96
106
  eventCache.set(event, event)
97
107
  return event
98
108
  }
99
109
 
100
- if (live && live.report !== false) {
101
- const sessionTag = event?.sessionId ?? event?.data?.sessionId ?? event?.data?.turn ?? 'global'
102
- const seen = `${String(sessionTag)}/${String(found.turn)}/${String(found.step)}/${bad.join(',')}`
103
- if (!told.has(seen)) {
104
- if (told.size >= MAX_TOLD) {
105
- const oldest = told.values().next().value
106
- told.delete(oldest)
107
- }
108
- told.add(seen)
110
+ const sessionTag = event?.sessionId ?? event?.data?.sessionId ?? event?.data?.turn ?? 'global'
111
+ const seen = `${String(sessionTag)}/${String(found.turn)}/${String(found.step)}/${bad.join(',')}`
112
+
113
+ let clampedAny = false
114
+ let fixedTokensCount = 0
115
+ const maxLimit = Number(live?.maxStepTokens) > 0 ? Number(live?.maxStepTokens) : 0
116
+ for (const f of bad) {
117
+ const val = found.usage?.[f]
118
+ if (maxLimit > 0 && typeof val === 'number' && val > maxLimit) {
119
+ clampedAny = true
120
+ fixedTokensCount += (val - maxLimit)
121
+ } else {
122
+ fixedTokensCount += 1
123
+ }
124
+ }
125
+
126
+ if (!told.has(seen)) {
127
+ if (told.size >= MAX_TOLD) {
128
+ const oldest = told.values().next().value
129
+ told.delete(oldest)
130
+ }
131
+ told.add(seen)
132
+
133
+ telemetry.recordIncident({
134
+ turn: found.turn,
135
+ step: found.step,
136
+ badFields: bad,
137
+ fixedTokenCount: fixedTokensCount,
138
+ clamped: clampedAny,
139
+ provider: event?.provider ?? event?.data?.provider ?? 'unknown',
140
+ sample: found.usage,
141
+ })
142
+
143
+ if (live && live.report !== false) {
109
144
  try {
110
145
  // eslint-disable-next-line no-console
111
- console.warn(`[dsh-usage-guard] ${complaint(found, bad)}`)
112
- } catch {
113
- // Ошибка вывода не должна ломать обработку событий
114
- }
146
+ console.warn(`[dsh-usage-guard] ${complaint(found, bad, { maxStepTokens: live?.maxStepTokens })}`)
147
+ } catch {}
115
148
  }
116
149
  }
117
150
 
118
- const result = live && live.repair === false ? event : healed(event, bad)
151
+ const result = live && live.repair === false ? event : healed(event, bad, { maxStepTokens: live?.maxStepTokens })
119
152
  eventCache.set(event, result)
120
153
  return result
121
154
  }
@@ -127,3 +160,4 @@ export function apply(ctx, config) {
127
160
  )
128
161
  })
129
162
  }
163
+ export { telemetry }
@@ -0,0 +1,111 @@
1
+ // lib/telemetry.js — In-memory telemetry accumulator for dsh-usage-guard
2
+ // Tracks rescued malformed usage samples, fixed NaN counters, and clamped token spikes.
3
+
4
+ class UsageGuardTelemetry {
5
+ constructor() {
6
+ this.rescuedEvents = 0;
7
+ this.fixedTokens = 0;
8
+ this.clampedSpikes = 0;
9
+ this.lastIncident = null;
10
+ this.maxRecentIncidents = 20;
11
+ this.recentIncidents = [];
12
+ }
13
+
14
+ recordIncident({ turn = 0, step = 0, badFields = [], fixedTokenCount = 0, clamped = false, provider = 'unknown', sample = null, kind = 'malformed-usage', target = '' } = {}) {
15
+ this.rescuedEvents += 1;
16
+ this.fixedTokens += Number(fixedTokenCount) || 0;
17
+ if (clamped) {
18
+ this.clampedSpikes += 1;
19
+ }
20
+
21
+ const incident = {
22
+ timestamp: new Date().toISOString(),
23
+ kind: String(kind),
24
+ turn: turn ?? 0,
25
+ step: step ?? 0,
26
+ target: String(target || (Array.isArray(badFields) ? badFields.join(',') : '')),
27
+ fields: Array.isArray(badFields) ? badFields : [],
28
+ clamped: Boolean(clamped),
29
+ provider: typeof provider === 'string' ? provider : 'unknown',
30
+ sample: sample && typeof sample === 'object' ? JSON.stringify(sample).slice(0, 300) : null,
31
+ };
32
+
33
+ this.lastIncident = incident;
34
+ this.recentIncidents.unshift(incident);
35
+ if (this.recentIncidents.length > this.maxRecentIncidents) {
36
+ this.recentIncidents.pop();
37
+ }
38
+ return incident;
39
+ }
40
+
41
+ recordRescue(tokens = 0) {
42
+ this.rescuedEvents += 1;
43
+ this.fixedTokens += Number(tokens) || 0;
44
+ }
45
+
46
+ recordClamped(tokens = 0) {
47
+ this.clampedSpikes += 1;
48
+ this.fixedTokens += Number(tokens) || 0;
49
+ }
50
+
51
+ getSnapshot() {
52
+ return {
53
+ rescuedEvents: this.rescuedEvents,
54
+ fixedTokens: this.fixedTokens,
55
+ clampedSpikes: this.clampedSpikes,
56
+ lastIncident: this.lastIncident ? { ...this.lastIncident } : null,
57
+ recentIncidents: this.recentIncidents.map((i) => ({ ...i })),
58
+ };
59
+ }
60
+
61
+ reset() {
62
+ this.rescuedEvents = 0;
63
+ this.fixedTokens = 0;
64
+ this.clampedSpikes = 0;
65
+ this.lastIncident = null;
66
+ this.recentIncidents = [];
67
+ }
68
+ }
69
+
70
+ export const telemetry = new UsageGuardTelemetry();
71
+
72
+ export function recordIncident(options) {
73
+ return telemetry.recordIncident(options);
74
+ }
75
+
76
+ export function recordRescue(tokens) {
77
+ return telemetry.recordRescue(tokens);
78
+ }
79
+
80
+ export function recordClamped(tokens) {
81
+ return telemetry.recordClamped(tokens);
82
+ }
83
+
84
+ export function getSnapshot() {
85
+ return telemetry.getSnapshot();
86
+ }
87
+
88
+ export function resetTelemetry() {
89
+ telemetry.reset();
90
+ }
91
+
92
+ export function registerTelemetryRoute(ctx, options = {}) {
93
+ const endpoint = options.endpoint || '/api/dsh-usage-guard/telemetry';
94
+ return ctx.webServer.register({
95
+ kind: 'exact',
96
+ path: endpoint,
97
+ handler: async (request, response) => {
98
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
99
+ response.writeHead(405, { allow: 'GET, HEAD' });
100
+ response.end();
101
+ return;
102
+ }
103
+ const snapshot = telemetry.getSnapshot();
104
+ response.writeHead(200, {
105
+ 'content-type': 'application/json; charset=utf-8',
106
+ 'cache-control': 'no-store',
107
+ });
108
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(snapshot));
109
+ },
110
+ });
111
+ }
package/lib/updater.js ADDED
@@ -0,0 +1,266 @@
1
+ // lib/updater.js — Host-side one-click updater for @goodandready/dsh-usage-guard
2
+ // Implements canonical DSH plugin-updater specification.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { homedir } from 'node:os';
8
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const UPDATE_HEADER = 'x-dsh-plugin-update';
12
+ const UPDATE_TIMEOUT_MS = 10 * 60_000;
13
+ const VERSION_CACHE_MS = 5 * 60_000;
14
+ let latestCache;
15
+
16
+ function header(request, name) {
17
+ const value = request?.headers?.[name];
18
+ return Array.isArray(value) ? value[0] : value;
19
+ }
20
+
21
+ function isLoopback(value) {
22
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
23
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
24
+ || address?.startsWith('127.') === true
25
+ || address?.startsWith('::ffff:127.') === true;
26
+ }
27
+
28
+ export function isTrustedUpdateRequest(request) {
29
+ if (header(request, UPDATE_HEADER) !== '1') return false;
30
+ if (!isLoopback(request.socket?.remoteAddress)) return false;
31
+ const site = header(request, 'sec-fetch-site');
32
+ if (site !== undefined && site !== 'same-origin') return false;
33
+ const origin = header(request, 'origin');
34
+ const host = header(request, 'host');
35
+ if (origin === undefined || host === undefined) return false;
36
+ try {
37
+ const url = new URL(origin);
38
+ return (url.protocol === 'http:' || url.protocol === 'https:')
39
+ && isLoopback(url.hostname) && url.host === host;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ function validProfileName(value) {
46
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
47
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
48
+ }
49
+
50
+ function profileNameFromArgv(argv) {
51
+ for (let index = 2; index < argv.length; index += 1) {
52
+ if (argv[index] === '--profile') return argv[index + 1];
53
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
54
+ }
55
+ return argv[2] === 'web' ? 'web' : undefined;
56
+ }
57
+
58
+ function findDshCliEntry() {
59
+ const value = process.argv[1];
60
+ if (value === undefined || value === '') return undefined;
61
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
62
+ if (!existsSync(entry)) return undefined;
63
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
64
+ const manifestPath = resolve(directory, 'package.json');
65
+ if (existsSync(manifestPath)) {
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
68
+ const bin = typeof manifest.bin === 'string'
69
+ ? manifest.bin
70
+ : typeof manifest.bin === 'object' && manifest.bin !== null
71
+ ? manifest.bin.dsh
72
+ : undefined;
73
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
74
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
75
+ } catch {
76
+ // Continue searching parent package directories.
77
+ }
78
+ }
79
+ const parent = dirname(directory);
80
+ if (parent === directory) return undefined;
81
+ }
82
+ }
83
+
84
+ function runtime() {
85
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
86
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'));
87
+ const selected = profileNameFromArgv(process.argv);
88
+ const profileName = validProfileName(selected)
89
+ ? selected
90
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
91
+ const cliEntry = findDshCliEntry();
92
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
93
+ }
94
+
95
+ function parseSemver(value) {
96
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
97
+ if (match === null) return undefined;
98
+ return {
99
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
100
+ prerelease: match[4]?.split('.') ?? [],
101
+ };
102
+ }
103
+
104
+ function comparePrerelease(left, right) {
105
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
106
+ const length = Math.max(left.length, right.length);
107
+ for (let index = 0; index < length; index += 1) {
108
+ const a = left[index];
109
+ const b = right[index];
110
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
111
+ if (a === b) continue;
112
+ const aNumeric = /^\d+$/.test(a);
113
+ const bNumeric = /^\d+$/.test(b);
114
+ if (aNumeric && bNumeric) {
115
+ const aNumber = BigInt(a);
116
+ const bNumber = BigInt(b);
117
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
118
+ continue;
119
+ }
120
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
121
+ return a > b ? 1 : -1;
122
+ }
123
+ return 0;
124
+ }
125
+
126
+ export function isNewerVersion(currentValue, candidateValue) {
127
+ const current = parseSemver(currentValue);
128
+ const candidate = parseSemver(candidateValue);
129
+ if (current === undefined || candidate === undefined) return false;
130
+ for (let index = 0; index < 3; index += 1) {
131
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
132
+ }
133
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
134
+ }
135
+
136
+ async function latestVersion(packageName, registry) {
137
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) return latestCache.version;
138
+ try {
139
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
140
+ signal: AbortSignal.timeout(8_000),
141
+ });
142
+ if (!response.ok) return undefined;
143
+ const value = await response.json();
144
+ if (typeof value.version !== 'string' || value.version === '') return undefined;
145
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
146
+ return value.version;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ async function currentVersion(manifestUrl) {
153
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'));
154
+ if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
155
+ return value.version;
156
+ }
157
+
158
+ export async function getUpdateStatus(options, target = runtime()) {
159
+ const current = await currentVersion(options.manifestUrl);
160
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
161
+ return {
162
+ packageName: options.packageName,
163
+ currentVersion: current,
164
+ ...(latest === undefined ? {} : { latestVersion: latest }),
165
+ latestCheckFailed: latest === undefined,
166
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
167
+ profileName: target.profileName,
168
+ canAutoUpdate: target.cliEntry !== undefined,
169
+ };
170
+ }
171
+
172
+ async function installExact(target, packageSpec, options) {
173
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
174
+ await new Promise((resolvePromise, reject) => {
175
+ const child = spawn(process.execPath, [
176
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
177
+ '--config.minimumReleaseAge=0', packageSpec,
178
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
179
+ ], {
180
+ cwd: target.profileDir,
181
+ windowsHide: true,
182
+ stdio: ['ignore', 'pipe', 'pipe'],
183
+ env: { ...process.env, NO_COLOR: '1' },
184
+ });
185
+ let detail = '';
186
+ child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
187
+ child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000); });
188
+ const timer = setTimeout(() => {
189
+ child.kill();
190
+ reject(new Error('Update timed out; use the normal DSH update flow.'));
191
+ }, UPDATE_TIMEOUT_MS);
192
+ child.once('error', error => { clearTimeout(timer); reject(error); });
193
+ child.once('exit', code => {
194
+ clearTimeout(timer);
195
+ if (code === 0) resolvePromise();
196
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
197
+ });
198
+ });
199
+ }
200
+
201
+ function json(response, statusCode, value) {
202
+ response.writeHead(statusCode, {
203
+ 'content-type': 'application/json; charset=utf-8',
204
+ 'cache-control': 'no-store',
205
+ });
206
+ response.end(JSON.stringify(value));
207
+ }
208
+
209
+ export function registerPluginUpdater(ctx, options) {
210
+ const host = ctx;
211
+ let installing = false;
212
+ return host.webServer.register({
213
+ kind: 'exact',
214
+ path: options.endpoint,
215
+ handler: async (request, response) => {
216
+ try {
217
+ const target = runtime();
218
+ if (request.method === 'GET' || request.method === 'HEAD') {
219
+ const payload = await getUpdateStatus(options, target);
220
+ response.writeHead(200, {
221
+ 'content-type': 'application/json; charset=utf-8',
222
+ 'cache-control': 'no-store',
223
+ });
224
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload));
225
+ return;
226
+ }
227
+ if (request.method !== 'POST') {
228
+ response.writeHead(405, { allow: 'GET, HEAD, POST' });
229
+ response.end();
230
+ return;
231
+ }
232
+ if (!isTrustedUpdateRequest(request)) {
233
+ json(response, 403, { error: 'Rejected non-local or cross-origin update request.' });
234
+ return;
235
+ }
236
+ if (installing) {
237
+ json(response, 409, { error: 'This plugin is already updating.' });
238
+ return;
239
+ }
240
+ installing = true;
241
+ try {
242
+ const before = await getUpdateStatus(options, target);
243
+ if (before.latestVersion === undefined) {
244
+ json(response, 503, { error: 'The latest version is temporarily unavailable.' });
245
+ return;
246
+ }
247
+ if (!before.updateAvailable) {
248
+ json(response, 200, before);
249
+ return;
250
+ }
251
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
252
+ json(response, 200, {
253
+ ...before,
254
+ updatedVersion: before.latestVersion,
255
+ restartRequired: true,
256
+ });
257
+ } finally {
258
+ installing = false;
259
+ }
260
+ } catch (error) {
261
+ ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`);
262
+ json(response, 503, { error: 'Plugin update failed; see server logs.' });
263
+ }
264
+ },
265
+ });
266
+ }