@promptctl/cc-candybar 1.17.4 → 1.18.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 +76 -76
- package/package.json +9 -5
- package/src/config/default-dsl-config.ts +39 -17
- package/src/daemon/cache/session-usage-store.ts +195 -38
- package/src/segments/metrics.ts +122 -36
- package/src/segments/session.ts +4 -169
- package/src/utils/claude.ts +79 -12
- package/src/utils/transcript-fs.ts +118 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"test:coverage": "jest --coverage",
|
|
25
25
|
"demo": "tsx src/demo/dsl.ts",
|
|
26
26
|
"benchmark:timing": "scripts/benchmark_timing.sh",
|
|
27
|
+
"preloadtest": "pnpm build",
|
|
28
|
+
"loadtest": "node --import tsx scripts/daemon-load-harness.ts",
|
|
29
|
+
"preloadtest:gate": "pnpm build",
|
|
30
|
+
"loadtest:gate": "node --import tsx scripts/daemon-load-harness.ts --sessions 25 --interval 300 --duration 20 --churn --transcript-lines 5000",
|
|
27
31
|
"check:protocol": "node scripts/check-protocol.mjs",
|
|
28
32
|
"gen:schema": "tsx scripts/gen-schema.ts",
|
|
29
33
|
"check:schema": "tsx scripts/check-schema.ts",
|
|
@@ -91,10 +95,10 @@
|
|
|
91
95
|
"mobx": "^6.15.0"
|
|
92
96
|
},
|
|
93
97
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
98
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.18.0",
|
|
99
|
+
"@promptctl/cc-candybar-darwin-x64": "1.18.0",
|
|
100
|
+
"@promptctl/cc-candybar-linux-x64": "1.18.0",
|
|
101
|
+
"@promptctl/cc-candybar-linux-arm64": "1.18.0"
|
|
98
102
|
},
|
|
99
103
|
"pnpm": {
|
|
100
104
|
"supportedArchitectures": {
|
|
@@ -627,15 +627,18 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
627
627
|
fg: "foreground",
|
|
628
628
|
when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
|
|
629
629
|
},
|
|
630
|
-
// Quick-action tray —
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
630
|
+
// Quick-action tray — the default bar's interactivity: copy the session id,
|
|
631
|
+
// open the project dir / transcript (this session's jsonl) in the editor.
|
|
632
|
+
// (copyDir — copy the cwd — stays declared as an action below for users who
|
|
633
|
+
// want a fourth glyph; it is simply not in the default tray.)
|
|
634
|
+
// [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
|
|
635
|
+
// (below) is the BEHAVIOR; the action name is the seam between them. Re-glyph
|
|
636
|
+
// without touching behavior; re-target without touching this template. Each
|
|
637
|
+
// `{{ action … }}` emits one OSC-8 clickable region whose URL the wire codec
|
|
638
|
+
// owns end-to-end.
|
|
636
639
|
toolbar: {
|
|
637
640
|
template:
|
|
638
|
-
'{{ action "copySession" "⎘ id" }}
|
|
641
|
+
'{{ action "copySession" "⎘ id" }}' +
|
|
639
642
|
' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}',
|
|
640
643
|
bg: "surface",
|
|
641
644
|
fg: "foreground",
|
|
@@ -808,11 +811,24 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
808
811
|
// Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
|
|
809
812
|
// requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
|
|
810
813
|
// the loader's authoring surface for user JSON, not this typed literal).
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
814
|
+
//
|
|
815
|
+
// Two rows stacked by the vertical container: an IDENTITY + ACTIONS row
|
|
816
|
+
// (where am I / what can I do here — the directory, the verbose `gitaculous`
|
|
817
|
+
// line (repo, sha, working-tree, upstream, stash, time-since-commit), then the
|
|
818
|
+
// quick-action tray: copy session id, open project / transcript in the editor)
|
|
819
|
+
// over a STATUS row (what's happening now — model, context-window fill,
|
|
820
|
+
// prompt-cache warmth, and the 5h / 7d rate-limit quotas). The tray sits on
|
|
821
|
+
// the identity row because its actions are workspace-scoped (this session,
|
|
822
|
+
// this project), not usage metrics. Each row zips its segments through the
|
|
823
|
+
// powerline joiner; `\n` separates the rows.
|
|
824
|
+
//
|
|
825
|
+
// [LAW:dataflow-not-control-flow] Every status segment is when-gated on its
|
|
826
|
+
// own signal (no repo → the identity row is just the directory + tray; no
|
|
827
|
+
// active rate-limit window → block/weekly drop; no cache activity → cacheTimer
|
|
828
|
+
// drops). A row therefore only ever shows the segments that have real data —
|
|
829
|
+
// the layout is chosen by the data, not by branches — so the default never
|
|
830
|
+
// paints an empty or placeholder cell. The directory and the tray have no
|
|
831
|
+
// `when`, so row 1 always anchors the bar.
|
|
816
832
|
root: {
|
|
817
833
|
kind: "container",
|
|
818
834
|
direction: "vertical",
|
|
@@ -822,13 +838,19 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
822
838
|
direction: "horizontal",
|
|
823
839
|
children: [
|
|
824
840
|
{ kind: "segment", name: "directory" },
|
|
825
|
-
{ kind: "segment", name: "
|
|
841
|
+
{ kind: "segment", name: "gitaculous" },
|
|
842
|
+
{ kind: "segment", name: "toolbar" },
|
|
843
|
+
],
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
kind: "container",
|
|
847
|
+
direction: "horizontal",
|
|
848
|
+
children: [
|
|
826
849
|
{ kind: "segment", name: "model" },
|
|
827
|
-
{ kind: "segment", name: "session" },
|
|
828
|
-
{ kind: "segment", name: "today" },
|
|
829
850
|
{ kind: "segment", name: "context" },
|
|
830
|
-
{ kind: "segment", name: "
|
|
831
|
-
{ kind: "segment", name: "
|
|
851
|
+
{ kind: "segment", name: "cacheTimer" },
|
|
852
|
+
{ kind: "segment", name: "block" },
|
|
853
|
+
{ kind: "segment", name: "weekly" },
|
|
832
854
|
],
|
|
833
855
|
},
|
|
834
856
|
],
|
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
3
2
|
|
|
4
3
|
import {
|
|
5
|
-
SessionProvider,
|
|
6
4
|
type SessionInfo,
|
|
7
|
-
type SessionUsage,
|
|
8
5
|
type UsageInfo,
|
|
9
6
|
type TokenBreakdown,
|
|
10
7
|
} from "../../segments/session";
|
|
8
|
+
import { PricingService } from "../../segments/pricing";
|
|
11
9
|
import {
|
|
12
10
|
getClaudePaths,
|
|
13
11
|
findProjectPaths,
|
|
12
|
+
findAgentTranscripts,
|
|
13
|
+
readAppendedEntries,
|
|
14
14
|
type ClaudeHookData,
|
|
15
|
+
type ParsedEntry,
|
|
16
|
+
type TranscriptCursor,
|
|
15
17
|
} from "../../utils/claude";
|
|
16
18
|
// [LAW:single-enforcer] The once-per-day seed's directory walk shares the same
|
|
17
19
|
// in-flight-I/O budget (gn4.2) as every other transcript scan.
|
|
18
20
|
import {
|
|
19
21
|
readdir as gatedReaddir,
|
|
20
22
|
stat as gatedStat,
|
|
23
|
+
statMtimeMs,
|
|
21
24
|
} from "../../utils/transcript-fs";
|
|
22
25
|
import { SingleFlight } from "../../utils/single-flight";
|
|
23
26
|
import { ABSENT, failed, ok, type Outcome } from "../../utils/outcome";
|
|
@@ -105,6 +108,10 @@ interface DayUsage {
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
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>;
|
|
108
115
|
sessionInfo: SessionInfo;
|
|
109
116
|
days: Map<string, DayUsage>;
|
|
110
117
|
transcriptMtime: number;
|
|
@@ -152,33 +159,119 @@ function seedCutoffMs(): number {
|
|
|
152
159
|
return d.getTime();
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
function
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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)
|
|
162
177
|
}
|
|
163
178
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
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.
|
|
167
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();
|
|
168
202
|
const days = new Map<string, DayUsage>();
|
|
169
|
-
|
|
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;
|
|
170
217
|
const key = dayKey(new Date(entry.timestamp));
|
|
171
218
|
if (key < keep) continue;
|
|
172
219
|
const d = days.get(key) ?? { ...EMPTY_DAY };
|
|
173
|
-
|
|
174
|
-
|
|
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;
|
|
175
225
|
d.input += u.input_tokens || 0;
|
|
176
226
|
d.output += u.output_tokens || 0;
|
|
177
227
|
d.cacheCreation += u.cache_creation_input_tokens || 0;
|
|
178
228
|
d.cacheRead += u.cache_read_input_tokens || 0;
|
|
179
229
|
days.set(key, d);
|
|
180
230
|
}
|
|
181
|
-
return days;
|
|
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
|
+
};
|
|
182
275
|
}
|
|
183
276
|
|
|
184
277
|
// Bounded-concurrency fan-out for the seed: at most `limit` parses in flight.
|
|
@@ -201,7 +294,6 @@ async function mapPool<T>(
|
|
|
201
294
|
}
|
|
202
295
|
|
|
203
296
|
export class SessionUsageStore {
|
|
204
|
-
private readonly sessions = new SessionProvider();
|
|
205
297
|
private readonly entries = new Map<string, SessionRecord>();
|
|
206
298
|
// [LAW:one-source-of-truth] Coalesces concurrent MISSES for the same
|
|
207
299
|
// (session, observed mtime) onto one parse; cleared on settle (a coalescer,
|
|
@@ -424,15 +516,49 @@ export class SessionUsageStore {
|
|
|
424
516
|
if (!transcriptPath) return existing ? ok(existing) : ABSENT;
|
|
425
517
|
|
|
426
518
|
this.misses++;
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
//
|
|
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;
|
|
433
557
|
const record: SessionRecord = {
|
|
434
|
-
|
|
435
|
-
|
|
558
|
+
files: outcome.value.files,
|
|
559
|
+
sessionInfo: outcome.value.sessionInfo,
|
|
560
|
+
days: outcome.value.days,
|
|
561
|
+
transcriptMtime: outcome.value.mainMtime,
|
|
436
562
|
transcriptPath,
|
|
437
563
|
lastSeenAt: Date.now(),
|
|
438
564
|
};
|
|
@@ -442,23 +568,54 @@ export class SessionUsageStore {
|
|
|
442
568
|
return ok(record);
|
|
443
569
|
}
|
|
444
570
|
|
|
445
|
-
|
|
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(
|
|
446
579
|
sessionId: string,
|
|
447
580
|
transcriptPath: string,
|
|
581
|
+
prior: SessionRecord | undefined,
|
|
448
582
|
): Promise<
|
|
449
|
-
Outcome<{
|
|
583
|
+
Outcome<{
|
|
584
|
+
files: Map<string, FileFold>;
|
|
585
|
+
sessionInfo: SessionInfo;
|
|
586
|
+
days: Map<string, DayUsage>;
|
|
587
|
+
mainMtime: number;
|
|
588
|
+
}>
|
|
450
589
|
> {
|
|
451
|
-
|
|
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(
|
|
452
596
|
sessionId,
|
|
453
|
-
transcriptPath,
|
|
597
|
+
dirname(transcriptPath),
|
|
598
|
+
prior === undefined ? undefined : new Set(prior.files.keys()),
|
|
454
599
|
);
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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 });
|
|
462
619
|
}
|
|
463
620
|
|
|
464
621
|
private ensureSeeded(day: string): Promise<void> {
|
package/src/segments/metrics.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ClaudeHookData, ParsedEntry } from "../utils/claude";
|
|
2
2
|
|
|
3
|
-
// [LAW:one-source-of-truth] Metrics reads the transcript through the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
3
|
+
// [LAW:one-source-of-truth] Metrics reads the transcript through the SAME
|
|
4
|
+
// incremental append reader the usage store uses, folding only the bytes added
|
|
5
|
+
// since last render into a running message count + a bounded recent-entry ring —
|
|
6
|
+
// never a whole-file re-parse. Before this, metrics re-parsed the entire growing
|
|
7
|
+
// transcript every render (free only while it piggybacked the store's cache hit);
|
|
8
|
+
// once the store went incremental, a full re-parse here would have re-inherited
|
|
9
|
+
// the very stall the store fix removed. Now both consumers are O(new bytes).
|
|
10
|
+
import { readAppendedEntries, type TranscriptCursor } from "../utils/claude";
|
|
11
|
+
import { statMtimeMs } from "../utils/transcript-fs";
|
|
8
12
|
import { debug } from "../utils/logger";
|
|
9
13
|
import { ABSENT, failed, ok, type Outcome } from "../utils/outcome";
|
|
10
14
|
|
|
@@ -21,6 +25,25 @@ export interface MetricsInfo {
|
|
|
21
25
|
linesRemoved: number;
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
// lastResponseTime only inspects the most recent turns for a user→assistant
|
|
29
|
+
// pair; a bounded tail of non-sidechain entries is all it needs. The ring caps
|
|
30
|
+
// per-session retained memory at O(1) regardless of transcript length.
|
|
31
|
+
const RECENT_WINDOW = 20;
|
|
32
|
+
// Bound the per-session fold map like the usage store's records — a long-lived
|
|
33
|
+
// daemon sees many sessions; the ring is tiny, but the map must not grow without
|
|
34
|
+
// limit. LRU by insertion order (delete+set on write), evict oldest past the cap.
|
|
35
|
+
const MAX_SESSIONS = 256;
|
|
36
|
+
|
|
37
|
+
// [LAW:one-source-of-truth] The incremental fold of one session's metrics: the
|
|
38
|
+
// byte cursor already consumed, the running real-user message count, and the
|
|
39
|
+
// recent-entry ring. Canonical; MetricsInfo's transcript-derived fields derive
|
|
40
|
+
// from it, the hookData-derived fields (durations, lines) are always fresh.
|
|
41
|
+
interface MetricsState {
|
|
42
|
+
cursor: TranscriptCursor;
|
|
43
|
+
messageCount: number;
|
|
44
|
+
recent: ParsedEntry[];
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
// A real user turn vs. a tool_result echoed back as a "user" line. The
|
|
25
48
|
// discriminator is metrics-local policy over the shared ParsedEntry scalars.
|
|
26
49
|
function isRealUserMessage(entry: ParsedEntry): boolean {
|
|
@@ -31,19 +54,17 @@ function isRealUserMessage(entry: ParsedEntry): boolean {
|
|
|
31
54
|
}
|
|
32
55
|
|
|
33
56
|
export class MetricsProvider {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
// [LAW:no-shared-mutable-globals] Single owner, hard cap, LRU eviction — the
|
|
58
|
+
// per-session incremental fold state. One instance per daemon.
|
|
59
|
+
private readonly state = new Map<string, MetricsState>();
|
|
37
60
|
|
|
38
61
|
private calculateLastResponseTime(entries: ParsedEntry[]): number | null {
|
|
39
62
|
if (entries.length === 0) return null;
|
|
40
63
|
|
|
41
|
-
const recentEntries = entries.slice(-20);
|
|
42
|
-
|
|
43
64
|
let lastUserTime: Date | null = null;
|
|
44
65
|
let bestResponseTime: number | null = null;
|
|
45
66
|
|
|
46
|
-
for (const entry of
|
|
67
|
+
for (const entry of entries) {
|
|
47
68
|
const messageType =
|
|
48
69
|
entry.type || entry.message?.role || entry.message?.type;
|
|
49
70
|
|
|
@@ -61,6 +82,80 @@ export class MetricsProvider {
|
|
|
61
82
|
return bestResponseTime;
|
|
62
83
|
}
|
|
63
84
|
|
|
85
|
+
// [LAW:dataflow-not-control-flow] Fold the appended entries onto the prior
|
|
86
|
+
// state, producing a FRESH state (prior is never mutated) so two concurrent
|
|
87
|
+
// renders of one session both fold from the same prior and last-writer-wins is
|
|
88
|
+
// a correct answer, never a double-count. `reset` (a /compact rewrite) folds
|
|
89
|
+
// from empty. Returns the transcript-derived fields the caller composes with
|
|
90
|
+
// the always-fresh hookData fields.
|
|
91
|
+
private async foldMetrics(
|
|
92
|
+
sessionId: string,
|
|
93
|
+
transcriptPath: string,
|
|
94
|
+
): Promise<
|
|
95
|
+
// [LAW:types-are-the-program] Never `absent` — a missing transcript is a real
|
|
96
|
+
// zero-count fold (ok), so the caller has exactly two arms to handle.
|
|
97
|
+
| { kind: "ok"; value: { messageCount: number; recent: ParsedEntry[] } }
|
|
98
|
+
| { kind: "failed"; reason: string }
|
|
99
|
+
> {
|
|
100
|
+
const prior = this.state.get(sessionId);
|
|
101
|
+
const mtime = statMtimeMs(transcriptPath);
|
|
102
|
+
// [LAW:no-ambient-temporal-coupling] Fast hit: the transcript is unchanged
|
|
103
|
+
// since we last folded it, so the message count + ring stand.
|
|
104
|
+
if (prior && mtime !== 0 && prior.cursor.mtimeMs === mtime) {
|
|
105
|
+
// [LAW:no-ambient-temporal-coupling] Re-order on the hit so LRU eviction
|
|
106
|
+
// reflects READ recency, not just write recency — an active session whose
|
|
107
|
+
// transcript is momentarily unchanged must not be evicted ahead of idle
|
|
108
|
+
// ones (matches the usage store's hit path).
|
|
109
|
+
this.state.delete(sessionId);
|
|
110
|
+
this.state.set(sessionId, prior);
|
|
111
|
+
// [LAW:no-shared-mutable-globals] Return a COPY of the stored ring, never
|
|
112
|
+
// the reference — a caller mutating the returned array would silently
|
|
113
|
+
// corrupt the retained state (a heisenbug in the next render's
|
|
114
|
+
// lastResponseTime). Every path (hit/miss/absent) copies. 20 entries.
|
|
115
|
+
return {
|
|
116
|
+
kind: "ok",
|
|
117
|
+
value: { messageCount: prior.messageCount, recent: [...prior.recent] },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const read = await readAppendedEntries(transcriptPath, prior?.cursor);
|
|
122
|
+
if (read.kind === "failed") return read;
|
|
123
|
+
if (read.kind === "absent") {
|
|
124
|
+
// No transcript yet (fresh session) — keep prior if any, don't cache a
|
|
125
|
+
// cursor so the next render retries when the file appears. Copy the ring
|
|
126
|
+
// (like the hit/miss paths) so no stored reference escapes.
|
|
127
|
+
return {
|
|
128
|
+
kind: "ok",
|
|
129
|
+
value: {
|
|
130
|
+
messageCount: prior?.messageCount ?? 0,
|
|
131
|
+
recent: [...(prior?.recent ?? [])],
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { entries, cursor, reset } = read.value;
|
|
137
|
+
const base = prior && !reset ? prior : undefined;
|
|
138
|
+
let messageCount = base?.messageCount ?? 0;
|
|
139
|
+
let recent = base ? [...base.recent] : [];
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (entry.isSidechain) continue;
|
|
142
|
+
if (isRealUserMessage(entry)) messageCount++;
|
|
143
|
+
recent.push(entry);
|
|
144
|
+
}
|
|
145
|
+
if (recent.length > RECENT_WINDOW) recent = recent.slice(-RECENT_WINDOW);
|
|
146
|
+
|
|
147
|
+
this.state.delete(sessionId);
|
|
148
|
+
this.state.set(sessionId, { cursor, messageCount, recent });
|
|
149
|
+
while (this.state.size > MAX_SESSIONS) {
|
|
150
|
+
const oldest = this.state.keys().next().value;
|
|
151
|
+
if (oldest === undefined) break;
|
|
152
|
+
this.state.delete(oldest);
|
|
153
|
+
}
|
|
154
|
+
// [LAW:no-shared-mutable-globals] `recent` is now the STORED array — copy it
|
|
155
|
+
// out so the returned value shares no reference with retained state.
|
|
156
|
+
return { kind: "ok", value: { messageCount, recent: [...recent] } };
|
|
157
|
+
}
|
|
158
|
+
|
|
64
159
|
// [LAW:no-silent-failure] A hook payload with no cost block is `absent`
|
|
65
160
|
// (old clients); a transcript parse error is `failed`, carried to the
|
|
66
161
|
// payload boundary — the old catch dressed it as the same all-null record
|
|
@@ -69,33 +164,24 @@ export class MetricsProvider {
|
|
|
69
164
|
sessionId: string,
|
|
70
165
|
hookData: ClaudeHookData,
|
|
71
166
|
): Promise<Outcome<MetricsInfo>> {
|
|
72
|
-
|
|
73
|
-
debug(`Getting metrics from hook data for session: ${sessionId}`);
|
|
167
|
+
debug(`Getting metrics from hook data for session: ${sessionId}`);
|
|
74
168
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
169
|
+
if (!hookData.cost) {
|
|
170
|
+
return ABSENT;
|
|
171
|
+
}
|
|
78
172
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
(entry) => !entry.isSidechain,
|
|
83
|
-
);
|
|
84
|
-
const messageCount = this.calculateMessageCount(entries);
|
|
85
|
-
const lastResponseTime = this.calculateLastResponseTime(entries);
|
|
86
|
-
|
|
87
|
-
return ok({
|
|
88
|
-
responseTime: hookData.cost.total_api_duration_ms / 1000,
|
|
89
|
-
lastResponseTime,
|
|
90
|
-
sessionDuration: hookData.cost.total_duration_ms / 1000,
|
|
91
|
-
messageCount,
|
|
92
|
-
linesAdded: hookData.cost.total_lines_added,
|
|
93
|
-
linesRemoved: hookData.cost.total_lines_removed,
|
|
94
|
-
});
|
|
95
|
-
} catch (error) {
|
|
96
|
-
return failed(
|
|
97
|
-
`metrics (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
|
|
98
|
-
);
|
|
173
|
+
const folded = await this.foldMetrics(sessionId, hookData.transcript_path);
|
|
174
|
+
if (folded.kind === "failed") {
|
|
175
|
+
return failed(`metrics (${sessionId}): ${folded.reason}`);
|
|
99
176
|
}
|
|
177
|
+
|
|
178
|
+
return ok({
|
|
179
|
+
responseTime: hookData.cost.total_api_duration_ms / 1000,
|
|
180
|
+
lastResponseTime: this.calculateLastResponseTime(folded.value.recent),
|
|
181
|
+
sessionDuration: hookData.cost.total_duration_ms / 1000,
|
|
182
|
+
messageCount: folded.value.messageCount,
|
|
183
|
+
linesAdded: hookData.cost.total_lines_added,
|
|
184
|
+
linesRemoved: hookData.cost.total_lines_removed,
|
|
185
|
+
});
|
|
100
186
|
}
|
|
101
187
|
}
|