@tzyc/dsh-model-visibility 1.9.1 → 1.10.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/README.md +50 -30
- package/client-src/catalog-store.mjs +464 -0
- package/client-src/checker-view.mjs +221 -0
- package/client-src/composer-filter.mjs +153 -0
- package/client-src/constants.mjs +20 -0
- package/client-src/css.mjs +248 -0
- package/client-src/dict.mjs +427 -0
- package/client-src/format.mjs +154 -0
- package/client-src/hooks.mjs +25 -0
- package/client-src/index.mjs +101 -0
- package/client-src/override-editor.mjs +286 -0
- package/client-src/pure.mjs +136 -0
- package/client-src/settings-section.mjs +581 -0
- package/client-src/tags.mjs +197 -0
- package/client-src/usage-view.mjs +112 -0
- package/client.js +3228 -2258
- package/index.js +178 -0
- package/lib/capability-catalog.js +5 -69
- package/lib/capability-corrections.js +1 -1
- package/lib/check-catalog.js +575 -0
- package/lib/export-csv.js +69 -0
- package/lib/learned-catalog.js +294 -0
- package/lib/model-id.js +50 -0
- package/lib/model-info.js +94 -52
- package/lib/model-overrides.js +1 -1
- package/lib/usage-ledger.js +574 -0
- package/package.json +65 -64
- package/lib/model-catalog.js +0 -670
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-usage ledger for the settings page.
|
|
3
|
+
*
|
|
4
|
+
* The page shows how many tokens the user burned today, this ISO week, this
|
|
5
|
+
* calendar month and in total. DSH's own token-meter keeps per-session totals
|
|
6
|
+
* (`session_projcache`), but the page needs per-day accounting with route
|
|
7
|
+
* attribution, so this module rolls its own ledger from the durable session
|
|
8
|
+
* logs under `$DSH_HOME/sessions/**`.
|
|
9
|
+
*
|
|
10
|
+
* Session logs are JSONL, line-batched into individually compressed Zstandard
|
|
11
|
+
* frames (the same container `dsh-session-persistence-jsonl` writes). Each
|
|
12
|
+
* `assistant/message` / `assistant/attempt` event carries provider-reported
|
|
13
|
+
* usage plus the routed model, and `llm/retry-started` closes a replaced
|
|
14
|
+
* attempt so the retried call adds instead of overwriting. The fold below is
|
|
15
|
+
* byte-identical to the shipped projection on the reference logs (checked by
|
|
16
|
+
* the test suite's fixture walk).
|
|
17
|
+
*
|
|
18
|
+
* Scans are incremental: a file whose (mtime, size) pair is unchanged reuses
|
|
19
|
+
* its stored buckets. Events from the current month get their exact event-day
|
|
20
|
+
* attribution; older events are attributed to the session's creation day (the
|
|
21
|
+
* user accepted a rough month for history), while per-model attribution stays
|
|
22
|
+
* exact because every event names its route.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here reads credentials, and nothing but capability-free aggregate
|
|
25
|
+
* numbers ever reaches the client.
|
|
26
|
+
*
|
|
27
|
+
* @module lib/usage-ledger
|
|
28
|
+
*/
|
|
29
|
+
import fs from 'node:fs'
|
|
30
|
+
import fsp from 'node:fs/promises'
|
|
31
|
+
import os from 'node:os'
|
|
32
|
+
import path from 'node:path'
|
|
33
|
+
import zlib from 'node:zlib'
|
|
34
|
+
|
|
35
|
+
/** Magic number of a complete Zstandard frame. */
|
|
36
|
+
const ZSTD_MAGIC = 4247762216
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Locate complete frames without decompressing their blocks (same structural
|
|
40
|
+
* walk as the persistence backend: frame header, then block headers, then the
|
|
41
|
+
* optional checksum). EOF inside a frame is reported as `tornStart`, not an
|
|
42
|
+
* error, because the persistence writer can crash mid-append.
|
|
43
|
+
* @param buffer - complete bytes of one session log.
|
|
44
|
+
* @returns `{ frames: [{ start, end }], tornStart? }`.
|
|
45
|
+
*/
|
|
46
|
+
export function scanZstdFrames(buffer) {
|
|
47
|
+
const frames = []
|
|
48
|
+
let offset = 0
|
|
49
|
+
while (offset < buffer.length) {
|
|
50
|
+
const start = offset
|
|
51
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start }
|
|
52
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error(`invalid frame magic at byte ${offset}`)
|
|
53
|
+
offset += 4
|
|
54
|
+
if (offset === buffer.length) return { frames, tornStart: start }
|
|
55
|
+
const descriptor = buffer.readUInt8(offset)
|
|
56
|
+
offset += 1
|
|
57
|
+
if ((descriptor & 24) !== 0) throw new Error(`reserved frame-header bit at byte ${offset - 1}`)
|
|
58
|
+
const contentSizeFlag = descriptor >>> 6
|
|
59
|
+
const singleSegment = (descriptor & 32) !== 0
|
|
60
|
+
const checksum = (descriptor & 4) !== 0
|
|
61
|
+
const dictionaryBytes = (descriptor & 3) === 3 ? 4 : descriptor & 3
|
|
62
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
|
|
63
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
|
|
64
|
+
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
|
|
65
|
+
offset += remainingHeaderBytes
|
|
66
|
+
for (;;) {
|
|
67
|
+
if (buffer.length - offset < 3) return { frames, tornStart: start }
|
|
68
|
+
const blockHeader = buffer.readUIntLE(offset, 3)
|
|
69
|
+
offset += 3
|
|
70
|
+
const lastBlock = (blockHeader & 1) !== 0
|
|
71
|
+
const blockType = blockHeader >>> 1 & 3
|
|
72
|
+
const blockSize = blockHeader >>> 3
|
|
73
|
+
if (blockType === 3) throw new Error(`reserved block type at byte ${offset - 3}`)
|
|
74
|
+
const payloadBytes = blockType === 1 ? 1 : blockSize
|
|
75
|
+
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
|
|
76
|
+
offset += payloadBytes
|
|
77
|
+
if (lastBlock) break
|
|
78
|
+
}
|
|
79
|
+
if (checksum) {
|
|
80
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start }
|
|
81
|
+
offset += 4
|
|
82
|
+
}
|
|
83
|
+
frames.push({ start, end: offset })
|
|
84
|
+
}
|
|
85
|
+
return { frames }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decode one session log into its JSON lines.
|
|
90
|
+
*
|
|
91
|
+
* Plaintext `.jsonl` files scan straight through; Zstandard files split into
|
|
92
|
+
* frames and decompress each one. A corrupt frame is skipped rather than
|
|
93
|
+
* failing the ledger (totals then under-count that session, matching what any
|
|
94
|
+
* reader that survives the same container can recover).
|
|
95
|
+
* @param buffer - the file bytes.
|
|
96
|
+
* @param isZstd - whether the suffix promises a Zstandard container.
|
|
97
|
+
* @returns an array of plain per-line strings.
|
|
98
|
+
*/
|
|
99
|
+
export function decodeLogLines(buffer, isZstd) {
|
|
100
|
+
if (!isZstd) {
|
|
101
|
+
return buffer
|
|
102
|
+
.toString('utf8')
|
|
103
|
+
.split('\n')
|
|
104
|
+
.filter((line) => line !== '')
|
|
105
|
+
}
|
|
106
|
+
const lines = []
|
|
107
|
+
let bad = 0
|
|
108
|
+
let scan
|
|
109
|
+
try {
|
|
110
|
+
scan = scanZstdFrames(buffer)
|
|
111
|
+
} catch (_error) {
|
|
112
|
+
return lines
|
|
113
|
+
}
|
|
114
|
+
for (const { start, end } of scan.frames) {
|
|
115
|
+
let text
|
|
116
|
+
try {
|
|
117
|
+
text = zlib.zstdDecompressSync(buffer.subarray(start, end))
|
|
118
|
+
} catch (_error) {
|
|
119
|
+
bad += 1
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
for (const line of text.toString('utf8').split('\n')) {
|
|
123
|
+
if (line !== '') lines.push(line)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (bad > 0 && lines.length === 0) return []
|
|
127
|
+
return lines
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Local-calendar `YYYY-MM-DD` key for one epoch-ms timestamp. */
|
|
131
|
+
export function dayKeyOf(time) {
|
|
132
|
+
const date = new Date(time)
|
|
133
|
+
if (Number.isNaN(date.getTime())) return undefined
|
|
134
|
+
const year = date.getFullYear()
|
|
135
|
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
136
|
+
const day = String(date.getDate()).padStart(2, '0')
|
|
137
|
+
return `${year}-${month}-${day}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Local-calendar `YYYY-MM` prefix of a day key. */
|
|
141
|
+
export function monthKeyOf(dayKey) {
|
|
142
|
+
return typeof dayKey === 'string' && dayKey.length >= 7 ? dayKey.slice(0, 7) : undefined
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** First day (Monday) of the local ISO week containing `now`. */
|
|
146
|
+
export function weekStartKey(now = Date.now()) {
|
|
147
|
+
const date = new Date(now)
|
|
148
|
+
const weekday = (date.getDay() + 6) % 7 // Monday = 0
|
|
149
|
+
date.setDate(date.getDate() - weekday)
|
|
150
|
+
return dayKeyOf(date.getTime())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Empty usage bucket. */
|
|
154
|
+
function zeroBuckets() {
|
|
155
|
+
return [0, 0, 0, 0]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Add bucket source into destination, in place. */
|
|
159
|
+
function addInto(destination, source) {
|
|
160
|
+
for (let index = 0; index < 4; index += 1) {
|
|
161
|
+
if (source[index] !== undefined) destination[index] += source[index] ?? 0
|
|
162
|
+
}
|
|
163
|
+
return destination
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Provider-reported usage record of one settlement event, or undefined. */
|
|
167
|
+
function usageOf(event) {
|
|
168
|
+
const data = event?.data
|
|
169
|
+
if (event?.type === 'assistant/message') {
|
|
170
|
+
return data?.usage !== null && typeof data?.usage === 'object' ? data.usage : undefined
|
|
171
|
+
}
|
|
172
|
+
if (event?.type !== 'assistant/attempt') return undefined
|
|
173
|
+
// The last usage chunk in the recorded stream is the settlement sample.
|
|
174
|
+
const stream = Array.isArray(data?.stream) ? data.stream : []
|
|
175
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
176
|
+
const chunk = stream[index]?.chunk
|
|
177
|
+
if (chunk?.type === 'usage' && chunk?.usage !== null && typeof chunk?.usage === 'object') {
|
|
178
|
+
return chunk.usage
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Bucket values of a provider usage record. */
|
|
185
|
+
function bucketsOf(usage) {
|
|
186
|
+
const input = usage?.inputTokens
|
|
187
|
+
const output = usage?.outputTokens
|
|
188
|
+
if (!Number.isFinite(input) || !Number.isFinite(output)) return undefined
|
|
189
|
+
return [
|
|
190
|
+
Math.max(0, Math.trunc(input)),
|
|
191
|
+
Math.max(0, Math.trunc(output)),
|
|
192
|
+
Math.max(0, Math.trunc(usage?.cacheReadTokens ?? 0)),
|
|
193
|
+
Math.max(0, Math.trunc(usage?.cacheWriteTokens ?? 0)),
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Fold one session's events into per-day per-model buckets.
|
|
199
|
+
*
|
|
200
|
+
* `preciseFrom` is the earliest day key that still earns exact event-day
|
|
201
|
+
* attribution; older events keep exact totals but count on the session's
|
|
202
|
+
* creation day. The fold keeps the shipped token-usage projection's
|
|
203
|
+
* replacement and retry semantics.
|
|
204
|
+
* @param lines - decoded JSONL strings of one session log.
|
|
205
|
+
* @param options - `{ createdAt?, preciseFrom? }`.
|
|
206
|
+
* @returns `{ createdAt?, buckets: { [dayKey]: { [route]: [in,out,cr,cw] } } }`.
|
|
207
|
+
*/
|
|
208
|
+
export function foldLogToBuckets(lines, options = {}) {
|
|
209
|
+
const preciseFrom = options.preciseFrom
|
|
210
|
+
const createdAtTime = Number.isSafeInteger(options.createdAt) ? options.createdAt : undefined
|
|
211
|
+
let headerCreation = undefined
|
|
212
|
+
const buckets = {}
|
|
213
|
+
const totals = zeroBuckets()
|
|
214
|
+
/** The sample the next same-(turn,step) settlement may replace. */
|
|
215
|
+
let lastKey = null
|
|
216
|
+
|
|
217
|
+
const routeOf = (event) => {
|
|
218
|
+
const source = event?.data?.message?.source
|
|
219
|
+
if (source !== null && typeof source === 'object' && source.kind === 'model') {
|
|
220
|
+
const provider = typeof source.provider === 'string' && source.provider !== '' ? source.provider : undefined
|
|
221
|
+
const model = typeof source.model === 'string' && source.model !== '' ? source.model : undefined
|
|
222
|
+
return provider !== undefined && model !== undefined ? `${provider}/${model}` : model
|
|
223
|
+
}
|
|
224
|
+
const data = event?.data
|
|
225
|
+
if (typeof data?.provider === 'string' && typeof data?.model === 'string') {
|
|
226
|
+
return `${data.provider}/${data.model}`
|
|
227
|
+
}
|
|
228
|
+
return 'unknown'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const record = (dayKey, route, values) => {
|
|
232
|
+
if (dayKey === undefined) return
|
|
233
|
+
const byModel = buckets[dayKey] ?? (buckets[dayKey] = {})
|
|
234
|
+
addInto(byModel[route] ?? (byModel[route] = zeroBuckets()), values)
|
|
235
|
+
addInto(byModel['*'] ?? (byModel['*'] = zeroBuckets()), values)
|
|
236
|
+
addInto(totals, values)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const line of lines) {
|
|
240
|
+
let event
|
|
241
|
+
try {
|
|
242
|
+
event = JSON.parse(line)
|
|
243
|
+
} catch (_error) {
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (event === null || typeof event !== 'object') continue
|
|
247
|
+
if (event.type === 'session') {
|
|
248
|
+
headerCreation = Number.isSafeInteger(event.createdAt) && event.createdAt > 0 ? event.createdAt : headerCreation
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
if (event.type === 'llm/retry-started') {
|
|
252
|
+
if (lastKey !== null && lastKey.turn === event.data?.turn && lastKey.step === event.data?.step) {
|
|
253
|
+
lastKey = null
|
|
254
|
+
}
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
const usage = usageOf(event)
|
|
258
|
+
if (usage === undefined) continue
|
|
259
|
+
const values = bucketsOf(usage)
|
|
260
|
+
if (values === undefined) continue
|
|
261
|
+
const turn = Number.isSafeInteger(event?.data?.turn) ? event.data.turn : -1
|
|
262
|
+
const step = Number.isSafeInteger(event?.data?.step) ? event.data.step : -1
|
|
263
|
+
const previous =
|
|
264
|
+
lastKey !== null && lastKey.turn === turn && lastKey.step === step ? lastKey : undefined
|
|
265
|
+
if (previous !== undefined) {
|
|
266
|
+
const same =
|
|
267
|
+
previous.buckets[0] === values[0] &&
|
|
268
|
+
previous.buckets[1] === values[1] &&
|
|
269
|
+
previous.buckets[2] === values[2] &&
|
|
270
|
+
previous.buckets[3] === values[3]
|
|
271
|
+
if (same) continue
|
|
272
|
+
record(previous.dayKey, previous.route, [-previous.buckets[0], -previous.buckets[1], -previous.buckets[2], -previous.buckets[3]])
|
|
273
|
+
}
|
|
274
|
+
const time = Number.isSafeInteger(event?.time) && event.time > 0 ? event.time : undefined
|
|
275
|
+
const fallbackTime = createdAtTime ?? headerCreation
|
|
276
|
+
const eventDay = dayKeyOf(time)
|
|
277
|
+
const dayKey =
|
|
278
|
+
accurateDay(eventDay, preciseFrom) ?? dayKeyOf(fallbackTime) ?? eventDay
|
|
279
|
+
record(dayKey, routeOf(event), values)
|
|
280
|
+
lastKey = { turn, step, dayKey, route: routeOf(event), buckets: values }
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
...(createdAtTime !== undefined ? { createdAt: createdAtTime } : {}),
|
|
284
|
+
...(buckets === undefined || Object.keys(buckets).length === 0 ? {} : { buckets }),
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Event-day attribution only below (and including) the precise window. */
|
|
289
|
+
function accurateDay(eventDay, preciseFrom) {
|
|
290
|
+
if (eventDay === undefined) return undefined
|
|
291
|
+
if (preciseFrom === undefined) return eventDay
|
|
292
|
+
return eventDay >= preciseFrom ? eventDay : undefined
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Absolute sessions directory for the ledger. */
|
|
296
|
+
function sessionsRoot(home) {
|
|
297
|
+
return path.join(home, 'sessions')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const SESSION_FILES = ['session.v4.jsonl.zstd', 'session.v4.jsonl']
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One recursive pass over the sessions tree collecting per-file candidates.
|
|
304
|
+
* @param root - sessions directory.
|
|
305
|
+
* @returns `{ root, files: [{ relPath, absPath, size, mtime }] }`.
|
|
306
|
+
*/
|
|
307
|
+
export async function listSessionFiles(root) {
|
|
308
|
+
const files = []
|
|
309
|
+
const walk = async (dir, relPrefix) => {
|
|
310
|
+
let entries
|
|
311
|
+
try {
|
|
312
|
+
entries = await fsp.readdir(dir, { withFileTypes: true })
|
|
313
|
+
} catch (_error) {
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
const child = path.join(dir, entry.name)
|
|
318
|
+
const rel = relPrefix === '' ? entry.name : `${relPrefix}/${entry.name}`
|
|
319
|
+
if (entry.isDirectory()) {
|
|
320
|
+
await walk(child, rel)
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (!entry.isFile()) continue
|
|
324
|
+
if (!SESSION_FILES.includes(entry.name)) continue
|
|
325
|
+
try {
|
|
326
|
+
const stat = await fsp.stat(child)
|
|
327
|
+
files.push({ relPath: rel, absPath: child, size: stat.size, mtime: Math.trunc(stat.mtimeMs) })
|
|
328
|
+
} catch (_error) {
|
|
329
|
+
// A file vanishing mid-scan is ordinary.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
await walk(root, '')
|
|
334
|
+
return { root, files }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The ledger state and scans behind the `/usage` route.
|
|
339
|
+
*
|
|
340
|
+
* Everything is best-effort: a scan never throws to the caller, errors land in
|
|
341
|
+
* the diagnostics list, and a partially scanned ledger still answers.
|
|
342
|
+
* @param options - `{ home?, file?, logger?, preciseMonths?, now? }`.
|
|
343
|
+
*/
|
|
344
|
+
export function createUsageLedger(options = {}) {
|
|
345
|
+
const logger = options.logger ?? console
|
|
346
|
+
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
347
|
+
const home = options.home || process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
348
|
+
const file = options.file || path.join(home, 'dsh-model-visibility', 'usage-ledger.json')
|
|
349
|
+
const root = options.sessionsRoot || sessionsRoot(home)
|
|
350
|
+
/** Exact event-day attribution window, in one calendar month. */
|
|
351
|
+
const preciseMonths = Number.isFinite(options.preciseMonths) ? Math.min(6, Math.max(1, options.preciseMonths)) : 1
|
|
352
|
+
|
|
353
|
+
/** `{ files: { relPath: record } }` after the last successful load. */
|
|
354
|
+
let stored = { files: {} }
|
|
355
|
+
/** Longer-lived plugin-recorded usage: `{ dayKey: { route: buckets } }`. */
|
|
356
|
+
let pluginBuckets = {}
|
|
357
|
+
|
|
358
|
+
let loaded = false
|
|
359
|
+
let scanInflight = null
|
|
360
|
+
let scanProgress = null
|
|
361
|
+
let lastError = undefined
|
|
362
|
+
let dirty = false
|
|
363
|
+
|
|
364
|
+
const warn = (message, error) => {
|
|
365
|
+
lastError = message
|
|
366
|
+
if (typeof logger.warn === 'function') {
|
|
367
|
+
logger.warn(message)
|
|
368
|
+
if (error !== undefined) logger.warn(error)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const load = () => {
|
|
373
|
+
if (loaded) return
|
|
374
|
+
loaded = true
|
|
375
|
+
try {
|
|
376
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
377
|
+
stored = parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
378
|
+
stored = { files: stored?.files !== null && typeof stored?.files === 'object' ? stored.files : {} }
|
|
379
|
+
const plugin = parsed !== null && typeof parsed === 'object' ? parsed?.plugin : undefined
|
|
380
|
+
pluginBuckets = plugin !== null && typeof plugin === 'object' ? plugin : {}
|
|
381
|
+
} catch (_error) {
|
|
382
|
+
stored = { files: {} }
|
|
383
|
+
pluginBuckets = {}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const persist = () => {
|
|
388
|
+
load()
|
|
389
|
+
try {
|
|
390
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
391
|
+
const body = { version: 1, updatedAt: now(), plugin: pluginBuckets, files: stored.files }
|
|
392
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
393
|
+
fs.writeFileSync(temp, `${JSON.stringify(body)}\n`)
|
|
394
|
+
fs.renameSync(temp, file)
|
|
395
|
+
dirty = false
|
|
396
|
+
} catch (error) {
|
|
397
|
+
warn('model-visibility: could not write the usage ledger', error)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Merge one file's folded buckets into the stored records. */
|
|
402
|
+
const absorb = (relPath, meta, folded) => {
|
|
403
|
+
stored.files[relPath] = {
|
|
404
|
+
...(stored.files[relPath] ?? {}),
|
|
405
|
+
mtime: meta.mtime,
|
|
406
|
+
size: meta.size,
|
|
407
|
+
createdAt: folded.createdAt,
|
|
408
|
+
buckets: folded.buckets ?? {},
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Drop ledger entries long gone and month data past the retention window. */
|
|
413
|
+
const prune = () => {
|
|
414
|
+
const horizon = new Date(now())
|
|
415
|
+
horizon.setMonth(horizon.getMonth() - 14)
|
|
416
|
+
const horizonKey = dayKeyOf(horizon.getTime())
|
|
417
|
+
let changed = false
|
|
418
|
+
if (horizonKey !== undefined) {
|
|
419
|
+
for (const record of Object.values(stored.files)) {
|
|
420
|
+
const buckets = record.buckets ?? {}
|
|
421
|
+
for (const key of Object.keys(buckets)) {
|
|
422
|
+
if (key < horizonKey) {
|
|
423
|
+
delete buckets[key]
|
|
424
|
+
changed = true
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (const key of Object.keys(pluginBuckets)) {
|
|
429
|
+
if (key < horizonKey) {
|
|
430
|
+
delete pluginBuckets[key]
|
|
431
|
+
changed = true
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return changed
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Scan session files; incremental by (mtime, size).
|
|
440
|
+
* @param options - `{ force? }` re-decodes every file when set.
|
|
441
|
+
*/
|
|
442
|
+
const rescan = async ({ force = false } = {}) => {
|
|
443
|
+
load()
|
|
444
|
+
if (scanInflight !== null) return scanInflight
|
|
445
|
+
scanProgress = { done: 0, total: null }
|
|
446
|
+
const stale = new Date(now())
|
|
447
|
+
stale.setMonth(stale.getMonth() - preciseMonths)
|
|
448
|
+
const preciseFrom = dayKeyOf(stale.getTime())
|
|
449
|
+
const run = (async () => {
|
|
450
|
+
const listing = await listSessionFiles(root)
|
|
451
|
+
scanProgress.total = listing.files.length
|
|
452
|
+
for (const meta of listing.files) {
|
|
453
|
+
const previous = force ? undefined : stored.files[meta.relPath]
|
|
454
|
+
if (previous?.mtime === meta.mtime && previous?.size === meta.size) {
|
|
455
|
+
// Already counted with the same content; keep the old buckets.
|
|
456
|
+
scanProgress.done += 1
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const buffer = await fsp.readFile(meta.absPath)
|
|
461
|
+
const lines = decodeLogLines(buffer, meta.absPath.endsWith('.zstd'))
|
|
462
|
+
const folded = foldLogToBuckets(lines, { preciseFrom })
|
|
463
|
+
absorb(meta.relPath, meta, { ...folded, createdAt: folded.createdAt })
|
|
464
|
+
} catch (error) {
|
|
465
|
+
warn(`model-visibility: session log scan failed for ${meta.relPath}`, error)
|
|
466
|
+
}
|
|
467
|
+
scanProgress.done += 1
|
|
468
|
+
// Yield so a large tree cannot starve the loop it runs on.
|
|
469
|
+
if (scanProgress.done % 8 === 0) await new Promise((resolve) => setImmediate(resolve))
|
|
470
|
+
}
|
|
471
|
+
// Persist whenever anything changed — including a clean first scan, so a
|
|
472
|
+
// restart resumes from the ledger instead of re-reading every log.
|
|
473
|
+
if (prune() || dirty || (scanProgress !== null && scanProgress.total > 0)) persist()
|
|
474
|
+
scanProgress = null
|
|
475
|
+
})().catch((error) => {
|
|
476
|
+
// The scan never propagates: a failed pass still leaves the last ledger.
|
|
477
|
+
warn('model-visibility: usage scan failed', error)
|
|
478
|
+
scanProgress = null
|
|
479
|
+
})
|
|
480
|
+
const tracked = run.finally(() => {
|
|
481
|
+
if (scanInflight !== null) scanInflight = null
|
|
482
|
+
})
|
|
483
|
+
scanInflight = tracked
|
|
484
|
+
return tracked
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Fold one plugin-attributed settlement (the verify sweep records itself). */
|
|
488
|
+
const notePluginUsage = (entry) => {
|
|
489
|
+
load()
|
|
490
|
+
const time = Number.isSafeInteger(entry?.time) ? entry.time : now()
|
|
491
|
+
const dayKey = dayKeyOf(time)
|
|
492
|
+
if (dayKey === undefined) return
|
|
493
|
+
const values = bucketsOf(entry?.usage)
|
|
494
|
+
if (values === undefined) return
|
|
495
|
+
const route =
|
|
496
|
+
typeof entry?.provider === 'string' && typeof entry?.model === 'string'
|
|
497
|
+
? `${entry.provider}/${entry.model}`
|
|
498
|
+
: 'plugin'
|
|
499
|
+
const byModel = pluginBuckets[dayKey] ?? (pluginBuckets[dayKey] = {})
|
|
500
|
+
addInto(byModel[route] ?? (byModel[route] = zeroBuckets()), values)
|
|
501
|
+
addInto(byModel['*'] ?? (byModel['*'] = zeroBuckets()), values)
|
|
502
|
+
dirty = true
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Combined day/model bucket map for `$windowFrom`-and-after days.
|
|
507
|
+
* @returns `{ days, byModel, newerThan? }`.
|
|
508
|
+
*/
|
|
509
|
+
const collect = (windowFrom) => {
|
|
510
|
+
load()
|
|
511
|
+
const days = {}
|
|
512
|
+
const byModel = {}
|
|
513
|
+
const absorbMap = (map, from) => {
|
|
514
|
+
for (const [dayKey, byRoute] of Object.entries(map)) {
|
|
515
|
+
if (from !== undefined && dayKey < from) continue
|
|
516
|
+
const day = days[dayKey] ?? (days[dayKey] = zeroBuckets())
|
|
517
|
+
for (const [route, values] of Object.entries(byRoute)) {
|
|
518
|
+
// `*` is a per-day convenience row only; aggregates derive from the
|
|
519
|
+
// real routes so no sum ever double counts.
|
|
520
|
+
if (route === '*') continue
|
|
521
|
+
addInto(day, values)
|
|
522
|
+
const row = byModel[route] ?? (byModel[route] = zeroBuckets())
|
|
523
|
+
addInto(row, values)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const from = windowFrom
|
|
528
|
+
for (const record of Object.values(stored.files)) {
|
|
529
|
+
absorbMap(record.buckets ?? {}, from)
|
|
530
|
+
}
|
|
531
|
+
absorbMap(pluginBuckets, from)
|
|
532
|
+
return { days, byModel }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const sumBuckets = (map) => {
|
|
536
|
+
const total = zeroBuckets()
|
|
537
|
+
for (const values of Object.values(map)) addInto(total, values)
|
|
538
|
+
return total
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The API the route serves: ranges resolved against the local calendar. */
|
|
542
|
+
const read = (options2 = {}) => {
|
|
543
|
+
load()
|
|
544
|
+
const stamp = now()
|
|
545
|
+
const todayKey = dayKeyOf(stamp)
|
|
546
|
+
const weekKey = weekStartKey(stamp)
|
|
547
|
+
const monthPrefix = todayKey.slice(0, 7)
|
|
548
|
+
const total = collect()
|
|
549
|
+
const month = collect(monthPrefix)
|
|
550
|
+
const week = collect(weekKey)
|
|
551
|
+
const day = collect(todayKey)
|
|
552
|
+
const byModel = month.byModel
|
|
553
|
+
delete byModel['*']
|
|
554
|
+
return {
|
|
555
|
+
generatedAt: stamp,
|
|
556
|
+
computing: scanInflight !== null,
|
|
557
|
+
progress: scanProgress,
|
|
558
|
+
...(lastError !== undefined ? { error: lastError } : {}),
|
|
559
|
+
ranges: {
|
|
560
|
+
today: sumBuckets(todayKey !== undefined && day.days[todayKey] ? [day.days[todayKey]] : []),
|
|
561
|
+
week: sumBuckets(week.days),
|
|
562
|
+
month: sumBuckets(month.days),
|
|
563
|
+
total: sumBuckets(total.days),
|
|
564
|
+
},
|
|
565
|
+
days: total.days,
|
|
566
|
+
byModel,
|
|
567
|
+
todayKey,
|
|
568
|
+
weekKey,
|
|
569
|
+
monthPrefix,
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return { file, read, rescan, notePluginUsage, isScanning: () => scanInflight !== null }
|
|
574
|
+
}
|