dsh-plugin-usage 0.1.4 → 0.2.0

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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Aggregation/query logic — pure over the runtime state.
3
+ *
4
+ * P1-7 (unpriced detection) and P1-8 (provider/model composite keys in the
5
+ * by-model table and model candidate list) are owned here.
6
+ */
7
+ import { dayKey, DAY_MS, bucketKeyFor, stepMs, alignFloor, stepDayKey, MAX_BUCKETS, } from './time.js';
8
+ function emptyBucket(key) {
9
+ return {
10
+ key,
11
+ calls: 0,
12
+ inputTokens: 0,
13
+ outputTokens: 0,
14
+ cacheReadTokens: 0,
15
+ cacheWriteTokens: 0,
16
+ totalTokens: 0,
17
+ cost: 0,
18
+ };
19
+ }
20
+ function addToBucket(b, r, cost) {
21
+ b.calls += 1;
22
+ b.inputTokens += r.inputTokens;
23
+ b.outputTokens += r.outputTokens;
24
+ b.cacheReadTokens += r.cacheReadTokens;
25
+ b.cacheWriteTokens += r.cacheWriteTokens;
26
+ b.totalTokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens;
27
+ b.cost += cost;
28
+ return b;
29
+ }
30
+ /** Composite model key: `provider/model`, or the bare model when provider is empty. */
31
+ function compositeModelKey(r) {
32
+ return r.provider ? r.provider + '/' + r.model : r.model;
33
+ }
34
+ export function query(state, args) {
35
+ const from = typeof args.from === 'number' ? args.from : -Infinity;
36
+ const to = typeof args.to === 'number' ? args.to : Infinity;
37
+ const granularity = args.granularity === 'minute' || args.granularity === 'hour' ? args.granularity : 'day';
38
+ const stepDays = granularity === 'day' && Number.isInteger(args.stepDays) && args.stepDays > 0 ? args.stepDays : 1;
39
+ // Zero-filled step series require a finite range.
40
+ const fill = Boolean(args.fill) && Number.isFinite(from) && Number.isFinite(to);
41
+ const modelSet = Array.isArray(args.models) && args.models.length > 0
42
+ ? new Set(args.models)
43
+ : null;
44
+ /** Match either a bare model id or a `provider/model` composite key. */
45
+ const modelMatches = (r) => {
46
+ if (!modelSet)
47
+ return true;
48
+ if (modelSet.has(r.model))
49
+ return true;
50
+ return Boolean(r.provider) && modelSet.has(r.provider + '/' + r.model);
51
+ };
52
+ const dailyMap = new Map();
53
+ const modelMap = new Map();
54
+ const total = emptyBucket('total');
55
+ const seenModels = new Map(); // composite key -> {provider, model} (first composite wins)
56
+ let unpricedCalls = 0;
57
+ // Range-anchored day-step grid: blocks start at the range's first day.
58
+ const dayAnchor = Number.isFinite(from) ? alignFloor(from, 'day') : null;
59
+ const useStepGrid = granularity === 'day' && stepDays > 1 && dayAnchor !== null;
60
+ for (const r of state.index ? state.index.recordsInRange(from, to) : state.records) {
61
+ if (r.time < from || r.time > to)
62
+ continue;
63
+ if (!modelMatches(r))
64
+ continue;
65
+ const cost = state.prices.costOf(r);
66
+ if (r.totalTokens > 0 && !state.prices.hasPrice(r))
67
+ unpricedCalls += 1;
68
+ const modelKey = compositeModelKey(r);
69
+ if (!seenModels.has(modelKey))
70
+ seenModels.set(modelKey, { provider: r.provider, model: r.model });
71
+ const dk = useStepGrid ? stepDayKey(r, dayAnchor, stepDays) : bucketKeyFor(r.time, granularity, stepDays);
72
+ let db = dailyMap.get(dk);
73
+ if (db === undefined) {
74
+ db = emptyBucket(dk);
75
+ dailyMap.set(dk, db);
76
+ }
77
+ addToBucket(db, r, cost);
78
+ let mb = modelMap.get(modelKey);
79
+ if (mb === undefined) {
80
+ mb = emptyBucket(modelKey);
81
+ modelMap.set(modelKey, mb);
82
+ }
83
+ addToBucket(mb, r, cost);
84
+ addToBucket(total, r, cost);
85
+ }
86
+ let daily;
87
+ if (fill) {
88
+ let start;
89
+ let step;
90
+ let end;
91
+ if (granularity === 'day') {
92
+ step = stepDays * DAY_MS;
93
+ start = dayAnchor !== null ? dayAnchor : alignFloor(from, 'day');
94
+ end = alignFloor(to, 'day') + step;
95
+ }
96
+ else {
97
+ step = stepMs(granularity);
98
+ start = alignFloor(from, granularity);
99
+ end = alignFloor(to, granularity) + step;
100
+ }
101
+ const count = Math.round((end - start) / step);
102
+ if (count <= MAX_BUCKETS) {
103
+ daily = [];
104
+ for (let i = 0; i < count; i++) {
105
+ const t = start + i * step;
106
+ const k = useStepGrid ? dayKey(t) : bucketKeyFor(t, granularity, stepDays);
107
+ daily.push(dailyMap.get(k) || emptyBucket(k));
108
+ }
109
+ }
110
+ else {
111
+ // Degenerate: too many steps; fall back to present-only buckets.
112
+ daily = [...dailyMap.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
113
+ }
114
+ }
115
+ else {
116
+ daily = [...dailyMap.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
117
+ }
118
+ const byModel = [...modelMap.values()].sort((a, b) => b.totalTokens - a.totalTokens);
119
+ const models = [...seenModels.values()];
120
+ // GitHub-style yearly heatmap: every calendar day of the current year up to
121
+ // today, zero-filled.
122
+ let heatmap = null;
123
+ if (args.heatmap) {
124
+ const now = Date.now();
125
+ const year = new Date(now).getFullYear();
126
+ const hmMap = new Map();
127
+ const yearStart = new Date(year, 0, 1).getTime();
128
+ for (const r of state.index ? state.index.recordsInRange(yearStart, now) : state.records) {
129
+ if (r.time < yearStart || r.time > now)
130
+ continue;
131
+ if (!modelMatches(r))
132
+ continue;
133
+ const dk = dayKey(r.time);
134
+ let b = hmMap.get(dk);
135
+ if (b === undefined) {
136
+ b = emptyBucket(dk);
137
+ hmMap.set(dk, b);
138
+ }
139
+ addToBucket(b, r, state.prices.costOf(r));
140
+ }
141
+ heatmap = [];
142
+ const todayStart = alignFloor(now, 'day');
143
+ for (let d = new Date(year, 0, 1); d.getTime() <= todayStart; d.setDate(d.getDate() + 1)) {
144
+ const k = dayKey(d.getTime());
145
+ heatmap.push(hmMap.get(k) || emptyBucket(k));
146
+ }
147
+ }
148
+ return {
149
+ daily,
150
+ byModel,
151
+ total,
152
+ models,
153
+ unpricedCalls,
154
+ ...heatmap === null ? {} : { heatmap },
155
+ backfill: { status: state.backfill.status, total: state.backfill.total, done: state.backfill.done },
156
+ };
157
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * RPC registration.
3
+ *
4
+ * Dynamic-plugin runs expose `harness` directly; static (bundle) installs
5
+ * expose the generic Connection RPC bridge (the browser half calls
6
+ * connection.rpc.call('/usage', endpoint, args)). Support both from one file.
7
+ *
8
+ * P0-3: the official Typert gateway path (@deepseek-ai/dsh-api-gateway host
9
+ * service, ctx key `typertGateway`) is wired here when the detected gateway
10
+ * exposes a registration surface, keeping these paths as fallbacks.
11
+ */
12
+ export function registerRPC(ctx, handlers) {
13
+ const dynamicHarness = (typeof harness !== 'undefined' && harness && typeof harness.handle === 'function') ? harness : null;
14
+ if (dynamicHarness !== null) {
15
+ for (const [endpoint, fn] of handlers) {
16
+ dynamicHarness.handle('usage/' + endpoint, fn);
17
+ }
18
+ return;
19
+ }
20
+ // P0-3 official Typert gateway path.
21
+ //
22
+ // Target API: @deepseek-ai/dsh-api-gateway host service, exposed on the host
23
+ // Read the gateway only via `ctx.get('typertGateway')` — cordis ctx is a
24
+ // property). The installed v0.1.0-rc.7 `TypertGateway` interface exposes
25
+ // only invoke() (see the package's lib/types/types.d.ts); the documented
26
+ // contribution path (@Remote/@RemoteScope + TypertRemoteService or
27
+ // bindTypertRemote from @deepseek-ai/dsh-typert-protocol) needs a runtime
28
+ // dependency this plugin must not add, so we only claim endpoints through a
29
+ // registration surface that already exists on the detected object:
30
+ // `typert.register(channel, endpoints)`, a forward-compatible contribution
31
+ // hook for host compositions that expose the gateway with registration.
32
+ //
33
+ // Precondition for this path to activate: the resolved typert gateway is
34
+ // non-null AND `typert.register` is a function. Endpoint functions receive
35
+ // (args) and return the raw value; the gateway/connection side owns the
36
+ // RPC envelope. Otherwise we fall back silently to the connection bridge
37
+ // below. All guards are typeof-based: nothing is imported, zero deps.
38
+ // cordis ctx is a strict proxy: direct property access to an un-injected
39
+ // service throws — always use ctx.get (returns undefined when absent).
40
+ const typert = typeof ctx.get === 'function' ? ctx.get('typertGateway') : undefined;
41
+ if (typert && typeof typert.register === 'function') {
42
+ const endpoints = {};
43
+ for (const [endpoint, fn] of handlers) {
44
+ endpoints[endpoint] = fn;
45
+ }
46
+ typert.register('usage', endpoints);
47
+ return;
48
+ }
49
+ // Static (bundle) installs: the `connection` service is provided
50
+ // asynchronously by the client-connection plugin, so wait for it before
51
+ // registering the /usage channel (the official api-gateway pattern).
52
+ ctx.inject(['connection'], (connectionCtx) => {
53
+ const connection = connectionCtx.connection;
54
+ if (connection === undefined || connection.rpc === undefined)
55
+ return;
56
+ connection.rpc.handle('/usage', async (endpoint, args) => {
57
+ const fn = handlers.get(endpoint);
58
+ if (fn === undefined) {
59
+ return { ok: false, error: { code: 'bad-request', message: `unknown endpoint: ${endpoint}`, details: { issues: [] } } };
60
+ }
61
+ try {
62
+ return { ok: true, value: await fn(args) };
63
+ }
64
+ catch (error) {
65
+ return { ok: false, error: { code: 'internal', message: String((error && error.message) || error), details: {} } };
66
+ }
67
+ }, { authority: 'loopback' });
68
+ });
69
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Live/persisted session scanning + startup backfill.
3
+ *
4
+ * P1-6: optimization lives here — bounded-concurrency backfill, per-session
5
+ * failure backoff, progress notifications, and an optional onProgress/
6
+ * onDone callback so the host can push a usage/updated event to clients.
7
+ */
8
+ import { foldNew } from './fold.js';
9
+ /** How many persisted headers are read concurrently per batch. */
10
+ const BATCH_SIZE = 8;
11
+ /**
12
+ * Per-session backoff bookkeeping, kept at module (closure) scope so it spans
13
+ * multiple passes. Value is { failures, skipLeft }: consecutive failure count
14
+ * plus the number of upcoming passes the session is skipped for
15
+ * (2 ** failures, capped at 5; decays by one each pass).
16
+ */
17
+ const backoffBySession = new Map();
18
+ /** True while a scanPersisted pass is in flight; overlapping passes are dropped. */
19
+ let persistedInFlight = false;
20
+ /** Scan live in-memory sessions (cheap, every tick). */
21
+ export function scanLive(ctx, state) {
22
+ const sessions = ctx.get('sessions');
23
+ if (sessions === undefined)
24
+ return;
25
+ for (const s of sessions.list()) {
26
+ if (!s || !s.id)
27
+ continue;
28
+ const wm = state.watermarks.get(s.id) ?? -1;
29
+ const next = foldNew(state, s.id, s.events || [], wm);
30
+ if (next > wm)
31
+ state.watermarks.set(s.id, next);
32
+ }
33
+ }
34
+ /**
35
+ * Incremental scan of persisted sessions (slow cadence). Headers are read in
36
+ * small concurrent batches; a failing session is backed off for an
37
+ * exponentially growing number of passes instead of aborting the pass.
38
+ */
39
+ export async function scanPersisted(ctx, state, options) {
40
+ const sp = ctx.get('sessionPersistence');
41
+ if (sp === undefined)
42
+ return;
43
+ if (persistedInFlight)
44
+ return; // previous pass still running: skip this tick
45
+ persistedInFlight = true;
46
+ try {
47
+ const headers = await sp.list();
48
+ if (!Array.isArray(headers))
49
+ return;
50
+ state.backfill.total = headers.length;
51
+ for (let i = 0; i < headers.length; i += BATCH_SIZE) {
52
+ const batch = headers.slice(i, i + BATCH_SIZE);
53
+ await Promise.all(batch.map((h) => processHeader(sp, state, h, options)));
54
+ }
55
+ }
56
+ finally {
57
+ persistedInFlight = false;
58
+ if (options && typeof options.onDone === 'function') {
59
+ options.onDone({
60
+ status: state.backfill.status,
61
+ total: state.backfill.total,
62
+ done: state.backfill.done,
63
+ });
64
+ }
65
+ }
66
+ }
67
+ /** Read + fold one persisted session header; bounded by the batch caller. */
68
+ async function processHeader(sp, state, h, options) {
69
+ if (!h || !h.id)
70
+ return;
71
+ const id = h.id;
72
+ const backoff = backoffBySession.get(id);
73
+ if (backoff && backoff.skipLeft > 0) {
74
+ backoff.skipLeft -= 1; // decay the skip count one pass at a time
75
+ return;
76
+ }
77
+ const wm = state.watermarks.get(id) ?? -1;
78
+ try {
79
+ const page = await sp.readFrom(id, wm + 1);
80
+ const events = (page && page.events) || [];
81
+ const next = foldNew(state, id, events, wm);
82
+ if (next > wm)
83
+ state.watermarks.set(id, next);
84
+ backoffBySession.delete(id); // success resets the consecutive-failure chain
85
+ }
86
+ catch (error) {
87
+ // A session may be gone or mid-rewrite: back off before trying again.
88
+ const b = backoffBySession.get(id) || { failures: 0, skipLeft: 0 };
89
+ b.failures += 1;
90
+ b.skipLeft = Math.min(5, 2 ** b.failures);
91
+ backoffBySession.set(id, b);
92
+ return;
93
+ }
94
+ finally {
95
+ if (state.backfill.status === 'running') {
96
+ state.backfill.done += 1;
97
+ if (options && typeof options.onProgress === 'function') {
98
+ options.onProgress({
99
+ status: state.backfill.status,
100
+ total: state.backfill.total,
101
+ done: state.backfill.done,
102
+ });
103
+ }
104
+ }
105
+ }
106
+ }
107
+ /** Full history scan, once at startup. */
108
+ export async function runBackfill(ctx, state, options) {
109
+ const sp = ctx.get('sessionPersistence');
110
+ if (sp === undefined) {
111
+ state.backfill.status = 'done';
112
+ fireBackfillDone(options, state);
113
+ return;
114
+ }
115
+ state.backfill.status = 'running';
116
+ state.backfill.done = 0;
117
+ // Delegate progress while the pass runs; onDone is fired right here exactly
118
+ // once so the pass callback does not double-report backfill completion.
119
+ const scanOptions = options && typeof options.onProgress === 'function'
120
+ ? { onProgress: options.onProgress }
121
+ : undefined;
122
+ try {
123
+ await scanPersisted(ctx, state, scanOptions);
124
+ state.backfill.status = 'done';
125
+ fireBackfillDone(options, state);
126
+ }
127
+ catch (error) {
128
+ state.backfill.status = 'done';
129
+ fireBackfillDone(options, state);
130
+ }
131
+ }
132
+ function fireBackfillDone(options, state) {
133
+ if (options && typeof options.onDone === 'function') {
134
+ options.onDone({
135
+ status: state.backfill.status,
136
+ total: state.backfill.total,
137
+ done: state.backfill.done,
138
+ });
139
+ }
140
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Price table + cost computation.
3
+ *
4
+ * P0-1: createPriceStore() keeps the process-memory table. The optimization
5
+ * adds a durable settings-namespace backend via `ctx.settings` (official
6
+ * dsh-settings) with the in-memory store as fallback — implemented by the
7
+ * settings workstream inside this file only.
8
+ */
9
+ export const DEFAULT_CURRENCY = '¥';
10
+ export const DEFAULT_PRICES = {
11
+ 'deepseek-chat': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
12
+ 'deepseek-v3': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
13
+ 'deepseek-v3.1': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
14
+ 'deepseek-v4': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
15
+ 'deepseek-v4-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
16
+ 'deepseek-reasoner': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
17
+ 'deepseek-r1': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
18
+ };
19
+ export function createPriceStore(defaultPrices, defaultCurrency) {
20
+ const prices = Object.assign({}, defaultPrices);
21
+ let currency = defaultCurrency || DEFAULT_CURRENCY;
22
+ const store = {
23
+ prices,
24
+ get currency() { return currency; },
25
+ /** Write-through slot wired by initSettings(); null without a settings service. */
26
+ persist: null,
27
+ /** Merge incoming per-model prices (each field coerced to a number). */
28
+ setPrices(incoming) {
29
+ if (!incoming || typeof incoming !== 'object')
30
+ return;
31
+ for (const [model, p] of Object.entries(incoming)) {
32
+ if (p && typeof p === 'object') {
33
+ prices[model] = {
34
+ inputPerM: Number(p.inputPerM) || 0,
35
+ outputPerM: Number(p.outputPerM) || 0,
36
+ cacheReadPerM: Number(p.cacheReadPerM) || 0,
37
+ cacheWritePerM: Number(p.cacheWritePerM) || 0,
38
+ };
39
+ }
40
+ }
41
+ if (typeof store.persist === 'function')
42
+ store.persist({ prices: Object.assign({}, prices) });
43
+ },
44
+ setCurrency(v) {
45
+ if (typeof v === 'string' && v.length > 0) {
46
+ currency = v;
47
+ if (typeof store.persist === 'function')
48
+ store.persist({ currency });
49
+ }
50
+ },
51
+ /** Look up a price by `provider/model` composite first, then bare model. */
52
+ priceOf(r) {
53
+ const composite = r.provider ? r.provider + '/' + r.model : '';
54
+ return prices[composite] || prices[r.model];
55
+ },
56
+ /** Whether an explicit price entry exists (composite first, then bare model). */
57
+ hasPrice(r) {
58
+ const composite = r.provider ? r.provider + '/' + r.model : '';
59
+ return composite in prices || r.model in prices;
60
+ },
61
+ costOf(r) {
62
+ const p = store.priceOf(r);
63
+ if (!p)
64
+ return 0;
65
+ return (r.inputTokens * p.inputPerM +
66
+ r.outputTokens * p.outputPerM +
67
+ r.cacheReadTokens * (p.cacheReadPerM || 0) +
68
+ r.cacheWriteTokens * (p.cacheWritePerM || 0)) / 1e6;
69
+ },
70
+ snapshot() {
71
+ return { prices: Object.assign({}, prices), currency };
72
+ },
73
+ };
74
+ return store;
75
+ }
76
+ const USAGE_PRICES_NS = 'usage.prices';
77
+ /** JSON-schema-shaped descriptor for the usage.prices settings section. */
78
+ const PRICE_SECTION_DESCRIPTOR = {
79
+ type: 'object',
80
+ properties: {
81
+ prices: {
82
+ type: 'object',
83
+ description: 'Per-model prices (cost per 1M tokens, in `currency`).',
84
+ additionalProperties: {
85
+ type: 'object',
86
+ properties: {
87
+ inputPerM: { type: 'number' },
88
+ outputPerM: { type: 'number' },
89
+ cacheReadPerM: { type: 'number' },
90
+ cacheWritePerM: { type: 'number' },
91
+ },
92
+ required: ['inputPerM', 'outputPerM', 'cacheReadPerM', 'cacheWritePerM'],
93
+ },
94
+ },
95
+ currency: { type: 'string' },
96
+ },
97
+ required: ['prices', 'currency'],
98
+ };
99
+ /** Apply a resolved settings section onto the in-memory price store. */
100
+ function applySection(store, section) {
101
+ if (!section || typeof section !== 'object')
102
+ return;
103
+ if (typeof store.setPrices === 'function' && section.prices && typeof section.prices === 'object' && !Array.isArray(section.prices)) {
104
+ store.setPrices(section.prices);
105
+ }
106
+ if (typeof store.setCurrency === 'function' && typeof section.currency === 'string')
107
+ store.setCurrency(section.currency);
108
+ }
109
+ /**
110
+ * P0-1: wire the price store to the official `ctx.settings` namespace
111
+ * (durable, shown in the official Plugins config tab) when available.
112
+ * Registers `usage.prices`, seeds the store from the resolved section, and
113
+ * writes every setPrices/setCurrency mutation through. Falls back to the
114
+ * in-memory store whenever the settings service is missing or unavailable.
115
+ */
116
+ export function initSettings(ctx, store) {
117
+ if (!ctx || typeof ctx.get !== 'function' || !store || typeof store !== 'object')
118
+ return;
119
+ let settings;
120
+ try {
121
+ settings = ctx.get('settings');
122
+ }
123
+ catch {
124
+ return;
125
+ }
126
+ if (!settings || typeof settings.register !== 'function')
127
+ return;
128
+ let scope;
129
+ try {
130
+ scope = settings.register(USAGE_PRICES_NS, PRICE_SECTION_DESCRIPTOR, { base: { prices: DEFAULT_PRICES, currency: DEFAULT_CURRENCY }, applies: 'live' });
131
+ }
132
+ catch {
133
+ return; // registration failed — keep the current in-memory behavior
134
+ }
135
+ store.persist = (patch) => {
136
+ try {
137
+ if (scope && typeof scope.update === 'function')
138
+ return scope.update(USAGE_PRICES_NS, patch);
139
+ if (settings && typeof settings.update === 'function')
140
+ return settings.update(USAGE_PRICES_NS, patch);
141
+ }
142
+ catch {
143
+ // Never throw when the settings scope is unavailable.
144
+ }
145
+ };
146
+ let section;
147
+ try {
148
+ if (scope && typeof scope.get === 'function')
149
+ section = scope.get();
150
+ else if (settings && typeof settings.get === 'function')
151
+ section = settings.get(USAGE_PRICES_NS);
152
+ }
153
+ catch {
154
+ section = undefined;
155
+ }
156
+ if (section && typeof section.then === 'function') {
157
+ section.then((resolved) => applySection(store, resolved)).catch(() => { });
158
+ }
159
+ else {
160
+ applySection(store, section);
161
+ }
162
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Mutable runtime state capsule for the usage host half.
3
+ *
4
+ * - records: folded usage records, one per assistant/message with usage.
5
+ * - watermarks: sessionId -> last folded event seq.
6
+ * - backfill: progress counters surfaced through query results.
7
+ * - prices: the price store from settings.js.
8
+ * - dedup: (sessionId, turn, step) -> index in records; owned by fold.js.
9
+ * - index: incremental aggregation index (aggregate.js), kept in sync by
10
+ * fold.js and consumed by query.js.
11
+ */
12
+ export function createState(priceStore) {
13
+ return {
14
+ records: [],
15
+ watermarks: new Map(),
16
+ backfill: { status: 'idle', total: 0, done: 0 },
17
+ prices: priceStore,
18
+ dedup: new Map(),
19
+ index: null, // wired by apply() after createUsageIndex(state)
20
+ };
21
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Time helpers for usage bucketing — pure, dependency-free.
3
+ * Granularity keys: 'day' | 'hour' | 'minute'.
4
+ *
5
+ * User-facing buckets (dayKey / alignFloor / bucketKeyFor non-day cases) use
6
+ * LOCAL calendar time. dayIndex is a local-midnight-based ordinal used to
7
+ * align step-day blocks. DST-consistent handling is owned by this module.
8
+ */
9
+ export const DAY_MS = 86400000;
10
+ export function dayKey(t) {
11
+ const d = new Date(t);
12
+ const pad = (n) => String(n).padStart(2, '0');
13
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
14
+ }
15
+ /** Ordinal day number (local-midnight based), for step alignment. */
16
+ export function dayIndex(t) {
17
+ return Math.floor(alignFloor(t, 'day') / DAY_MS);
18
+ }
19
+ /** Local-midnight epoch of the day whose dayIndex ordinal is `block`. */
20
+ function localMidnightForOrdinal(block) {
21
+ let epoch = alignFloor(block * DAY_MS, 'day');
22
+ let idx = Math.floor(epoch / DAY_MS);
23
+ while (idx < block) {
24
+ epoch = alignFloor(epoch + DAY_MS, 'day');
25
+ idx = Math.floor(epoch / DAY_MS);
26
+ }
27
+ while (idx > block) {
28
+ epoch = alignFloor(epoch - DAY_MS, 'day');
29
+ idx = Math.floor(epoch / DAY_MS);
30
+ }
31
+ return epoch;
32
+ }
33
+ /** Bucket key for one instant under a granularity (day buckets may span stepDays). */
34
+ export function bucketKeyFor(t, g, stepDays) {
35
+ const pad = (n) => String(n).padStart(2, '0');
36
+ if (g === 'day') {
37
+ // Single-day buckets are the local calendar day itself (DST-safe by construction).
38
+ if (stepDays === 1)
39
+ return dayKey(t);
40
+ const block = Math.floor(dayIndex(t) / stepDays) * stepDays;
41
+ return dayKey(localMidnightForOrdinal(block));
42
+ }
43
+ const d = new Date(t);
44
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
45
+ const hm = pad(d.getHours()) + ':' + (g === 'hour' ? '00' : pad(d.getMinutes()));
46
+ return date + ' ' + hm;
47
+ }
48
+ export function stepMs(g) {
49
+ return g === 'day' ? DAY_MS : g === 'hour' ? 3600000 : 60000;
50
+ }
51
+ /** Floor an instant to its granularity boundary. */
52
+ export function alignFloor(t, g) {
53
+ const d = new Date(t);
54
+ if (g === 'day') {
55
+ d.setHours(0, 0, 0, 0);
56
+ return d.getTime();
57
+ }
58
+ if (g === 'hour') {
59
+ d.setMinutes(0, 0, 0);
60
+ return d.getTime();
61
+ }
62
+ d.setSeconds(0, 0);
63
+ return d.getTime();
64
+ }
65
+ /** Floor an instant to its stepDays block start (local midnight of the block's anchor day). */
66
+ export function alignBlock(t, stepDays) {
67
+ if (stepDays === 1)
68
+ return alignFloor(t, 'day');
69
+ const block = Math.floor(dayIndex(t) / stepDays) * stepDays;
70
+ return localMidnightForOrdinal(block);
71
+ }
72
+ /**
73
+ * Bucket key under a day granularity with a range-anchored step grid:
74
+ * blocks start at the range's first day, so a custom range reads naturally
75
+ * (e.g. 90 days -> 18 x 5-day buckets starting on the range's first day).
76
+ */
77
+ export function stepDayKey(r, dayStart, stepDays) {
78
+ const k = Math.floor((alignFloor(r.time, 'day') - dayStart) / (stepDays * DAY_MS));
79
+ return dayKey(dayStart + k * stepDays * DAY_MS);
80
+ }
81
+ /** Cap on zero-filled output buckets; beyond it we emit present-only buckets. */
82
+ export const MAX_BUCKETS = 2000;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-usage",
3
3
  "description": "Token usage statistics for DeepSeek Harness — daily/custom-range views, model filters, call counts and cost, inspired by CC Switch statistics.",
4
- "version": "0.1.4",
4
+ "version": "0.2.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -39,8 +39,8 @@
39
39
  "client": {
40
40
  "inject": [
41
41
  "@deepseek-ai/dsh-client-runtime",
42
- "@deepseek-ai/dsh-client-ui-sidebar",
43
- "@deepseek-ai/dsh-client-ui-settings"
42
+ "@deepseek-ai/dsh-client-ui-settings",
43
+ "@deepseek-ai/dsh-client-locale"
44
44
  ],
45
45
  "platform": "web"
46
46
  }
@@ -57,7 +57,8 @@
57
57
  },
58
58
  "scripts": {
59
59
  "build": "tsc -p tsconfig.json && node scripts/build-client.js",
60
+ "test": "node --test tests/*.test.js",
60
61
  "watch": "tsc -p tsconfig.json --watch",
61
62
  "prepublishOnly": "npm run build"
62
63
  }
63
- }
64
+ }