@kidli1412/dsh-token-heatmap 0.1.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.
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/cordis.patch.yml +12 -0
- package/docs//347/203/255/345/212/233/345/233/276.jpg +0 -0
- package/lib/client.js +908 -0
- package/lib/config.js +54 -0
- package/lib/index.js +474 -0
- package/lib/usage.js +251 -0
- package/package.json +60 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-token-heatmap — config model.
|
|
3
|
+
*
|
|
4
|
+
* Pure validation/coercion for the plugin's user-facing settings:
|
|
5
|
+
* enabled — master switch for the hero-screen heatmap card (default true)
|
|
6
|
+
* colorScheme — cell palette name (default "green")
|
|
7
|
+
*
|
|
8
|
+
* The server half persists the raw JSON document under
|
|
9
|
+
* `<DSH_HOME>/storages/token-heatmap-config.json` and serves it over the
|
|
10
|
+
* loopback-only config endpoint; the settings card in
|
|
11
|
+
* 设置 → 插件 → 插件配置 edits it.
|
|
12
|
+
*
|
|
13
|
+
* Scheme membership is deliberately NOT enforced here. The browser bundle is
|
|
14
|
+
* served fresh at request time while the server half only reloads on restart,
|
|
15
|
+
* so a client that knows a new palette must be able to store it against a
|
|
16
|
+
* server still running older code; the client renders any unknown scheme with
|
|
17
|
+
* its green fallback. The server bounds only the SHAPE (a short non-blank
|
|
18
|
+
* string) so a foreign or hand-edited document still degrades to sane values.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-token-heatmap/config
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Default configuration (also what a corrupt or absent document resolves to). */
|
|
24
|
+
export const DEFAULT_CONFIG = Object.freeze({ enabled: true, colorScheme: "green" });
|
|
25
|
+
|
|
26
|
+
/** Canonical color schemes, in display order (informational; not enforced). */
|
|
27
|
+
export const COLOR_SCHEMES = Object.freeze(["green", "blue", "orange", "red", "purple", "teal"]);
|
|
28
|
+
|
|
29
|
+
/** Upper bound on a stored scheme name; anything longer is treated as junk. */
|
|
30
|
+
const SCHEME_MAX_LENGTH = 32;
|
|
31
|
+
|
|
32
|
+
/** Whether a value is a plain object. */
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate and coerce one raw config document into the canonical shape.
|
|
39
|
+
* Unknown fields are dropped; missing or invalid fields fall back to the
|
|
40
|
+
* default. `colorScheme` is preserved verbatim (any short non-blank string),
|
|
41
|
+
* so a newer client's scheme survives an older server between restarts.
|
|
42
|
+
* @param raw - parsed JSON document, or undefined for an absent file.
|
|
43
|
+
* @returns the canonical config.
|
|
44
|
+
*/
|
|
45
|
+
export function parseConfig(raw) {
|
|
46
|
+
const config = { ...DEFAULT_CONFIG };
|
|
47
|
+
if (!isPlainObject(raw)) return config;
|
|
48
|
+
if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
|
|
49
|
+
if (typeof raw.colorScheme === "string") {
|
|
50
|
+
const scheme = raw.colorScheme.trim();
|
|
51
|
+
if (scheme.length > 0 && scheme.length <= SCHEME_MAX_LENGTH) config.colorScheme = scheme;
|
|
52
|
+
}
|
|
53
|
+
return config;
|
|
54
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-token-heatmap — server half.
|
|
3
|
+
*
|
|
4
|
+
* Registers two loopback-only endpoints on the web server:
|
|
5
|
+
* GET /api/token-heatmap/usage — per-day token usage across every session
|
|
6
|
+
* GET /api/token-heatmap/config — the plugin's display settings
|
|
7
|
+
* POST /api/token-heatmap/config — persist display settings (switch + scheme)
|
|
8
|
+
*
|
|
9
|
+
* The endpoints live under the `/api` prefix as exact routes, so they win
|
|
10
|
+
* over the connection plugin's `/api` prefix handler; each handler applies
|
|
11
|
+
* its own peer-socket loopback fence (the exact route bypasses the RPC trust
|
|
12
|
+
* fence); Host is checked only as an additional defense.
|
|
13
|
+
*
|
|
14
|
+
* Usage aggregation is INCREMENTAL: per-session fold state (day/model
|
|
15
|
+
* buckets plus the last usage sample) is cached in memory and persisted to
|
|
16
|
+
* `<DSH_HOME>/storages/token-heatmap-cache.json`. On each request only the
|
|
17
|
+
* events added since the last fold are processed — live sessions fold their
|
|
18
|
+
* in-memory tail, while persisted sessions use the storage backend's opaque
|
|
19
|
+
* revision when available. Steady-state cost stays O(new events) no matter
|
|
20
|
+
* how large the logs grow.
|
|
21
|
+
*
|
|
22
|
+
* The fold semantics live in ./usage.js and mirror `dsh-token-meter`'s
|
|
23
|
+
* `tokenUsage` projection (same semantics as the reference plugin
|
|
24
|
+
* dsh-usage-stats, MIT © Ychris12138).
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-token-heatmap
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { join, dirname } from "node:path";
|
|
31
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
32
|
+
import { applyUsageDelta, createUsageState, mergeInto, renderUsage, zeroBuckets } from "./usage.js";
|
|
33
|
+
import { DEFAULT_CONFIG, parseConfig } from "./config.js";
|
|
34
|
+
|
|
35
|
+
/** Stable Cordis plugin name. */
|
|
36
|
+
const name = "token-heatmap";
|
|
37
|
+
|
|
38
|
+
/** Services required before this plugin activates. */
|
|
39
|
+
const inject = ["webServer", "sessions", "sessionPersistence"];
|
|
40
|
+
|
|
41
|
+
const USAGE_PATH = "/api/token-heatmap/usage";
|
|
42
|
+
const CONFIG_PATH = "/api/token-heatmap/config";
|
|
43
|
+
const CACHE_VERSION = 1;
|
|
44
|
+
|
|
45
|
+
/** Write a JSON response. */
|
|
46
|
+
function json(res, status, value) {
|
|
47
|
+
const body = JSON.stringify(value);
|
|
48
|
+
res.writeHead(status, {
|
|
49
|
+
"content-type": "application/json; charset=utf-8",
|
|
50
|
+
"cache-control": "no-cache"
|
|
51
|
+
});
|
|
52
|
+
res.end(body);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Loopback fence, primary on the PEER SOCKET address (not the
|
|
57
|
+
* client-controllable Host header): the request must come from a loopback
|
|
58
|
+
* interface. IPv4-mapped IPv6 (`::ffff:127.0.0.1`) is normalized. The Host
|
|
59
|
+
* header is kept as an additional check, never as the deciding one.
|
|
60
|
+
*/
|
|
61
|
+
function isLoopbackAddress(address) {
|
|
62
|
+
if (typeof address !== "string") return false;
|
|
63
|
+
const a = address.toLowerCase();
|
|
64
|
+
if (a === "::1") return true;
|
|
65
|
+
const ipv4 = a.startsWith("::ffff:") ? a.slice(7) : a;
|
|
66
|
+
const octets = ipv4.split(".");
|
|
67
|
+
return octets.length === 4 && octets[0] === "127" && octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parse a Host header without breaking bracketed or bare IPv6 literals. */
|
|
71
|
+
function hostNameOf(value) {
|
|
72
|
+
if (typeof value !== "string") return null;
|
|
73
|
+
const host = value.trim().toLowerCase();
|
|
74
|
+
if (host.startsWith("[")) {
|
|
75
|
+
const close = host.indexOf("]");
|
|
76
|
+
if (close <= 1) return null;
|
|
77
|
+
const suffix = host.slice(close + 1);
|
|
78
|
+
if (suffix !== "" && !/^:\d+$/.test(suffix)) return null;
|
|
79
|
+
return host.slice(1, close);
|
|
80
|
+
}
|
|
81
|
+
const firstColon = host.indexOf(":");
|
|
82
|
+
const lastColon = host.lastIndexOf(":");
|
|
83
|
+
if (firstColon !== lastColon) return host;
|
|
84
|
+
if (lastColon === -1) return host.replace(/\.$/, "");
|
|
85
|
+
if (!/^\d+$/.test(host.slice(lastColon + 1))) return null;
|
|
86
|
+
return host.slice(0, lastColon).replace(/\.$/, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isLoopbackHostHeader(req) {
|
|
90
|
+
const hostName = hostNameOf(req.headers.host);
|
|
91
|
+
return hostName === "localhost" || isLoopbackAddress(hostName);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Refuse callers whose peer socket is not loopback (Host header is defense-in-depth). */
|
|
95
|
+
function isLoopbackCaller(req) {
|
|
96
|
+
const peer = req.socket?.remoteAddress;
|
|
97
|
+
return isLoopbackAddress(peer) && isLoopbackHostHeader(req);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Refuse non-loopback callers and non-GET methods before any work. */
|
|
101
|
+
function rejectForeignCaller(req, res) {
|
|
102
|
+
if (req.method !== "GET") {
|
|
103
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
104
|
+
res.end(JSON.stringify({ ok: false, error: "method-not-allowed" }));
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (isLoopbackCaller(req)) return false;
|
|
108
|
+
json(res, 403, { ok: false, error: "forbidden" });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Refuse non-loopback callers and non-GET/POST methods before config work. */
|
|
113
|
+
function rejectForeignConfigCaller(req, res) {
|
|
114
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
115
|
+
res.writeHead(405, { "content-type": "application/json; charset=utf-8" });
|
|
116
|
+
res.end(JSON.stringify({ ok: false, error: "method-not-allowed" }));
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (isLoopbackCaller(req)) return false;
|
|
120
|
+
json(res, 403, { ok: false, error: "forbidden" });
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Collect a bounded request body as UTF-8 text. */
|
|
125
|
+
function readBody(req, limit = 4096) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const chunks = [];
|
|
128
|
+
let size = 0;
|
|
129
|
+
req.on("data", (chunk) => {
|
|
130
|
+
size += chunk.length;
|
|
131
|
+
if (size > limit) {
|
|
132
|
+
reject(new Error("request body too large"));
|
|
133
|
+
req.destroy();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
chunks.push(chunk);
|
|
137
|
+
});
|
|
138
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
139
|
+
req.on("error", reject);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
//#region incremental cache
|
|
144
|
+
/** Cache file location under the dsh home. */
|
|
145
|
+
function cachePath() {
|
|
146
|
+
const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
147
|
+
return join(home, "storages", "token-heatmap-cache.json");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let loadedCache = null;
|
|
151
|
+
let loadPromise = null;
|
|
152
|
+
let inflight = null;
|
|
153
|
+
|
|
154
|
+
/** Serialize one session's fold state (Maps → plain objects). */
|
|
155
|
+
function serializeSession(state) {
|
|
156
|
+
const days = {};
|
|
157
|
+
for (const [date, entry] of state.days) {
|
|
158
|
+
const models = {};
|
|
159
|
+
for (const [model, buckets] of entry.models) models[model] = { ...buckets };
|
|
160
|
+
days[date] = { totals: { ...entry.totals }, models };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
kind: state.kind ?? "persisted",
|
|
164
|
+
consumed: state.consumed ?? 0,
|
|
165
|
+
...(state.revision === void 0 ? {} : { revision: state.revision }),
|
|
166
|
+
days,
|
|
167
|
+
lastSample: state.lastSample === null ? null : {
|
|
168
|
+
key: state.lastSample.key,
|
|
169
|
+
day: state.lastSample.day,
|
|
170
|
+
model: state.lastSample.model,
|
|
171
|
+
buckets: { ...state.lastSample.buckets }
|
|
172
|
+
},
|
|
173
|
+
currentModel: state.currentModel
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Parse a serialized session entry back into fold state (lenient). */
|
|
178
|
+
function parseSession(raw) {
|
|
179
|
+
const state = createUsageState();
|
|
180
|
+
if (raw === null || typeof raw !== "object") return state;
|
|
181
|
+
state.kind = typeof raw.kind === "string" ? raw.kind : "persisted";
|
|
182
|
+
state.consumed = Number.isSafeInteger(raw.consumed) ? raw.consumed : 0;
|
|
183
|
+
if (typeof raw.revision === "string") state.revision = raw.revision;
|
|
184
|
+
if (raw.days !== null && typeof raw.days === "object") {
|
|
185
|
+
for (const [date, entry] of Object.entries(raw.days)) {
|
|
186
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
187
|
+
const target = { totals: zeroBuckets(), models: new Map() };
|
|
188
|
+
const totals = entry.totals;
|
|
189
|
+
if (totals !== null && typeof totals === "object") {
|
|
190
|
+
target.totals.inputTokens = Number.isFinite(totals.inputTokens) ? totals.inputTokens : 0;
|
|
191
|
+
target.totals.outputTokens = Number.isFinite(totals.outputTokens) ? totals.outputTokens : 0;
|
|
192
|
+
target.totals.cacheReadTokens = Number.isFinite(totals.cacheReadTokens) ? totals.cacheReadTokens : 0;
|
|
193
|
+
target.totals.cacheWriteTokens = Number.isFinite(totals.cacheWriteTokens) ? totals.cacheWriteTokens : 0;
|
|
194
|
+
}
|
|
195
|
+
if (entry.models !== null && typeof entry.models === "object") {
|
|
196
|
+
for (const [model, buckets] of Object.entries(entry.models)) {
|
|
197
|
+
if (buckets === null || typeof buckets !== "object") continue;
|
|
198
|
+
target.models.set(model, {
|
|
199
|
+
inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
|
|
200
|
+
outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
|
|
201
|
+
cacheReadTokens: Number.isFinite(buckets.cacheReadTokens) ? buckets.cacheReadTokens : 0,
|
|
202
|
+
cacheWriteTokens: Number.isFinite(buckets.cacheWriteTokens) ? buckets.cacheWriteTokens : 0
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
state.days.set(date, target);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (raw.lastSample !== null && raw.lastSample !== void 0 && typeof raw.lastSample === "object" && typeof raw.lastSample.key === "string" && typeof raw.lastSample.day === "string") {
|
|
210
|
+
const buckets = raw.lastSample.buckets ?? {};
|
|
211
|
+
state.lastSample = {
|
|
212
|
+
key: raw.lastSample.key,
|
|
213
|
+
day: raw.lastSample.day,
|
|
214
|
+
model: typeof raw.lastSample.model === "string" ? raw.lastSample.model : "unknown",
|
|
215
|
+
buckets: {
|
|
216
|
+
inputTokens: Number.isFinite(buckets.inputTokens) ? buckets.inputTokens : 0,
|
|
217
|
+
outputTokens: Number.isFinite(buckets.outputTokens) ? buckets.outputTokens : 0,
|
|
218
|
+
cacheReadTokens: Number.isFinite(buckets.cacheReadTokens) ? buckets.cacheReadTokens : 0,
|
|
219
|
+
cacheWriteTokens: Number.isFinite(buckets.cacheWriteTokens) ? buckets.cacheWriteTokens : 0
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (typeof raw.currentModel === "string") state.currentModel = raw.currentModel;
|
|
224
|
+
return state;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Load the cache once per process; any corruption degrades to a fresh cache. */
|
|
228
|
+
async function loadCache() {
|
|
229
|
+
if (loadedCache !== null) return loadedCache;
|
|
230
|
+
loadPromise ??= (async () => {
|
|
231
|
+
const fresh = { version: CACHE_VERSION, sessions: {} };
|
|
232
|
+
try {
|
|
233
|
+
const raw = await readFile(cachePath(), "utf8");
|
|
234
|
+
const parsed = JSON.parse(raw);
|
|
235
|
+
if (parsed !== null && typeof parsed === "object" && parsed.version === CACHE_VERSION && parsed.sessions !== null && typeof parsed.sessions === "object") {
|
|
236
|
+
const sessions = {};
|
|
237
|
+
for (const [id, entry] of Object.entries(parsed.sessions)) {
|
|
238
|
+
if (typeof id === "string" && id.length > 0) sessions[id] = parseSession(entry);
|
|
239
|
+
}
|
|
240
|
+
return { version: CACHE_VERSION, sessions };
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
/* first run or corrupt cache */
|
|
244
|
+
}
|
|
245
|
+
return fresh;
|
|
246
|
+
})();
|
|
247
|
+
loadedCache = await loadPromise;
|
|
248
|
+
return loadedCache;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Persist the cache atomically (temp + rename); failures are logged, never fatal. */
|
|
252
|
+
async function saveCache(ctx, cache) {
|
|
253
|
+
try {
|
|
254
|
+
const path = cachePath();
|
|
255
|
+
await mkdir(dirname(path), { recursive: true });
|
|
256
|
+
const serialized = { version: CACHE_VERSION, sessions: {} };
|
|
257
|
+
for (const [id, state] of Object.entries(cache.sessions)) serialized.sessions[id] = serializeSession(state);
|
|
258
|
+
const tmp = `${path}.tmp`;
|
|
259
|
+
await writeFile(tmp, JSON.stringify(serialized), "utf8");
|
|
260
|
+
await rename(tmp, path);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
ctx.logger.warn(`token-heatmap: saving usage cache failed: ${String(error)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Single-flight guard: concurrent requests share one aggregation run. */
|
|
267
|
+
function withLock(run) {
|
|
268
|
+
if (inflight !== null) return inflight;
|
|
269
|
+
inflight = run().finally(() => {
|
|
270
|
+
inflight = null;
|
|
271
|
+
});
|
|
272
|
+
return inflight;
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
|
|
276
|
+
//#region config store
|
|
277
|
+
/** Config file location under the dsh home. */
|
|
278
|
+
function configPath() {
|
|
279
|
+
const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
280
|
+
return join(home, "storages", "token-heatmap-config.json");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let loadedConfig = null;
|
|
284
|
+
let configLoadPromise = null;
|
|
285
|
+
|
|
286
|
+
/** Read the config document once per process; absence or corruption degrades to defaults. */
|
|
287
|
+
async function readConfigFile() {
|
|
288
|
+
if (loadedConfig !== null) return loadedConfig;
|
|
289
|
+
configLoadPromise ??= (async () => {
|
|
290
|
+
try {
|
|
291
|
+
const raw = await readFile(configPath(), "utf8");
|
|
292
|
+
return parseConfig(JSON.parse(raw));
|
|
293
|
+
} catch {
|
|
294
|
+
return { ...DEFAULT_CONFIG };
|
|
295
|
+
}
|
|
296
|
+
})();
|
|
297
|
+
loadedConfig = await configLoadPromise;
|
|
298
|
+
return loadedConfig;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Persist the config atomically (temp + rename); failures are logged, never fatal. */
|
|
302
|
+
async function writeConfigFile(ctx, config) {
|
|
303
|
+
try {
|
|
304
|
+
const path = configPath();
|
|
305
|
+
await mkdir(dirname(path), { recursive: true });
|
|
306
|
+
const tmp = `${path}.tmp`;
|
|
307
|
+
await writeFile(tmp, JSON.stringify({ version: 1, ...config }, null, 2), "utf8");
|
|
308
|
+
await rename(tmp, path);
|
|
309
|
+
loadedConfig = config;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
ctx.logger.warn(`token-heatmap: saving config failed: ${String(error)}`);
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function handleConfig(ctx, req, res) {
|
|
317
|
+
if (rejectForeignConfigCaller(req, res)) return;
|
|
318
|
+
try {
|
|
319
|
+
if (req.method === "GET") {
|
|
320
|
+
json(res, 200, { ok: true, ...(await readConfigFile()) });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
let raw;
|
|
324
|
+
try {
|
|
325
|
+
raw = JSON.parse(await readBody(req));
|
|
326
|
+
} catch (error) {
|
|
327
|
+
json(res, 400, { ok: false, error: "bad-json", message: "request body must be a JSON object" });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const config = parseConfig(raw);
|
|
331
|
+
await writeConfigFile(ctx, config);
|
|
332
|
+
json(res, 200, { ok: true, ...config });
|
|
333
|
+
} catch (error) {
|
|
334
|
+
ctx.logger.warn(`token-heatmap: config ${req.method} failed: ${String(error)}`);
|
|
335
|
+
json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Collect per-day usage across live and persisted sessions, incrementally.
|
|
342
|
+
*
|
|
343
|
+
* Live sessions: fold only the in-memory events added since the last fold.
|
|
344
|
+
* Persisted sessions: skipped when the backend's opaque revision is
|
|
345
|
+
* unchanged (`sessionPersistence.listSnapshots`, falling back to always
|
|
346
|
+
* reading the delta); when the revision changes, the new events are verified
|
|
347
|
+
* to be contiguous with the last folded seq — a gap or an empty delta means
|
|
348
|
+
* the log was truncated/rewritten, so the session is refolded from scratch.
|
|
349
|
+
* Sessions that vanished are dropped, and a session switching between
|
|
350
|
+
* live/persisted is refolded from scratch to stay exact.
|
|
351
|
+
*/
|
|
352
|
+
export async function collectUsage(ctx) {
|
|
353
|
+
return withLock(async () => {
|
|
354
|
+
const cache = await loadCache();
|
|
355
|
+
const live = ctx.get("sessions");
|
|
356
|
+
const attached = new Set();
|
|
357
|
+
if (live !== void 0) {
|
|
358
|
+
for (const session of live.list()) {
|
|
359
|
+
attached.add(session.id);
|
|
360
|
+
const state = cache.sessions[session.id] ?? createUsageState();
|
|
361
|
+
if (state.kind !== "live") {
|
|
362
|
+
// Live/persisted transition: refold the whole in-memory log.
|
|
363
|
+
state.days = new Map();
|
|
364
|
+
state.lastSample = null;
|
|
365
|
+
state.currentModel = null;
|
|
366
|
+
state.consumed = 0;
|
|
367
|
+
}
|
|
368
|
+
const count = session.events.length;
|
|
369
|
+
if ((state.consumed ?? 0) < count) {
|
|
370
|
+
applyUsageDelta(state, session.events.slice(state.consumed ?? 0));
|
|
371
|
+
state.consumed = count;
|
|
372
|
+
}
|
|
373
|
+
state.kind = "live";
|
|
374
|
+
cache.sessions[session.id] = state;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const persistence = ctx.get("sessionPersistence");
|
|
378
|
+
const persistedIds = new Set();
|
|
379
|
+
if (persistence !== void 0) {
|
|
380
|
+
// Prefer the backend's opaque per-log revisions (no file I/O in the
|
|
381
|
+
// plugin, works for any backend that exposes listSnapshots).
|
|
382
|
+
let snapshots = null;
|
|
383
|
+
if (typeof persistence.listSnapshots === "function") {
|
|
384
|
+
try {
|
|
385
|
+
snapshots = await persistence.listSnapshots();
|
|
386
|
+
} catch (error) {
|
|
387
|
+
ctx.logger.warn(`token-heatmap: listSnapshots failed, falling back to list(): ${String(error)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const metas = snapshots !== null ? snapshots.map((entry) => entry.header) : await persistence.list();
|
|
391
|
+
const revisionOf = new Map();
|
|
392
|
+
if (snapshots !== null) for (const entry of snapshots) revisionOf.set(entry.header.id, entry.revision);
|
|
393
|
+
for (const meta of metas) {
|
|
394
|
+
persistedIds.add(meta.id);
|
|
395
|
+
if (attached.has(meta.id)) continue;
|
|
396
|
+
const state = cache.sessions[meta.id] ?? createUsageState();
|
|
397
|
+
const revision = revisionOf.get(meta.id);
|
|
398
|
+
const changed = state.kind !== "persisted" || (revision !== void 0 && revision !== state.revision) || revision === void 0;
|
|
399
|
+
if (changed) {
|
|
400
|
+
try {
|
|
401
|
+
const wasPersisted = state.kind === "persisted";
|
|
402
|
+
const fromSeq = wasPersisted ? state.consumed : 0;
|
|
403
|
+
const { events } = await persistence.readFrom(meta.id, fromSeq);
|
|
404
|
+
if (!wasPersisted) {
|
|
405
|
+
state.days = new Map();
|
|
406
|
+
state.lastSample = null;
|
|
407
|
+
state.currentModel = null;
|
|
408
|
+
state.consumed = 0;
|
|
409
|
+
}
|
|
410
|
+
const fresh = wasPersisted ? events.filter((event) => event.seq > (state.consumed ?? 0)) : events;
|
|
411
|
+
const contiguous = fresh.length === 0 ? state.consumed === 0 : fresh[0].seq === state.consumed + 1;
|
|
412
|
+
if (!contiguous && state.consumed > 0) {
|
|
413
|
+
// Log truncated or rewritten: refold the whole log.
|
|
414
|
+
state.days = new Map();
|
|
415
|
+
state.lastSample = null;
|
|
416
|
+
state.currentModel = null;
|
|
417
|
+
state.consumed = 0;
|
|
418
|
+
const { events: allEvents } = await persistence.readFrom(meta.id, 0);
|
|
419
|
+
applyUsageDelta(state, allEvents);
|
|
420
|
+
state.consumed = allEvents.length > 0 ? allEvents[allEvents.length - 1].seq : 0;
|
|
421
|
+
} else if (fresh.length > 0) {
|
|
422
|
+
applyUsageDelta(state, fresh);
|
|
423
|
+
state.consumed = fresh[fresh.length - 1].seq;
|
|
424
|
+
}
|
|
425
|
+
state.kind = "persisted";
|
|
426
|
+
if (revision !== void 0) state.revision = revision;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
ctx.logger.warn(`token-heatmap: reading persisted session "${meta.id}" failed: ${String(error)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
cache.sessions[meta.id] = state;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
for (const id of Object.keys(cache.sessions)) {
|
|
435
|
+
if (!attached.has(id) && !persistedIds.has(id)) delete cache.sessions[id];
|
|
436
|
+
}
|
|
437
|
+
const byDay = new Map();
|
|
438
|
+
for (const state of Object.values(cache.sessions)) mergeInto(byDay, state.days);
|
|
439
|
+
// Keep the atomic cache write inside the single-flight section. Otherwise
|
|
440
|
+
// overlapping saves can race on the same temporary file.
|
|
441
|
+
await saveCache(ctx, cache);
|
|
442
|
+
return renderUsage(byDay, Date.now());
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function handleUsage(ctx, req, res) {
|
|
447
|
+
if (rejectForeignCaller(req, res)) return;
|
|
448
|
+
try {
|
|
449
|
+
const result = await collectUsage(ctx);
|
|
450
|
+
json(res, 200, { ok: true, ...result });
|
|
451
|
+
} catch (error) {
|
|
452
|
+
ctx.logger.warn(`token-heatmap: usage aggregation failed: ${String(error)}`);
|
|
453
|
+
json(res, 500, { ok: false, error: "internal", message: error instanceof Error ? error.message : String(error) });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Plugin body: register the usage and config routes.
|
|
459
|
+
* @param ctx - plugin context carrying webServer, sessions, and sessionPersistence.
|
|
460
|
+
*/
|
|
461
|
+
function apply(ctx) {
|
|
462
|
+
ctx.effect(() => ctx.webServer.register({
|
|
463
|
+
kind: "exact",
|
|
464
|
+
path: USAGE_PATH,
|
|
465
|
+
handler: (req, res) => handleUsage(ctx, req, res)
|
|
466
|
+
}), "token-heatmap: usage route");
|
|
467
|
+
ctx.effect(() => ctx.webServer.register({
|
|
468
|
+
kind: "exact",
|
|
469
|
+
path: CONFIG_PATH,
|
|
470
|
+
handler: (req, res) => handleConfig(ctx, req, res)
|
|
471
|
+
}), "token-heatmap: config route");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export { apply, inject, name, CONFIG_PATH, USAGE_PATH };
|