@vibescore/tracker 0.0.1

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/src/lib/fs.js ADDED
@@ -0,0 +1,62 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+
4
+ async function ensureDir(p) {
5
+ await fs.mkdir(p, { recursive: true });
6
+ }
7
+
8
+ async function writeFileAtomic(filePath, content) {
9
+ const dir = path.dirname(filePath);
10
+ await ensureDir(dir);
11
+ const tmp = `${filePath}.tmp.${Date.now()}`;
12
+ await fs.writeFile(tmp, content, { encoding: 'utf8' });
13
+ await fs.rename(tmp, filePath);
14
+ }
15
+
16
+ async function readJson(filePath) {
17
+ try {
18
+ const raw = await fs.readFile(filePath, 'utf8');
19
+ return JSON.parse(raw);
20
+ } catch (_e) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ async function writeJson(filePath, obj) {
26
+ await writeFileAtomic(filePath, JSON.stringify(obj, null, 2) + '\n');
27
+ }
28
+
29
+ async function chmod600IfPossible(filePath) {
30
+ try {
31
+ await fs.chmod(filePath, 0o600);
32
+ } catch (_e) {}
33
+ }
34
+
35
+ async function openLock(lockPath, { quietIfLocked }) {
36
+ try {
37
+ const handle = await fs.open(lockPath, 'wx');
38
+ return {
39
+ async release() {
40
+ await handle.close().catch(() => {});
41
+ }
42
+ };
43
+ } catch (e) {
44
+ if (e && e.code === 'EEXIST') {
45
+ if (!quietIfLocked) {
46
+ process.stdout.write('Another sync is already running.\n');
47
+ }
48
+ return null;
49
+ }
50
+ throw e;
51
+ }
52
+ }
53
+
54
+ module.exports = {
55
+ ensureDir,
56
+ writeFileAtomic,
57
+ readJson,
58
+ writeJson,
59
+ chmod600IfPossible,
60
+ openLock
61
+ };
62
+
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const { createClient } = require('@insforge/sdk');
4
+
5
+ function getAnonKey() {
6
+ return process.env.VIBESCORE_INSFORGE_ANON_KEY || process.env.INSFORGE_ANON_KEY || '';
7
+ }
8
+
9
+ function getHttpTimeoutMs() {
10
+ const raw = process.env.VIBESCORE_HTTP_TIMEOUT_MS;
11
+ if (raw == null || raw === '') return 20_000;
12
+ const n = Number(raw);
13
+ if (!Number.isFinite(n)) return 20_000;
14
+ if (n <= 0) return 0;
15
+ return clampInt(n, 1000, 120_000);
16
+ }
17
+
18
+ function createTimeoutFetch(baseFetch) {
19
+ if (!baseFetch) return baseFetch;
20
+ return async (input, init = {}) => {
21
+ const timeoutMs = getHttpTimeoutMs();
22
+ if (!timeoutMs) return baseFetch(input, init);
23
+ const controller = new AbortController();
24
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
25
+ try {
26
+ return await baseFetch(input, { ...init, signal: controller.signal });
27
+ } catch (err) {
28
+ if (controller.signal.aborted) {
29
+ const timeoutErr = new Error(`Request timeout after ${timeoutMs}ms`);
30
+ timeoutErr.cause = err;
31
+ throw timeoutErr;
32
+ }
33
+ throw err;
34
+ } finally {
35
+ clearTimeout(timeoutId);
36
+ }
37
+ };
38
+ }
39
+
40
+ function createInsforgeClient({ baseUrl, accessToken } = {}) {
41
+ if (!baseUrl) throw new Error('Missing baseUrl');
42
+ const anonKey = getAnonKey();
43
+ return createClient({
44
+ baseUrl,
45
+ anonKey: anonKey || undefined,
46
+ edgeFunctionToken: accessToken || undefined,
47
+ fetch: createTimeoutFetch(globalThis.fetch)
48
+ });
49
+ }
50
+
51
+ function clampInt(value, min, max) {
52
+ const n = Number(value);
53
+ if (!Number.isFinite(n)) return min;
54
+ return Math.min(max, Math.max(min, Math.floor(n)));
55
+ }
56
+
57
+ module.exports = {
58
+ createInsforgeClient
59
+ };
@@ -0,0 +1,17 @@
1
+ const { issueDeviceToken, signInWithPassword } = require('./vibescore-api');
2
+
3
+ async function issueDeviceTokenWithPassword({ baseUrl, email, password, deviceName }) {
4
+ const accessToken = await signInWithPassword({ baseUrl, email, password });
5
+ const issued = await issueDeviceToken({ baseUrl, accessToken, deviceName });
6
+ return issued;
7
+ }
8
+
9
+ async function issueDeviceTokenWithAccessToken({ baseUrl, accessToken, deviceName }) {
10
+ const issued = await issueDeviceToken({ baseUrl, accessToken, deviceName });
11
+ return issued;
12
+ }
13
+
14
+ module.exports = {
15
+ issueDeviceTokenWithPassword,
16
+ issueDeviceTokenWithAccessToken
17
+ };
@@ -0,0 +1,77 @@
1
+ function createProgress({ stream } = {}) {
2
+ const out = stream || process.stdout;
3
+ const enabled = Boolean(out && out.isTTY);
4
+ const frames = ['|', '/', '-', '\\'];
5
+ const intervalMs = 90;
6
+
7
+ let timer = null;
8
+ let text = '';
9
+ let frame = 0;
10
+ let lastLen = 0;
11
+
12
+ function render() {
13
+ if (!enabled) return;
14
+ const line = `${frames[frame++ % frames.length]} ${text}`;
15
+ const pad = lastLen > line.length ? ' '.repeat(lastLen - line.length) : '';
16
+ lastLen = line.length;
17
+ out.write(`\r${line}${pad}`);
18
+ }
19
+
20
+ function start(initialText) {
21
+ if (!enabled) return;
22
+ text = initialText || '';
23
+ if (timer) clearInterval(timer);
24
+ timer = setInterval(render, intervalMs);
25
+ render();
26
+ }
27
+
28
+ function update(nextText) {
29
+ text = nextText || '';
30
+ render();
31
+ }
32
+
33
+ function stop() {
34
+ if (!enabled) return;
35
+ if (timer) clearInterval(timer);
36
+ timer = null;
37
+ out.write(`\r${' '.repeat(lastLen)}\r`);
38
+ lastLen = 0;
39
+ }
40
+
41
+ return { enabled, start, update, stop };
42
+ }
43
+
44
+ function renderBar(progress, width = 20) {
45
+ const p = Number.isFinite(progress) ? Math.min(1, Math.max(0, progress)) : 0;
46
+ const filled = Math.round(p * width);
47
+ const empty = Math.max(0, width - filled);
48
+ return `[${'='.repeat(filled)}${'-'.repeat(empty)}] ${Math.round(p * 100)}%`;
49
+ }
50
+
51
+ function formatNumber(n) {
52
+ const v = Number(n);
53
+ if (!Number.isFinite(v)) return '0';
54
+ return Math.trunc(v).toLocaleString('en-US');
55
+ }
56
+
57
+ function formatBytes(bytes) {
58
+ const n = Number(bytes);
59
+ if (!Number.isFinite(n) || n <= 0) return '0 B';
60
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
61
+ let v = n;
62
+ let i = 0;
63
+ while (v >= 1024 && i < units.length - 1) {
64
+ v /= 1024;
65
+ i += 1;
66
+ }
67
+ const fixed = i === 0 ? String(Math.trunc(v)) : v.toFixed(v >= 10 ? 1 : 2);
68
+ return `${fixed} ${units[i]}`;
69
+ }
70
+
71
+ module.exports = {
72
+ createProgress,
73
+ renderBar,
74
+ formatNumber,
75
+ formatBytes
76
+ };
77
+
@@ -0,0 +1,20 @@
1
+ const readline = require('node:readline');
2
+
3
+ async function prompt(label) {
4
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
5
+ const value = await new Promise((resolve) => rl.question(label, resolve));
6
+ rl.close();
7
+ return String(value || '').trim();
8
+ }
9
+
10
+ async function promptHidden(label) {
11
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
12
+ const value = await new Promise((resolve) => {
13
+ rl._writeToOutput = function _writeToOutput() {};
14
+ rl.question(label, (answer) => resolve(answer));
15
+ });
16
+ rl.close();
17
+ return String(value || '').trim();
18
+ }
19
+
20
+ module.exports = { prompt, promptHidden };
@@ -0,0 +1,263 @@
1
+ const fs = require('node:fs/promises');
2
+ const fssync = require('node:fs');
3
+ const path = require('node:path');
4
+ const readline = require('node:readline');
5
+ const crypto = require('node:crypto');
6
+
7
+ const { ensureDir } = require('./fs');
8
+
9
+ async function listRolloutFiles(sessionsDir) {
10
+ const out = [];
11
+ const years = await safeReadDir(sessionsDir);
12
+ for (const y of years) {
13
+ if (!/^[0-9]{4}$/.test(y.name) || !y.isDirectory()) continue;
14
+ const yearDir = path.join(sessionsDir, y.name);
15
+ const months = await safeReadDir(yearDir);
16
+ for (const m of months) {
17
+ if (!/^[0-9]{2}$/.test(m.name) || !m.isDirectory()) continue;
18
+ const monthDir = path.join(yearDir, m.name);
19
+ const days = await safeReadDir(monthDir);
20
+ for (const d of days) {
21
+ if (!/^[0-9]{2}$/.test(d.name) || !d.isDirectory()) continue;
22
+ const dayDir = path.join(monthDir, d.name);
23
+ const files = await safeReadDir(dayDir);
24
+ for (const f of files) {
25
+ if (!f.isFile()) continue;
26
+ if (!f.name.startsWith('rollout-') || !f.name.endsWith('.jsonl')) continue;
27
+ out.push(path.join(dayDir, f.name));
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ out.sort((a, b) => a.localeCompare(b));
34
+ return out;
35
+ }
36
+
37
+ async function parseRolloutIncremental({ rolloutFiles, cursors, queuePath, onProgress }) {
38
+ await ensureDir(path.dirname(queuePath));
39
+ let filesProcessed = 0;
40
+ let eventsQueued = 0;
41
+
42
+ const cb = typeof onProgress === 'function' ? onProgress : null;
43
+ const totalFiles = Array.isArray(rolloutFiles) ? rolloutFiles.length : 0;
44
+
45
+ for (let idx = 0; idx < rolloutFiles.length; idx++) {
46
+ const filePath = rolloutFiles[idx];
47
+ const st = await fs.stat(filePath).catch(() => null);
48
+ if (!st || !st.isFile()) continue;
49
+
50
+ const key = filePath;
51
+ const prev = cursors.files[key] || null;
52
+ const inode = st.ino || 0;
53
+ const startOffset = prev && prev.inode === inode ? prev.offset || 0 : 0;
54
+ const lastTotal = prev && prev.inode === inode ? prev.lastTotal || null : null;
55
+ const lastModel = prev && prev.inode === inode ? prev.lastModel || null : null;
56
+
57
+ const result = await parseRolloutFile({
58
+ filePath,
59
+ startOffset,
60
+ lastTotal,
61
+ lastModel,
62
+ queuePath
63
+ });
64
+
65
+ cursors.files[key] = {
66
+ inode,
67
+ offset: result.endOffset,
68
+ lastTotal: result.lastTotal,
69
+ lastModel: result.lastModel,
70
+ updatedAt: new Date().toISOString()
71
+ };
72
+
73
+ filesProcessed += 1;
74
+ eventsQueued += result.eventsQueued;
75
+
76
+ if (cb) {
77
+ cb({
78
+ index: idx + 1,
79
+ total: totalFiles,
80
+ filePath,
81
+ filesProcessed,
82
+ eventsQueued
83
+ });
84
+ }
85
+ }
86
+
87
+ return { filesProcessed, eventsQueued };
88
+ }
89
+
90
+ async function parseRolloutFile({ filePath, startOffset, lastTotal, lastModel, queuePath }) {
91
+ const st = await fs.stat(filePath);
92
+ const endOffset = st.size;
93
+ if (startOffset >= endOffset) {
94
+ return { endOffset, lastTotal, lastModel, eventsQueued: 0 };
95
+ }
96
+
97
+ const stream = fssync.createReadStream(filePath, { encoding: 'utf8', start: startOffset });
98
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
99
+
100
+ const toAppend = [];
101
+ let model = typeof lastModel === 'string' ? lastModel : null;
102
+ let totals = lastTotal && typeof lastTotal === 'object' ? lastTotal : null;
103
+ let eventsQueued = 0;
104
+
105
+ for await (const line of rl) {
106
+ if (!line) continue;
107
+ const maybeTokenCount = line.includes('"token_count"');
108
+ const maybeTurnContext = !maybeTokenCount && line.includes('"turn_context"') && line.includes('"model"');
109
+ if (!maybeTokenCount && !maybeTurnContext) continue;
110
+
111
+ let obj;
112
+ try {
113
+ obj = JSON.parse(line);
114
+ } catch (_e) {
115
+ continue;
116
+ }
117
+
118
+ if (obj?.type === 'turn_context' && obj?.payload && typeof obj.payload.model === 'string') {
119
+ model = obj.payload.model;
120
+ continue;
121
+ }
122
+
123
+ const payload = obj?.payload;
124
+ if (!payload || payload.type !== 'token_count') continue;
125
+
126
+ const info = payload.info;
127
+ if (!info || typeof info !== 'object') continue;
128
+
129
+ const tokenTimestamp = typeof obj.timestamp === 'string' ? obj.timestamp : null;
130
+ if (!tokenTimestamp) continue;
131
+
132
+ const lastUsage = info.last_token_usage;
133
+ const totalUsage = info.total_token_usage;
134
+
135
+ const delta = pickDelta(lastUsage, totalUsage, totals);
136
+ if (!delta) continue;
137
+
138
+ if (totalUsage && typeof totalUsage === 'object') {
139
+ totals = totalUsage;
140
+ }
141
+
142
+ const event = {
143
+ event_id: sha256Hex(line),
144
+ token_timestamp: tokenTimestamp,
145
+ model: model || null,
146
+ input_tokens: delta.input_tokens || 0,
147
+ cached_input_tokens: delta.cached_input_tokens || 0,
148
+ output_tokens: delta.output_tokens || 0,
149
+ reasoning_output_tokens: delta.reasoning_output_tokens || 0,
150
+ total_tokens: delta.total_tokens || 0
151
+ };
152
+
153
+ toAppend.push(JSON.stringify(event));
154
+ eventsQueued += 1;
155
+ }
156
+
157
+ if (toAppend.length > 0) {
158
+ await fs.appendFile(queuePath, toAppend.join('\n') + '\n', 'utf8');
159
+ }
160
+
161
+ return { endOffset, lastTotal: totals, lastModel: model, eventsQueued };
162
+ }
163
+
164
+ function pickDelta(lastUsage, totalUsage, prevTotals) {
165
+ const hasLast = isNonEmptyObject(lastUsage);
166
+ const hasTotal = isNonEmptyObject(totalUsage);
167
+ const hasPrevTotals = isNonEmptyObject(prevTotals);
168
+
169
+ // Codex rollout logs sometimes emit duplicate token_count records where total_token_usage does not
170
+ // change between adjacent entries. Counting last_token_usage in those cases will double-count.
171
+ if (hasTotal && hasPrevTotals && sameUsage(totalUsage, prevTotals)) {
172
+ return null;
173
+ }
174
+
175
+ if (!hasLast && hasTotal && hasPrevTotals && totalsReset(totalUsage, prevTotals)) {
176
+ const normalized = normalizeUsage(totalUsage);
177
+ return isAllZeroUsage(normalized) ? null : normalized;
178
+ }
179
+
180
+ if (hasLast) {
181
+ return normalizeUsage(lastUsage);
182
+ }
183
+
184
+ if (hasTotal && hasPrevTotals) {
185
+ const delta = {};
186
+ for (const k of ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens']) {
187
+ const a = Number(totalUsage[k]);
188
+ const b = Number(prevTotals[k]);
189
+ if (Number.isFinite(a) && Number.isFinite(b)) delta[k] = Math.max(0, a - b);
190
+ }
191
+ const normalized = normalizeUsage(delta);
192
+ return isAllZeroUsage(normalized) ? null : normalized;
193
+ }
194
+
195
+ if (hasTotal) {
196
+ const normalized = normalizeUsage(totalUsage);
197
+ return isAllZeroUsage(normalized) ? null : normalized;
198
+ }
199
+
200
+ return null;
201
+ }
202
+
203
+ function normalizeUsage(u) {
204
+ const out = {};
205
+ for (const k of ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens']) {
206
+ const n = Number(u[k] || 0);
207
+ out[k] = Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
208
+ }
209
+ return out;
210
+ }
211
+
212
+ function sha256Hex(s) {
213
+ return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
214
+ }
215
+
216
+ function isNonEmptyObject(v) {
217
+ return Boolean(v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length > 0);
218
+ }
219
+
220
+ function isAllZeroUsage(u) {
221
+ if (!u || typeof u !== 'object') return true;
222
+ for (const k of ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens']) {
223
+ if (Number(u[k] || 0) !== 0) return false;
224
+ }
225
+ return true;
226
+ }
227
+
228
+ function sameUsage(a, b) {
229
+ for (const k of ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens']) {
230
+ if (toNonNegativeInt(a?.[k]) !== toNonNegativeInt(b?.[k])) return false;
231
+ }
232
+ return true;
233
+ }
234
+
235
+ function totalsReset(curr, prev) {
236
+ const currTotal = curr?.total_tokens;
237
+ const prevTotal = prev?.total_tokens;
238
+ if (!isFiniteNumber(currTotal) || !isFiniteNumber(prevTotal)) return false;
239
+ return currTotal < prevTotal;
240
+ }
241
+
242
+ function isFiniteNumber(v) {
243
+ return typeof v === 'number' && Number.isFinite(v);
244
+ }
245
+
246
+ function toNonNegativeInt(v) {
247
+ const n = Number(v);
248
+ if (!Number.isFinite(n) || n < 0) return 0;
249
+ return Math.floor(n);
250
+ }
251
+
252
+ async function safeReadDir(dir) {
253
+ try {
254
+ return await fs.readdir(dir, { withFileTypes: true });
255
+ } catch (_e) {
256
+ return [];
257
+ }
258
+ }
259
+
260
+ module.exports = {
261
+ listRolloutFiles,
262
+ parseRolloutIncremental
263
+ };
@@ -0,0 +1,129 @@
1
+ const DEFAULTS = {
2
+ intervalMs: 10 * 60_000,
3
+ jitterMsMax: 120_000,
4
+ backlogBytes: 1_000_000,
5
+ batchSize: 300,
6
+ maxBatchesSmall: 2,
7
+ maxBatchesLarge: 4,
8
+ backoffInitialMs: 60_000,
9
+ backoffMaxMs: 30 * 60_000
10
+ };
11
+
12
+ function normalizeState(raw) {
13
+ const s = raw && typeof raw === 'object' ? raw : {};
14
+ return {
15
+ version: 1,
16
+ lastSuccessMs: toSafeInt(s.lastSuccessMs),
17
+ nextAllowedAtMs: toSafeInt(s.nextAllowedAtMs),
18
+ backoffUntilMs: toSafeInt(s.backoffUntilMs),
19
+ backoffStep: toSafeInt(s.backoffStep),
20
+ lastErrorAt: typeof s.lastErrorAt === 'string' ? s.lastErrorAt : null,
21
+ lastError: typeof s.lastError === 'string' ? s.lastError : null,
22
+ updatedAt: typeof s.updatedAt === 'string' ? s.updatedAt : null
23
+ };
24
+ }
25
+
26
+ function decideAutoUpload({ nowMs, pendingBytes, state, config }) {
27
+ const cfg = { ...DEFAULTS, ...(config || {}) };
28
+ const s = normalizeState(state);
29
+ const pending = Number(pendingBytes || 0);
30
+
31
+ if (pending <= 0) {
32
+ return { allowed: false, reason: 'no-pending', maxBatches: 0, batchSize: cfg.batchSize, blockedUntilMs: 0 };
33
+ }
34
+
35
+ const blockedUntilMs = Math.max(s.nextAllowedAtMs || 0, s.backoffUntilMs || 0);
36
+ if (blockedUntilMs > 0 && nowMs < blockedUntilMs) {
37
+ return { allowed: false, reason: 'throttled', maxBatches: 0, batchSize: cfg.batchSize, blockedUntilMs };
38
+ }
39
+
40
+ const maxBatches = pending >= cfg.backlogBytes ? cfg.maxBatchesLarge : cfg.maxBatchesSmall;
41
+ return { allowed: true, reason: 'allowed', maxBatches, batchSize: cfg.batchSize, blockedUntilMs: 0 };
42
+ }
43
+
44
+ function recordUploadSuccess({ nowMs, state, config, randInt }) {
45
+ const cfg = { ...DEFAULTS, ...(config || {}) };
46
+ const s = normalizeState(state);
47
+ const jitter = typeof randInt === 'function' ? randInt(0, cfg.jitterMsMax) : randomInt(0, cfg.jitterMsMax);
48
+ const nextAllowedAtMs = nowMs + cfg.intervalMs + jitter;
49
+
50
+ return {
51
+ ...s,
52
+ lastSuccessMs: nowMs,
53
+ nextAllowedAtMs,
54
+ backoffUntilMs: 0,
55
+ backoffStep: 0,
56
+ lastErrorAt: null,
57
+ lastError: null,
58
+ updatedAt: new Date(nowMs).toISOString()
59
+ };
60
+ }
61
+
62
+ function recordUploadFailure({ nowMs, state, error, config }) {
63
+ const cfg = { ...DEFAULTS, ...(config || {}) };
64
+ const s = normalizeState(state);
65
+
66
+ const retryAfterMs = toSafeInt(error?.retryAfterMs);
67
+ const status = toSafeInt(error?.status);
68
+
69
+ let backoffMs = 0;
70
+ if (retryAfterMs > 0) {
71
+ backoffMs = Math.min(cfg.backoffMaxMs, Math.max(cfg.backoffInitialMs, retryAfterMs));
72
+ } else {
73
+ const step = Math.min(10, Math.max(0, s.backoffStep || 0));
74
+ backoffMs = Math.min(cfg.backoffMaxMs, cfg.backoffInitialMs * Math.pow(2, step));
75
+ }
76
+
77
+ const backoffUntilMs = nowMs + backoffMs;
78
+ const nextAllowedAtMs = Math.max(s.nextAllowedAtMs || 0, backoffUntilMs);
79
+
80
+ return {
81
+ ...s,
82
+ nextAllowedAtMs,
83
+ backoffUntilMs,
84
+ backoffStep: Math.min(20, (s.backoffStep || 0) + 1),
85
+ lastErrorAt: new Date(nowMs).toISOString(),
86
+ lastError: truncate(String(error?.message || 'upload failed'), 200),
87
+ updatedAt: new Date(nowMs).toISOString()
88
+ };
89
+ }
90
+
91
+ function parseRetryAfterMs(headerValue, nowMs = Date.now()) {
92
+ if (typeof headerValue !== 'string' || headerValue.trim().length === 0) return null;
93
+ const v = headerValue.trim();
94
+ const seconds = Number(v);
95
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.floor(seconds * 1000);
96
+ const d = new Date(v);
97
+ if (Number.isNaN(d.getTime())) return null;
98
+ const delta = d.getTime() - nowMs;
99
+ return delta > 0 ? delta : 0;
100
+ }
101
+
102
+ function randomInt(min, maxInclusive) {
103
+ const lo = Math.floor(min);
104
+ const hi = Math.floor(maxInclusive);
105
+ if (hi <= lo) return lo;
106
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
107
+ }
108
+
109
+ function toSafeInt(v) {
110
+ const n = Number(v);
111
+ if (!Number.isFinite(n)) return 0;
112
+ if (n <= 0) return 0;
113
+ return Math.floor(n);
114
+ }
115
+
116
+ function truncate(s, maxLen) {
117
+ if (typeof s !== 'string') return '';
118
+ if (s.length <= maxLen) return s;
119
+ return s.slice(0, maxLen - 1) + '…';
120
+ }
121
+
122
+ module.exports = {
123
+ DEFAULTS,
124
+ normalizeState,
125
+ decideAutoUpload,
126
+ recordUploadSuccess,
127
+ recordUploadFailure,
128
+ parseRetryAfterMs
129
+ };