@promptctl/cc-candybar 1.42.1 → 1.43.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/dist/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- package/src/version.ts +0 -17
|
@@ -1,720 +0,0 @@
|
|
|
1
|
-
import { dirname, join } from "node:path";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
type SessionInfo,
|
|
5
|
-
type UsageInfo,
|
|
6
|
-
type TokenBreakdown,
|
|
7
|
-
} from "../../segments/session";
|
|
8
|
-
import { PricingService } from "../../segments/pricing";
|
|
9
|
-
import {
|
|
10
|
-
getClaudePaths,
|
|
11
|
-
findProjectPaths,
|
|
12
|
-
findAgentTranscripts,
|
|
13
|
-
readAppendedEntries,
|
|
14
|
-
type ClaudeHookData,
|
|
15
|
-
type ParsedEntry,
|
|
16
|
-
type TranscriptCursor,
|
|
17
|
-
} from "../../utils/claude";
|
|
18
|
-
// [LAW:single-enforcer] The once-per-day seed's directory walk shares the same
|
|
19
|
-
// in-flight-I/O budget (gn4.2) as every other transcript scan.
|
|
20
|
-
import {
|
|
21
|
-
readdir as gatedReaddir,
|
|
22
|
-
stat as gatedStat,
|
|
23
|
-
statMtimeMs,
|
|
24
|
-
} from "../../utils/transcript-fs";
|
|
25
|
-
import { SingleFlight } from "../../utils/single-flight";
|
|
26
|
-
import { ABSENT, failed, ok, type Outcome } from "../../utils/outcome";
|
|
27
|
-
import { dlog } from "../log";
|
|
28
|
-
|
|
29
|
-
// [LAW:one-source-of-truth] The daemon's single owner of per-session usage.
|
|
30
|
-
// Per-session records are canonical; the `session` projection (whole-session
|
|
31
|
-
// totals) and the `today` projection (cross-session sum of today's per-day
|
|
32
|
-
// buckets) are BOTH folds over this one store. There is no second usage cache
|
|
33
|
-
// and no per-render whole-tree scan: a render observes the active session's
|
|
34
|
-
// change through the single mtime stat it already does, re-parses only that one
|
|
35
|
-
// session, and folds in-memory records for everything else.
|
|
36
|
-
//
|
|
37
|
-
// [LAW:dataflow-not-control-flow] `today` stops being "recompute-if-stale"
|
|
38
|
-
// (where the staleness probe — a whole-tree mtime sweep — cost as much as the
|
|
39
|
-
// recompute it guarded). The aggregate is derived state maintained
|
|
40
|
-
// incrementally: the whole transcript tree is scanned EXACTLY ONCE, lazily, to
|
|
41
|
-
// seed records for sessions that did work before this daemon saw them; every
|
|
42
|
-
// render after that is a single-file stat plus a fold.
|
|
43
|
-
|
|
44
|
-
// [LAW:types-are-the-program] An `ok` TodayInfo always carries real totals —
|
|
45
|
-
// "no usage recorded today" is the `absent` outcome arm, not a bag of nulls.
|
|
46
|
-
export interface TodayInfo {
|
|
47
|
-
cost: number;
|
|
48
|
-
tokens: number;
|
|
49
|
-
tokenBreakdown: TokenBreakdown;
|
|
50
|
-
date: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// [LAW:one-source-of-truth] One observation of the active session's cumulative
|
|
54
|
-
// token counts at a single instant. tok/s is the delta between two of these —
|
|
55
|
-
// the prior sample lives in this store (the single owner of per-session token
|
|
56
|
-
// totals), never in a parallel counter. `input` folds the cache lanes into the
|
|
57
|
-
// prompt-side total so `total === input + output`. `atMs` is the render's clock
|
|
58
|
-
// instant (the daemon's single-enforcer clock), so a frozen test clock makes
|
|
59
|
-
// the rate deterministic.
|
|
60
|
-
export interface SpeedSample {
|
|
61
|
-
readonly input: number;
|
|
62
|
-
readonly output: number;
|
|
63
|
-
readonly total: number;
|
|
64
|
-
readonly atMs: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// The prior observation (absent on the very first render of a session), the one
|
|
68
|
-
// just taken, and the recent ring (oldest→newest, INCLUDING `cur`). The pure
|
|
69
|
-
// projections live at the render-payload boundary; this store only remembers and
|
|
70
|
-
// reports. [LAW:one-source-of-truth] `prev === samples[samples.length - 2]` — the
|
|
71
|
-
// tok/s baseline and the burn-rate history fold from the SAME owned ring, not two
|
|
72
|
-
// parallel stores. tok/s reads the last pair; the sparkline reads every pair.
|
|
73
|
-
export interface SpeedObservation {
|
|
74
|
-
readonly prev?: SpeedSample;
|
|
75
|
-
readonly cur: SpeedSample;
|
|
76
|
-
readonly samples: readonly SpeedSample[];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// How many recent samples the burn-rate ring retains per session. A render-cadence
|
|
80
|
-
// trend, not an archive: enough to fill a wide sparkline cell, capped so an
|
|
81
|
-
// idle-but-alive session can't grow it without bound. The window the sparkline
|
|
82
|
-
// draws is a tail slice of this (the `width` arg), so this only sets the ceiling.
|
|
83
|
-
const SPEED_RING_CAPACITY = 64;
|
|
84
|
-
|
|
85
|
-
function speedSampleOf(
|
|
86
|
-
breakdown: TokenBreakdown | null,
|
|
87
|
-
atMs: number,
|
|
88
|
-
): SpeedSample {
|
|
89
|
-
// Prompt-side = raw input plus both cache lanes (all tokens fed to the model);
|
|
90
|
-
// output = generated. total = the same sum the store's `tokens` projection
|
|
91
|
-
// uses, so `total === input + output`. [LAW:one-source-of-truth]
|
|
92
|
-
const input = breakdown
|
|
93
|
-
? breakdown.input + breakdown.cacheCreation + breakdown.cacheRead
|
|
94
|
-
: 0;
|
|
95
|
-
const output = breakdown ? breakdown.output : 0;
|
|
96
|
-
return { input, output, total: input + output, atMs };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Per-(session, day) scalar contribution — the only granularity the today fold
|
|
100
|
-
// needs. Raw entries are discarded after bucketing, so per-session retained
|
|
101
|
-
// memory is O(retained-days), not O(entries).
|
|
102
|
-
interface DayUsage {
|
|
103
|
-
cost: number;
|
|
104
|
-
input: number;
|
|
105
|
-
output: number;
|
|
106
|
-
cacheCreation: number;
|
|
107
|
-
cacheRead: number;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
interface SessionRecord {
|
|
111
|
-
// [LAW:one-source-of-truth] `files` is canonical (main transcript + agent
|
|
112
|
-
// sidechains, each its own incremental FileFold); `sessionInfo`/`days` are
|
|
113
|
-
// derived from mergeFolds and re-synced on every ingest — the single writer.
|
|
114
|
-
files: Map<string, FileFold>;
|
|
115
|
-
sessionInfo: SessionInfo;
|
|
116
|
-
days: Map<string, DayUsage>;
|
|
117
|
-
transcriptMtime: number;
|
|
118
|
-
transcriptPath: string | undefined;
|
|
119
|
-
lastSeenAt: number;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const DEFAULT_MAX_ENTRIES = 256;
|
|
123
|
-
const DEFAULT_STALE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
124
|
-
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
125
|
-
// Mirrors the transcript-fs gate width: the seed's parse fan-out is bounded by
|
|
126
|
-
// the same constant as the I/O it drives, so the once-a-day scan can never
|
|
127
|
-
// re-create the unbounded burst gn4 exists to kill.
|
|
128
|
-
const SEED_CONCURRENCY = 8;
|
|
129
|
-
|
|
130
|
-
const EMPTY_DAY: DayUsage = {
|
|
131
|
-
cost: 0,
|
|
132
|
-
input: 0,
|
|
133
|
-
output: 0,
|
|
134
|
-
cacheCreation: 0,
|
|
135
|
-
cacheRead: 0,
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const EMPTY_SESSION_INFO: SessionInfo = {
|
|
139
|
-
cost: null,
|
|
140
|
-
calculatedCost: null,
|
|
141
|
-
officialCost: null,
|
|
142
|
-
tokens: null,
|
|
143
|
-
tokenBreakdown: null,
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
function dayKey(date: Date): string {
|
|
147
|
-
const year = date.getFullYear();
|
|
148
|
-
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
149
|
-
const day = String(date.getDate()).padStart(2, "0");
|
|
150
|
-
return `${year}-${month}-${day}`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Files modified before this can hold no entry that lands in "today", so the
|
|
154
|
-
// seed never parses them. A full day of slack absorbs timezone/rollover skew.
|
|
155
|
-
function seedCutoffMs(): number {
|
|
156
|
-
const d = new Date();
|
|
157
|
-
d.setHours(0, 0, 0, 0);
|
|
158
|
-
d.setDate(d.getDate() - 1);
|
|
159
|
-
return d.getTime();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function emptyBreakdown(): TokenBreakdown {
|
|
163
|
-
return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// [LAW:one-source-of-truth] The incremental fold of ONE append-only transcript
|
|
167
|
-
// file (a session's main transcript or one agent sidechain). `cursor` marks the
|
|
168
|
-
// bytes already folded; the sums run over every usage-bearing entry seen so far.
|
|
169
|
-
// The session aggregate is the MERGE of its files' folds (mergeFolds), so a file
|
|
170
|
-
// rewritten by /compact re-folds in isolation without disturbing the others.
|
|
171
|
-
interface FileFold {
|
|
172
|
-
cursor: TranscriptCursor;
|
|
173
|
-
entries: number; // usage-bearing entries folded (0 ⇒ empty session, all-null)
|
|
174
|
-
cost: number;
|
|
175
|
-
breakdown: TokenBreakdown;
|
|
176
|
-
days: Map<string, DayUsage>; // keys >= seed cutoff only (pruned on fold)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// [LAW:effects-at-boundaries] Pure fold of NEW entries onto a prior file fold.
|
|
180
|
-
// Returns a FRESH FileFold (prior is never mutated) so two concurrent refolds
|
|
181
|
-
// sharing one prior snapshot can't corrupt each other. `reset` (file rewritten)
|
|
182
|
-
// discards the prior sums and folds `entries` — the whole new file — from zero.
|
|
183
|
-
//
|
|
184
|
-
// [LAW:one-source-of-truth] Session cost and day cost use the SAME priced value:
|
|
185
|
-
// an entry lacking costUSD is priced once (PricingService) and that figure feeds
|
|
186
|
-
// BOTH the session total and its day bucket. This matches the old path exactly —
|
|
187
|
-
// it mutated entry.costUSD to the priced cost before bucketByDay read it — so
|
|
188
|
-
// `today` is not silently under-counted for un-costed entries.
|
|
189
|
-
async function foldFile(
|
|
190
|
-
prior: FileFold | undefined,
|
|
191
|
-
reset: boolean,
|
|
192
|
-
entries: readonly ParsedEntry[],
|
|
193
|
-
cursor: TranscriptCursor,
|
|
194
|
-
): Promise<FileFold> {
|
|
195
|
-
// dayKey strings sort lexically == chronologically, so "keep recent" is a
|
|
196
|
-
// string comparison against yesterday's key.
|
|
197
|
-
const keep = dayKey(new Date(seedCutoffMs()));
|
|
198
|
-
const base = prior && !reset ? prior : undefined;
|
|
199
|
-
let count = base?.entries ?? 0;
|
|
200
|
-
let cost = base?.cost ?? 0;
|
|
201
|
-
const breakdown = base ? { ...base.breakdown } : emptyBreakdown();
|
|
202
|
-
const days = new Map<string, DayUsage>();
|
|
203
|
-
if (base) {
|
|
204
|
-
for (const [k, v] of base.days) if (k >= keep) days.set(k, { ...v });
|
|
205
|
-
}
|
|
206
|
-
for (const entry of entries) {
|
|
207
|
-
const u = entry.message?.usage;
|
|
208
|
-
if (!u) continue;
|
|
209
|
-
count++;
|
|
210
|
-
const priced =
|
|
211
|
-
entry.costUSD ?? (await PricingService.calculateCostForEntry(entry.raw));
|
|
212
|
-
cost += priced;
|
|
213
|
-
breakdown.input += u.input_tokens || 0;
|
|
214
|
-
breakdown.output += u.output_tokens || 0;
|
|
215
|
-
breakdown.cacheCreation += u.cache_creation_input_tokens || 0;
|
|
216
|
-
breakdown.cacheRead += u.cache_read_input_tokens || 0;
|
|
217
|
-
const key = dayKey(new Date(entry.timestamp));
|
|
218
|
-
if (key < keep) continue;
|
|
219
|
-
const d = days.get(key) ?? { ...EMPTY_DAY };
|
|
220
|
-
// [LAW:one-source-of-truth] Day cost uses the PRICED value, same as session
|
|
221
|
-
// cost — the old path mutated entry.costUSD to the priced cost before
|
|
222
|
-
// bucketByDay read it, so an un-costed entry contributed its priced value to
|
|
223
|
-
// `today`, not 0. Using `priced` here preserves that (session and day agree).
|
|
224
|
-
d.cost += priced;
|
|
225
|
-
d.input += u.input_tokens || 0;
|
|
226
|
-
d.output += u.output_tokens || 0;
|
|
227
|
-
d.cacheCreation += u.cache_creation_input_tokens || 0;
|
|
228
|
-
d.cacheRead += u.cache_read_input_tokens || 0;
|
|
229
|
-
days.set(key, d);
|
|
230
|
-
}
|
|
231
|
-
return { cursor, entries: count, cost, breakdown, days };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// [LAW:effects-at-boundaries] Pure merge of a session's file folds into the
|
|
235
|
-
// derived record shape the read path serves. `entries === 0` across all files is
|
|
236
|
-
// the empty session (all-null SessionInfo, whose fields the payload drops) — the
|
|
237
|
-
// same distinction the old `entries.length === 0` arm carried.
|
|
238
|
-
function mergeFolds(files: ReadonlyMap<string, FileFold>): {
|
|
239
|
-
sessionInfo: SessionInfo;
|
|
240
|
-
days: Map<string, DayUsage>;
|
|
241
|
-
} {
|
|
242
|
-
let count = 0;
|
|
243
|
-
let cost = 0;
|
|
244
|
-
const bd = emptyBreakdown();
|
|
245
|
-
const days = new Map<string, DayUsage>();
|
|
246
|
-
for (const f of files.values()) {
|
|
247
|
-
count += f.entries;
|
|
248
|
-
cost += f.cost;
|
|
249
|
-
bd.input += f.breakdown.input;
|
|
250
|
-
bd.output += f.breakdown.output;
|
|
251
|
-
bd.cacheCreation += f.breakdown.cacheCreation;
|
|
252
|
-
bd.cacheRead += f.breakdown.cacheRead;
|
|
253
|
-
for (const [k, v] of f.days) {
|
|
254
|
-
const d = days.get(k) ?? { ...EMPTY_DAY };
|
|
255
|
-
d.cost += v.cost;
|
|
256
|
-
d.input += v.input;
|
|
257
|
-
d.output += v.output;
|
|
258
|
-
d.cacheCreation += v.cacheCreation;
|
|
259
|
-
d.cacheRead += v.cacheRead;
|
|
260
|
-
days.set(k, d);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
if (count === 0) return { sessionInfo: EMPTY_SESSION_INFO, days };
|
|
264
|
-
const tokens = bd.input + bd.output + bd.cacheCreation + bd.cacheRead;
|
|
265
|
-
return {
|
|
266
|
-
sessionInfo: {
|
|
267
|
-
cost,
|
|
268
|
-
calculatedCost: cost,
|
|
269
|
-
officialCost: null,
|
|
270
|
-
tokens,
|
|
271
|
-
tokenBreakdown: bd,
|
|
272
|
-
},
|
|
273
|
-
days,
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Bounded-concurrency fan-out for the seed: at most `limit` parses in flight.
|
|
278
|
-
async function mapPool<T>(
|
|
279
|
-
items: readonly T[],
|
|
280
|
-
limit: number,
|
|
281
|
-
fn: (item: T) => Promise<unknown>,
|
|
282
|
-
): Promise<void> {
|
|
283
|
-
let cursor = 0;
|
|
284
|
-
const worker = async (): Promise<void> => {
|
|
285
|
-
// The while-guard proves the index is in range; the `!` discharges
|
|
286
|
-
// noUncheckedIndexedAccess, it is not a defensive guard.
|
|
287
|
-
while (cursor < items.length) {
|
|
288
|
-
const item = items[cursor++]!;
|
|
289
|
-
await fn(item);
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
const width = Math.min(limit, items.length);
|
|
293
|
-
await Promise.all(Array.from({ length: width }, worker));
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
export class SessionUsageStore {
|
|
297
|
-
private readonly entries = new Map<string, SessionRecord>();
|
|
298
|
-
// [LAW:one-source-of-truth] Coalesces concurrent MISSES for the same
|
|
299
|
-
// (session, observed mtime) onto one parse; cleared on settle (a coalescer,
|
|
300
|
-
// not a cache — the records map IS the cache).
|
|
301
|
-
private readonly flight = new SingleFlight();
|
|
302
|
-
// [LAW:dataflow-not-control-flow] Per-day memo of the one seed scan. Unlike
|
|
303
|
-
// SingleFlight this RETAINS the resolved promise for the day, so after the
|
|
304
|
-
// first seed completes every later read awaits an already-settled promise —
|
|
305
|
-
// zero rescan. A rejected seed is dropped so the next read retries.
|
|
306
|
-
private readonly seeded = new Map<string, Promise<void>>();
|
|
307
|
-
// [LAW:one-source-of-truth] The recent tok/s observations per session, a
|
|
308
|
-
// bounded ring (oldest→newest). tok/s is a derivative of the SAME token totals
|
|
309
|
-
// the records map already owns; the baseline (prior counts + time) is the ring's
|
|
310
|
-
// last element, not a parallel counter. The burn-rate sparkline folds over the
|
|
311
|
-
// whole ring; tok/s folds over its final pair. One call to observeSpeed appends;
|
|
312
|
-
// the pure delta math is render-payload's.
|
|
313
|
-
private readonly speedRings = new Map<string, SpeedSample[]>();
|
|
314
|
-
// [LAW:no-ambient-temporal-coupling] Explicit owner of observe/commit ordering
|
|
315
|
-
// for the speed sample. Concurrent renders observing the SAME transcript state
|
|
316
|
-
// (key = `${sessionId}:${mtime}`) share ONE observation and commit the baseline
|
|
317
|
-
// exactly once — so they return the same prev+cur and render identical,
|
|
318
|
-
// deterministic throughput instead of the second clobbering the first.
|
|
319
|
-
private readonly speedFlight = new SingleFlight();
|
|
320
|
-
private readonly maxEntries: number;
|
|
321
|
-
private readonly staleAgeMs: number;
|
|
322
|
-
private hits = 0;
|
|
323
|
-
private misses = 0;
|
|
324
|
-
private sweeps = 0;
|
|
325
|
-
private seeds = 0;
|
|
326
|
-
private sweepTimer: NodeJS.Timeout | null = null;
|
|
327
|
-
|
|
328
|
-
constructor(
|
|
329
|
-
opts: {
|
|
330
|
-
maxEntries?: number;
|
|
331
|
-
staleAgeMs?: number;
|
|
332
|
-
sweepIntervalMs?: number;
|
|
333
|
-
} = {},
|
|
334
|
-
) {
|
|
335
|
-
this.maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
336
|
-
this.staleAgeMs = opts.staleAgeMs ?? DEFAULT_STALE_AGE_MS;
|
|
337
|
-
const interval = opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
338
|
-
if (interval > 0) {
|
|
339
|
-
this.sweepTimer = setInterval(() => this.sweepStale(), interval);
|
|
340
|
-
this.sweepTimer.unref();
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
getStats(): {
|
|
345
|
-
size: number;
|
|
346
|
-
hits: number;
|
|
347
|
-
misses: number;
|
|
348
|
-
sweeps: number;
|
|
349
|
-
seeds: number;
|
|
350
|
-
} {
|
|
351
|
-
return {
|
|
352
|
-
size: this.entries.size,
|
|
353
|
-
hits: this.hits,
|
|
354
|
-
misses: this.misses,
|
|
355
|
-
sweeps: this.sweeps,
|
|
356
|
-
seeds: this.seeds,
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// The `session` projection: whole-session totals for the active session.
|
|
361
|
-
// [LAW:no-silent-failure] A failed transcript parse flows out as `failed`
|
|
362
|
-
// (the payload boundary logs it); an unknown/empty session is the all-null
|
|
363
|
-
// SessionInfo whose fields the boundary drops per-field — top-level
|
|
364
|
-
// `absent` is reserved for ingest, since the native officialCost overlay
|
|
365
|
-
// applies even with no record.
|
|
366
|
-
async getUsageInfo(
|
|
367
|
-
sessionId: string,
|
|
368
|
-
hookData?: ClaudeHookData,
|
|
369
|
-
): Promise<Outcome<UsageInfo>> {
|
|
370
|
-
const record = await this.ingest(sessionId, hookData?.transcript_path);
|
|
371
|
-
if (record.kind === "failed") return record;
|
|
372
|
-
const base =
|
|
373
|
-
record.kind === "ok" ? record.value.sessionInfo : EMPTY_SESSION_INFO;
|
|
374
|
-
// [LAW:one-source-of-truth] Claude's reported total_cost_usd is the
|
|
375
|
-
// authoritative cost of the active session. base.cost (transcript entries
|
|
376
|
-
// priced by PricingService against a hand-maintained rate table) is a
|
|
377
|
-
// reimplementation — kept ONLY as a fallback for clients that omit cost,
|
|
378
|
-
// and to feed the cross-session `today` total, which has no native source
|
|
379
|
-
// (past sessions expose only their transcripts, not a live cost figure).
|
|
380
|
-
// The native cost is overlaid at READ time, not frozen into the mtime-keyed
|
|
381
|
-
// record, because it changes every render while the transcript total moves
|
|
382
|
-
// only when the file does.
|
|
383
|
-
const officialCost = hookData?.cost?.total_cost_usd ?? null;
|
|
384
|
-
return ok({
|
|
385
|
-
session: { ...base, cost: officialCost ?? base.cost, officialCost },
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// The `today` projection: cross-session sum of every record's today bucket.
|
|
390
|
-
// [LAW:no-silent-failure] A failed seed or a failed active-session ingest
|
|
391
|
-
// makes the whole projection `failed` — a total silently missing today's
|
|
392
|
-
// main work would be a confident wrong number, worse than a loud gap.
|
|
393
|
-
async getTodayInfo(hookData?: ClaudeHookData): Promise<Outcome<TodayInfo>> {
|
|
394
|
-
const today = dayKey(new Date());
|
|
395
|
-
try {
|
|
396
|
-
await this.ensureSeeded(today);
|
|
397
|
-
} catch (error) {
|
|
398
|
-
return failed(
|
|
399
|
-
`usage seed: ${error instanceof Error ? error.message : String(error)}`,
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
// Keep the active session fresh: the seed runs once per day, so after it
|
|
403
|
-
// every render's freshness for the active session comes from here (a hit
|
|
404
|
-
// when its transcript is unchanged). Empty sessionId no-ops in ingest.
|
|
405
|
-
const active = await this.ingest(
|
|
406
|
-
hookData?.session_id ?? "",
|
|
407
|
-
hookData?.transcript_path,
|
|
408
|
-
);
|
|
409
|
-
if (active.kind === "failed") return active;
|
|
410
|
-
|
|
411
|
-
const total: DayUsage = { ...EMPTY_DAY };
|
|
412
|
-
let any = false;
|
|
413
|
-
for (const record of this.entries.values()) {
|
|
414
|
-
any = any || record.days.has(today);
|
|
415
|
-
const d = record.days.get(today) ?? EMPTY_DAY;
|
|
416
|
-
total.cost += d.cost;
|
|
417
|
-
total.input += d.input;
|
|
418
|
-
total.output += d.output;
|
|
419
|
-
total.cacheCreation += d.cacheCreation;
|
|
420
|
-
total.cacheRead += d.cacheRead;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
if (!any) return ABSENT;
|
|
424
|
-
return ok({
|
|
425
|
-
cost: total.cost,
|
|
426
|
-
tokens:
|
|
427
|
-
total.input + total.output + total.cacheCreation + total.cacheRead,
|
|
428
|
-
tokenBreakdown: {
|
|
429
|
-
input: total.input,
|
|
430
|
-
output: total.output,
|
|
431
|
-
cacheCreation: total.cacheCreation,
|
|
432
|
-
cacheRead: total.cacheRead,
|
|
433
|
-
},
|
|
434
|
-
date: today,
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Take one tok/s observation of the active session: ingest its current
|
|
439
|
-
// cumulative counts, return the prior sample alongside, and record this one as
|
|
440
|
-
// the new baseline. [LAW:no-silent-failure] A failed transcript parse flows out
|
|
441
|
-
// as `failed` (the boundary logs it and the segment reads "—"); an unknown
|
|
442
|
-
// session yields a zero-count sample, so a first-ever render establishes a
|
|
443
|
-
// baseline without fabricating a rate. `nowMs` is the caller's single-enforcer
|
|
444
|
-
// clock instant — the store never reads the clock for tok/s timing itself.
|
|
445
|
-
async observeSpeed(
|
|
446
|
-
sessionId: string,
|
|
447
|
-
transcriptPath: string | undefined,
|
|
448
|
-
nowMs: number,
|
|
449
|
-
): Promise<Outcome<SpeedObservation>> {
|
|
450
|
-
// [LAW:no-ambient-temporal-coupling] Key the observation by the same
|
|
451
|
-
// (session, mtime) tuple ingest uses, so concurrent renders at one transcript
|
|
452
|
-
// state coalesce onto a single observe-and-commit — the read of `prev` and
|
|
453
|
-
// the write of `cur` happen exactly once for that state, with the flight as
|
|
454
|
-
// the sole owner of ordering. A distinct mtime is a genuinely new sample and
|
|
455
|
-
// gets its own key.
|
|
456
|
-
const mtime = statMtimeMs(transcriptPath);
|
|
457
|
-
return this.speedFlight.run(`${sessionId}:${mtime}`, async () => {
|
|
458
|
-
const record = await this.ingest(sessionId, transcriptPath, mtime);
|
|
459
|
-
if (record.kind === "failed") return record;
|
|
460
|
-
const breakdown =
|
|
461
|
-
record.kind === "ok" ? record.value.sessionInfo.tokenBreakdown : null;
|
|
462
|
-
const cur = speedSampleOf(breakdown, nowMs);
|
|
463
|
-
const ring = this.speedRings.get(sessionId) ?? [];
|
|
464
|
-
// [LAW:no-ambient-temporal-coupling] Observation time (atMs, the render
|
|
465
|
-
// clock) owns ring order — NOT ingest-completion order. Two concurrent
|
|
466
|
-
// observes with different mtimes don't coalesce in speedFlight and each
|
|
467
|
-
// awaits ingest before this mutation, so a plain append would record
|
|
468
|
-
// samples in whichever-ingest-settled-first order and invert oldest→newest.
|
|
469
|
-
// prev is the latest sample strictly before this observation; the post-
|
|
470
|
-
// insert sort by atMs makes the ring's order independent of completion
|
|
471
|
-
// order. (get→insert→set is synchronous after the await, so each resumed
|
|
472
|
-
// continuation mutates atomically — no lost update.)
|
|
473
|
-
let prev: SpeedSample | undefined;
|
|
474
|
-
for (const s of ring) {
|
|
475
|
-
if (s.atMs < cur.atMs && (prev === undefined || s.atMs > prev.atMs)) {
|
|
476
|
-
prev = s;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
ring.push(cur);
|
|
480
|
-
ring.sort((a, b) => a.atMs - b.atMs);
|
|
481
|
-
// Drop oldest (smallest atMs ⇒ ring[0]) beyond the cap — a tail window,
|
|
482
|
-
// not an archive.
|
|
483
|
-
if (ring.length > SPEED_RING_CAPACITY) ring.shift();
|
|
484
|
-
this.speedRings.set(sessionId, ring);
|
|
485
|
-
return ok({
|
|
486
|
-
...(prev !== undefined && { prev }),
|
|
487
|
-
cur,
|
|
488
|
-
samples: [...ring],
|
|
489
|
-
});
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// mtime-gated, coalesced re-parse of ONE session. `ok` is its record,
|
|
494
|
-
// `absent` is an unknown empty session (or no sessionId), `failed` is a
|
|
495
|
-
// transcript that exists but couldn't be parsed — NOT cached (only ok
|
|
496
|
-
// records enter the map; the next render retries, same rule as the git
|
|
497
|
-
// cache). This is the single write-path into the records map.
|
|
498
|
-
private async ingest(
|
|
499
|
-
sessionId: string,
|
|
500
|
-
transcriptPath: string | undefined,
|
|
501
|
-
knownMtime?: number,
|
|
502
|
-
): Promise<Outcome<SessionRecord>> {
|
|
503
|
-
if (!sessionId) return ABSENT;
|
|
504
|
-
|
|
505
|
-
const mtime = knownMtime ?? statMtimeMs(transcriptPath);
|
|
506
|
-
const existing = this.entries.get(sessionId);
|
|
507
|
-
if (existing && mtime !== 0 && existing.transcriptMtime === mtime) {
|
|
508
|
-
existing.lastSeenAt = Date.now();
|
|
509
|
-
this.entries.delete(sessionId);
|
|
510
|
-
this.entries.set(sessionId, existing);
|
|
511
|
-
this.hits++;
|
|
512
|
-
return ok(existing);
|
|
513
|
-
}
|
|
514
|
-
// No path to read fresh content — preserve the last-known record rather
|
|
515
|
-
// than blank it. (The session will refresh when its transcript reappears.)
|
|
516
|
-
if (!transcriptPath) return existing ? ok(existing) : ABSENT;
|
|
517
|
-
|
|
518
|
-
this.misses++;
|
|
519
|
-
// [LAW:no-ambient-temporal-coupling] The flight thunk snapshots the prior
|
|
520
|
-
// record when it RUNS (first caller); coalesced callers share that one
|
|
521
|
-
// refold. Two concurrent DIFFERENT-mtime refolds each read the same prior and
|
|
522
|
-
// last-writer-wins. This is safe — and specifically NOT a double-count —
|
|
523
|
-
// because `refold` writes the byte cursor and the fold as ONE atomic pair
|
|
524
|
-
// (both from a single refold result), and the fold always covers exactly
|
|
525
|
-
// [0, cursor). A losing writer's fold is discarded WITH its cursor, not left
|
|
526
|
-
// beside a stale one. So even when last-writer-wins rewinds the stored cursor,
|
|
527
|
-
// it rewinds the paired fold with it; the next refold folds [cursor, end) onto
|
|
528
|
-
// a fold that lacks those bytes — each byte folded once. `foldFile` is pure
|
|
529
|
-
// (fresh FileFold, prior never mutated), so the shared prior can't corrupt.
|
|
530
|
-
// Pinned by the concurrency test in test/transcript-incremental.test.ts.
|
|
531
|
-
// The prior is read when the thunk RUNS. If evictIfNeeded dropped this
|
|
532
|
-
// session between thunk creation and run, prior is undefined and refold does
|
|
533
|
-
// one whole-file re-fold — still correct, just non-incremental that once.
|
|
534
|
-
// (Eviction only fires after a successful write, so this can bite only on a
|
|
535
|
-
// first miss under cap pressure.)
|
|
536
|
-
// [LAW:no-silent-failure] ingest MUST stay total — every caller reads
|
|
537
|
-
// `.kind`, so a rejected promise (e.g. PricingService.calculateCostForEntry
|
|
538
|
-
// throwing on a bad rate table or network hiccup inside foldFile) would
|
|
539
|
-
// escape the Outcome contract entirely. The deleted getSessionUsageFromPath
|
|
540
|
-
// wrapped this; keep that guarantee by mapping any throw to `failed`.
|
|
541
|
-
let outcome: Outcome<{
|
|
542
|
-
files: Map<string, FileFold>;
|
|
543
|
-
sessionInfo: SessionInfo;
|
|
544
|
-
days: Map<string, DayUsage>;
|
|
545
|
-
mainMtime: number;
|
|
546
|
-
}>;
|
|
547
|
-
try {
|
|
548
|
-
outcome = await this.flight.run(`${sessionId}:${mtime}`, () =>
|
|
549
|
-
this.refold(sessionId, transcriptPath, this.entries.get(sessionId)),
|
|
550
|
-
);
|
|
551
|
-
} catch (error) {
|
|
552
|
-
return failed(
|
|
553
|
-
`usage refold (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
|
|
554
|
-
);
|
|
555
|
-
}
|
|
556
|
-
if (outcome.kind !== "ok") return outcome;
|
|
557
|
-
const record: SessionRecord = {
|
|
558
|
-
files: outcome.value.files,
|
|
559
|
-
sessionInfo: outcome.value.sessionInfo,
|
|
560
|
-
days: outcome.value.days,
|
|
561
|
-
transcriptMtime: outcome.value.mainMtime,
|
|
562
|
-
transcriptPath,
|
|
563
|
-
lastSeenAt: Date.now(),
|
|
564
|
-
};
|
|
565
|
-
this.entries.delete(sessionId);
|
|
566
|
-
this.entries.set(sessionId, record);
|
|
567
|
-
this.evictIfNeeded();
|
|
568
|
-
return ok(record);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// [LAW:dataflow-not-control-flow] Re-fold ONLY the bytes appended since the
|
|
572
|
-
// prior record — the transcript (and each agent sidechain) is append-only, so
|
|
573
|
-
// per-render work is O(new bytes), not O(file). The whole-file case (cold
|
|
574
|
-
// record, or a first-ever read) is the same path with an empty prior: the
|
|
575
|
-
// reader yields the whole file from offset 0. A `failed` read on any file
|
|
576
|
-
// fails the fold (the boundary logs it); an `absent` file (not yet written /
|
|
577
|
-
// agent removed) keeps its prior fold and contributes nothing new.
|
|
578
|
-
private async refold(
|
|
579
|
-
sessionId: string,
|
|
580
|
-
transcriptPath: string,
|
|
581
|
-
prior: SessionRecord | undefined,
|
|
582
|
-
): Promise<
|
|
583
|
-
Outcome<{
|
|
584
|
-
files: Map<string, FileFold>;
|
|
585
|
-
sessionInfo: SessionInfo;
|
|
586
|
-
days: Map<string, DayUsage>;
|
|
587
|
-
mainMtime: number;
|
|
588
|
-
}>
|
|
589
|
-
> {
|
|
590
|
-
// File set for this fold: the main transcript plus the session's current
|
|
591
|
-
// agent sidechains. Files no longer in the set (a removed sidechain) drop
|
|
592
|
-
// out of `newFiles`, so a rewritten/renamed file cannot linger in the merge.
|
|
593
|
-
// Pass the prior file set so already-verified agent sidechains skip their
|
|
594
|
-
// first-line re-read — per-render agent discovery is O(new sidechains).
|
|
595
|
-
const agentPaths = await findAgentTranscripts(
|
|
596
|
-
sessionId,
|
|
597
|
-
dirname(transcriptPath),
|
|
598
|
-
prior === undefined ? undefined : new Set(prior.files.keys()),
|
|
599
|
-
);
|
|
600
|
-
const newFiles = new Map<string, FileFold>();
|
|
601
|
-
let mainMtime = 0;
|
|
602
|
-
for (const filePath of [transcriptPath, ...agentPaths]) {
|
|
603
|
-
const priorFold = prior?.files.get(filePath);
|
|
604
|
-
const read = await readAppendedEntries(filePath, priorFold?.cursor);
|
|
605
|
-
if (read.kind === "failed") return read;
|
|
606
|
-
if (read.kind === "absent") {
|
|
607
|
-
// The file has no bytes yet (fresh main) or vanished (removed agent
|
|
608
|
-
// sidechain). Carry a prior fold forward; otherwise it contributes
|
|
609
|
-
// nothing. The main transcript's absence leaves mainMtime 0, so the next
|
|
610
|
-
// render's mtime gate re-attempts rather than caching an empty read.
|
|
611
|
-
if (priorFold) newFiles.set(filePath, priorFold);
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
const { entries, cursor, reset } = read.value;
|
|
615
|
-
newFiles.set(filePath, await foldFile(priorFold, reset, entries, cursor));
|
|
616
|
-
if (filePath === transcriptPath) mainMtime = cursor.mtimeMs;
|
|
617
|
-
}
|
|
618
|
-
return ok({ files: newFiles, ...mergeFolds(newFiles), mainMtime });
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
private ensureSeeded(day: string): Promise<void> {
|
|
622
|
-
const existing = this.seeded.get(day);
|
|
623
|
-
if (existing) return existing;
|
|
624
|
-
// Drop other days' memos so the map holds at most the current day.
|
|
625
|
-
this.seeded.clear();
|
|
626
|
-
const promise = this.seed(day);
|
|
627
|
-
this.seeded.set(day, promise);
|
|
628
|
-
promise.catch(() => {
|
|
629
|
-
if (this.seeded.get(day) === promise) this.seeded.delete(day);
|
|
630
|
-
});
|
|
631
|
-
return promise;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// The one and only whole-tree scan: lazily, once per day, ingest every
|
|
635
|
-
// session whose transcript was touched recently enough to hold a today entry.
|
|
636
|
-
private async seed(_day: string): Promise<void> {
|
|
637
|
-
const cutoff = seedCutoffMs();
|
|
638
|
-
const projectPaths = await findProjectPaths(getClaudePaths());
|
|
639
|
-
const candidates: Array<{
|
|
640
|
-
sessionId: string;
|
|
641
|
-
path: string;
|
|
642
|
-
mtime: number;
|
|
643
|
-
}> = [];
|
|
644
|
-
|
|
645
|
-
for (const dir of projectPaths) {
|
|
646
|
-
let files: string[];
|
|
647
|
-
try {
|
|
648
|
-
files = await gatedReaddir(dir);
|
|
649
|
-
} catch {
|
|
650
|
-
continue;
|
|
651
|
-
}
|
|
652
|
-
for (const file of files) {
|
|
653
|
-
if (!file.endsWith(".jsonl")) continue;
|
|
654
|
-
const filePath = join(dir, file);
|
|
655
|
-
let mtime: number;
|
|
656
|
-
try {
|
|
657
|
-
mtime = (await gatedStat(filePath)).mtimeMs;
|
|
658
|
-
} catch {
|
|
659
|
-
continue;
|
|
660
|
-
}
|
|
661
|
-
if (mtime < cutoff) continue;
|
|
662
|
-
candidates.push({
|
|
663
|
-
sessionId: file.slice(0, -".jsonl".length),
|
|
664
|
-
path: filePath,
|
|
665
|
-
mtime,
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// [LAW:no-silent-failure] The seed is its own effect edge (timer/lazy
|
|
671
|
-
// driven, no render boundary to carry the outcome to), so its per-session
|
|
672
|
-
// parse failures are logged here.
|
|
673
|
-
await mapPool(candidates, SEED_CONCURRENCY, async (c) => {
|
|
674
|
-
const outcome = await this.ingest(c.sessionId, c.path, c.mtime);
|
|
675
|
-
if (outcome.kind === "failed") {
|
|
676
|
-
dlog("warn", `usageStore seed: ${outcome.reason}`);
|
|
677
|
-
}
|
|
678
|
-
});
|
|
679
|
-
this.seeds++;
|
|
680
|
-
dlog("info", `usageStore seed sessions=${candidates.length}`);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// Public for tests; called periodically from the timer.
|
|
684
|
-
sweepStale(): number {
|
|
685
|
-
const now = Date.now();
|
|
686
|
-
let dropped = 0;
|
|
687
|
-
for (const [sid, record] of this.entries) {
|
|
688
|
-
if (now - record.lastSeenAt > this.staleAgeMs) {
|
|
689
|
-
this.entries.delete(sid);
|
|
690
|
-
this.speedRings.delete(sid);
|
|
691
|
-
dropped++;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
if (dropped > 0) {
|
|
695
|
-
this.sweeps++;
|
|
696
|
-
dlog("info", `usageStore sweep dropped=${dropped}`);
|
|
697
|
-
}
|
|
698
|
-
return dropped;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
private evictIfNeeded(): void {
|
|
702
|
-
while (this.entries.size > this.maxEntries) {
|
|
703
|
-
const oldest = this.entries.keys().next().value;
|
|
704
|
-
if (oldest === undefined) break;
|
|
705
|
-
this.entries.delete(oldest);
|
|
706
|
-
this.speedRings.delete(oldest);
|
|
707
|
-
dlog("info", `usageStore evict ${oldest}`);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
close(): void {
|
|
712
|
-
if (this.sweepTimer) {
|
|
713
|
-
clearInterval(this.sweepTimer);
|
|
714
|
-
this.sweepTimer = null;
|
|
715
|
-
}
|
|
716
|
-
this.entries.clear();
|
|
717
|
-
this.seeded.clear();
|
|
718
|
-
this.speedRings.clear();
|
|
719
|
-
}
|
|
720
|
-
}
|