@hydraharness/harness-session-stats 0.1.1-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @hydraharness/harness-session-stats
2
+
3
+ Function plugin registering the `sessionStats` projection unit: whole-log conversation figures — turn/step counts and the LLM, tool, first-token, and decode wall times — folded from step boundaries, stream chunks, tool pairs, and assembled assistant messages, and served through the session-projection seam (registry snapshot, change feed, and every projection carrier: history tail page, `session/projection` push frames, session list rows). Clients render full-session figures that paging and compaction cannot change; the reference consumer is the web chat stats strip, whose window fold mirrors these field names as its no-unit fallback.
4
+
5
+ ## Fold semantics
6
+
7
+ - `steps` counts `step/end` events. The agent loop appends exactly one per entered step, in a `finally`, so completed, failed, cancelled, and max-tokens steps all count. Counting assembled assistant messages instead would overcount max-tokens usage-host messages (empty content, excluded from the surface) and undercount cancelled steps (aborted before the message assembles).
8
+ - `turns` counts distinct turns carrying at least one closed step; rejected or empty turns (closed with no step) are uncounted. Turn numbers are host-assigned and monotonic per session, so the fold keeps only the last counted turn.
9
+ - `llmMs` sums `step/start` → `assistant/message` per step that assembled a message (retry waits inside the step are model time, as in the window fold).
10
+ - `ttftMs`/`ttftSteps` sum and count `step/start` → first non-empty delta chunk; the first attempt's boundary survives an in-step `llm/retry` (window `resetForRetry` parity).
11
+ - `decodeMs`/`decodeTokens` sum first token → assembled message and the provider-reported output tokens, only over steps carrying both.
12
+ - `toolMs` sums `tool/call` → `tool/result` pairs matched by callId; unresolved calls are dropped at `turn/end` (results land within their turn).
13
+ - Every field is 0 until its first contributing event. A composed registry always serves the key, so clients read the value, never key presence.
14
+
15
+ ## Composition
16
+
17
+ ```yaml
18
+ - id: session-stats
19
+ name: '@hydraharness/harness-session-stats'
20
+ ```
21
+
22
+ Injects `sessionProjections` — the plugin's whole purpose; in assemblies without the registry the fiber stays pending and nothing registers.
23
+
24
+ ## Model Experience
25
+
26
+ None, as the plugin only computes a client-facing read model of already-logged session events and touches no prompt, message, schema, stream, or tool result.
27
+
28
+ #### KV Cache effect
29
+
30
+ None; the plugin never assembles or sends provider requests.
31
+
32
+ ## Known Limitations and Deferred Work
33
+
34
+ - **Steps count work attempted, not visible output** — a step that failed before producing any visible content still closed with `step/end` and counts; a step interrupted by a crash counts after the session reloads, when crash recovery appends its synthetic `step/end` (`interruptedTurnClosers` in @hydraharness/harness-session).
35
+ - **A cancelled step is counted but untimed** — no assistant message assembles, so its partial stream time enters no wall-time figure, matching the window fold's untimed interrupted node; a max-tokens usage-host message conversely contributes model time the surface does not show.
36
+ - **Counts are log-scoped, not surface-scoped** — steps whose messages were later compacted away stay counted; the figures describe the whole session, not the current model-visible surface.
37
+ - **Mounted only in the web-app bundle** — other assemblies serve no `sessionStats` key, and their consumers fall back to window-scoped counting (the web stats strip's fallback path).
package/lib/index.js ADDED
@@ -0,0 +1,196 @@
1
+ import { z } from "zod";
2
+ import { isTokenDelta } from "@hydraharness/harness-llm/message";
3
+ //#region lib/types/projection.js
4
+ /**
5
+ * The `sessionStats` projection unit: a pure fold of step boundaries, stream
6
+ * chunks, tool pairs, and assembled assistant messages into whole-log counts
7
+ * and wall times.
8
+ *
9
+ * `step/end` — not `assistant/message` — is the counted step event because it
10
+ * is the step lifecycle authority: the loop appends exactly one per entered
11
+ * step, in a `finally`, so completed, failed, cancelled, and max-tokens steps
12
+ * all land one. Counting assembled assistant messages instead would overcount
13
+ * max-tokens usage-host messages (empty content, excluded from the surface)
14
+ * and undercount cancelled steps (aborted before the message assembles).
15
+ *
16
+ * The wall-time folds mirror the client window fold field by field
17
+ * (`deriveStats` in @hydraharness/harness-client-ui-conversation, that fold's whole-window
18
+ * fallback role): model time is `step/start` → `assistant/message`, first
19
+ * token is the first non-empty delta chunk and survives an in-step
20
+ * `llm/retry`, decode spans first token → assembled message on steps that
21
+ * also report output tokens, and tool time pairs `tool/call` → `tool/result`
22
+ * by callId. A cancelled step assembles no message, so its partial stream
23
+ * time stays uncounted in every time figure — matching the window, which
24
+ * renders it as an untimed interrupted node.
25
+ *
26
+ * @module @hydraharness/harness-session-stats/projection
27
+ */
28
+ const sessionStatsSchema = z.object({
29
+ turns: z.number().int().nonnegative(),
30
+ steps: z.number().int().nonnegative(),
31
+ llmMs: z.number().nonnegative(),
32
+ toolMs: z.number().nonnegative(),
33
+ ttftMs: z.number().nonnegative(),
34
+ ttftSteps: z.number().int().nonnegative(),
35
+ decodeMs: z.number().nonnegative(),
36
+ decodeTokens: z.number().nonnegative()
37
+ }).strict();
38
+ /**
39
+ * The fold state's shape (totals plus in-flight boundaries), validated on
40
+ * persisted-cache rows after their `ver` gate — the unit's input boundary.
41
+ * The view is a strict subset of the state, so this schema extends
42
+ * `sessionStatsSchema` (the wire output boundary) with the boundary fields.
43
+ */
44
+ const sessionStatsStateSchema = sessionStatsSchema.extend({
45
+ lastTurn: z.number().int().nonnegative().nullable(),
46
+ openStep: z.object({
47
+ turn: z.number().int().nonnegative(),
48
+ step: z.number().int().nonnegative(),
49
+ startTime: z.number().nonnegative(),
50
+ firstTokenTime: z.number().nonnegative().nullable()
51
+ }).nullable(),
52
+ pendingCalls: z.record(z.string(), z.number().nonnegative())
53
+ });
54
+ /**
55
+ * Provider-reported completion tokens, guarded the way the window fold guards
56
+ * node usage.
57
+ * @param usage - the assistant/message event's optional usage record.
58
+ * @returns the output-token count, or null when unreported or invalid.
59
+ */
60
+ function usageOutputTokens(usage) {
61
+ if (typeof usage !== "object" || usage === null) return null;
62
+ const value = usage.outputTokens;
63
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
64
+ }
65
+ /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
66
+ const sessionStatsProjectionDefinition = {
67
+ key: "sessionStats",
68
+ stateVersion: 1,
69
+ stateSchema: sessionStatsStateSchema,
70
+ init: () => ({
71
+ turns: 0,
72
+ steps: 0,
73
+ llmMs: 0,
74
+ toolMs: 0,
75
+ ttftMs: 0,
76
+ ttftSteps: 0,
77
+ decodeMs: 0,
78
+ decodeTokens: 0,
79
+ lastTurn: null,
80
+ openStep: null,
81
+ pendingCalls: {}
82
+ }),
83
+ apply: (state, event) => {
84
+ switch (event.type) {
85
+ case "step/start": return {
86
+ ...state,
87
+ openStep: {
88
+ turn: event.data.turn,
89
+ step: event.data.step,
90
+ startTime: event.time,
91
+ firstTokenTime: null
92
+ }
93
+ };
94
+ case "assistant/chunk": {
95
+ const open = state.openStep;
96
+ if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state;
97
+ if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk)) return state;
98
+ return {
99
+ ...state,
100
+ openStep: {
101
+ ...open,
102
+ firstTokenTime: event.time
103
+ }
104
+ };
105
+ }
106
+ case "assistant/message": {
107
+ const open = state.openStep;
108
+ if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state;
109
+ const next = {
110
+ ...state,
111
+ llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
112
+ openStep: null
113
+ };
114
+ if (open.firstTokenTime !== null) {
115
+ next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime);
116
+ next.ttftSteps += 1;
117
+ const outputTokens = usageOutputTokens(event.data.usage);
118
+ if (outputTokens !== null) {
119
+ next.decodeMs += Math.max(0, event.time - open.firstTokenTime);
120
+ next.decodeTokens += outputTokens;
121
+ }
122
+ }
123
+ return next;
124
+ }
125
+ case "tool/call": return {
126
+ ...state,
127
+ pendingCalls: {
128
+ ...state.pendingCalls,
129
+ [event.data.callId]: event.time
130
+ }
131
+ };
132
+ case "tool/result": {
133
+ const callId = event.data.message.source.callId;
134
+ const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : void 0;
135
+ if (dispatched === void 0) return state;
136
+ const pendingCalls = Object.fromEntries(Object.entries(state.pendingCalls).filter(([id]) => id !== callId));
137
+ return {
138
+ ...state,
139
+ toolMs: state.toolMs + Math.max(0, event.time - dispatched),
140
+ pendingCalls
141
+ };
142
+ }
143
+ case "step/end": return {
144
+ ...state,
145
+ turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
146
+ steps: state.steps + 1,
147
+ lastTurn: event.data.turn,
148
+ openStep: null
149
+ };
150
+ case "turn/end": return Object.keys(state.pendingCalls).length === 0 ? state : {
151
+ ...state,
152
+ pendingCalls: {}
153
+ };
154
+ default: return state;
155
+ }
156
+ },
157
+ wire: {
158
+ viewSchema: sessionStatsSchema,
159
+ view: (state) => ({
160
+ turns: state.turns,
161
+ steps: state.steps,
162
+ llmMs: state.llmMs,
163
+ toolMs: state.toolMs,
164
+ ttftMs: state.ttftMs,
165
+ ttftSteps: state.ttftSteps,
166
+ decodeMs: state.decodeMs,
167
+ decodeTokens: state.decodeTokens
168
+ })
169
+ }
170
+ };
171
+ //#endregion
172
+ //#region lib/types/index.js
173
+ /**
174
+ * Function plugin registering the `sessionStats` projection unit: whole-log
175
+ * turn/step counts and LLM/tool/first-token/decode wall times served through
176
+ * the session-projection seam (registry snapshot, change feed, and every
177
+ * projection carrier), so clients render full-session figures that paging and
178
+ * compaction cannot change. The plugin owns only the fold; delivery is the
179
+ * seam's.
180
+ *
181
+ * @module @hydraharness/harness-session-stats
182
+ */
183
+ /** Cordis plugin name. */
184
+ const name = "session-stats";
185
+ /** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
186
+ const inject = ["sessionProjections"];
187
+ /**
188
+ * Register the `sessionStats` unit; the registration is an effect on this
189
+ * plugin's fiber, so unloading removes the key.
190
+ * @param ctx - registrant context carrying the projection registry.
191
+ */
192
+ function apply(ctx) {
193
+ ctx.sessionProjections.register(sessionStatsProjectionDefinition);
194
+ }
195
+ //#endregion
196
+ export { apply, inject, name };
@@ -0,0 +1,28 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@hydraharness/harness-session-stats`.
4
+ * @module @hydraharness/harness-session-stats/invariant
5
+ */
6
+ const PACKAGE_NAME = "@hydraharness/harness-session-stats";
7
+ /** Cordis companion plugin name. */
8
+ const name = "session-stats-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: the package owns a single pure projection fold whose
13
+ * wire payload is schema-validated by the projection registry at every
14
+ * snapshot and change-feed emission, and the event relations the fold relies
15
+ * on (`step/end` exactly once per entered step, monotonic host-assigned turn
16
+ * numbers, chunk and tool events carrying their step coordinates and call
17
+ * ids) are owned and runtime-checked by @hydraharness/harness-agent-loop and the session
18
+ * surface, not here.
19
+ */
20
+ const install = () => {};
21
+ /**
22
+ * Register this package's invariant companion.
23
+ * @param ctx - Cordis context carrying the invariant service.
24
+ * @returns the installed registration's disposer after setup succeeds.
25
+ */
26
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
27
+ //#endregion
28
+ export { apply, inject, name };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Client-namespace projection of the session-stats domain: a pure re-export
3
+ * of the package's types outlet. Client code imports ONLY the client
4
+ * namespace (repo discipline), so `./client` projects the same single-source
5
+ * content `./types` serves to host consumers — zero duplication.
6
+ *
7
+ * @module @hydraharness/harness-session-stats/client
8
+ */
9
+ export type * from './types.ts';
10
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Client-namespace projection of the session-stats domain: a pure re-export
3
+ * of the package's types outlet. Client code imports ONLY the client
4
+ * namespace (repo discipline), so `./client` projects the same single-source
5
+ * content `./types` serves to host consumers — zero duplication.
6
+ *
7
+ * @module @hydraharness/harness-session-stats/client
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Function plugin registering the `sessionStats` projection unit: whole-log
3
+ * turn/step counts and LLM/tool/first-token/decode wall times served through
4
+ * the session-projection seam (registry snapshot, change feed, and every
5
+ * projection carrier), so clients render full-session figures that paging and
6
+ * compaction cannot change. The plugin owns only the fold; delivery is the
7
+ * seam's.
8
+ *
9
+ * @module @hydraharness/harness-session-stats
10
+ */
11
+ import type { Context } from '@hydraharness/cordis';
12
+ export type * from './types.ts';
13
+ /** Cordis plugin name. */
14
+ export declare const name = "session-stats";
15
+ /** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
16
+ export declare const inject: string[];
17
+ /**
18
+ * Register the `sessionStats` unit; the registration is an effect on this
19
+ * plugin's fiber, so unloading removes the key.
20
+ * @param ctx - registrant context carrying the projection registry.
21
+ */
22
+ export declare function apply(ctx: Context): void;
23
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Function plugin registering the `sessionStats` projection unit: whole-log
3
+ * turn/step counts and LLM/tool/first-token/decode wall times served through
4
+ * the session-projection seam (registry snapshot, change feed, and every
5
+ * projection carrier), so clients render full-session figures that paging and
6
+ * compaction cannot change. The plugin owns only the fold; delivery is the
7
+ * seam's.
8
+ *
9
+ * @module @hydraharness/harness-session-stats
10
+ */
11
+ import { sessionStatsProjectionDefinition } from "./projection.js";
12
+ /** Cordis plugin name. */
13
+ export const name = 'session-stats';
14
+ /** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
15
+ export const inject = ['sessionProjections'];
16
+ /**
17
+ * Register the `sessionStats` unit; the registration is an effect on this
18
+ * plugin's fiber, so unloading removes the key.
19
+ * @param ctx - registrant context carrying the projection registry.
20
+ */
21
+ export function apply(ctx) {
22
+ ctx.sessionProjections.register(sessionStatsProjectionDefinition);
23
+ }
24
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@hydraharness/harness-session-stats`.
3
+ * @module @hydraharness/harness-session-stats/invariant
4
+ */
5
+ import type { Context } from '@hydraharness/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "session-stats-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Package-owned invariant companion for `@hydraharness/harness-session-stats`.
3
+ * @module @hydraharness/harness-session-stats/invariant
4
+ */
5
+ const PACKAGE_NAME = '@hydraharness/harness-session-stats';
6
+ /** Cordis companion plugin name. */
7
+ export const name = 'session-stats-invariant';
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * No runtime invariant: the package owns a single pure projection fold whose
12
+ * wire payload is schema-validated by the projection registry at every
13
+ * snapshot and change-feed emission, and the event relations the fold relies
14
+ * on (`step/end` exactly once per entered step, monotonic host-assigned turn
15
+ * numbers, chunk and tool events carrying their step coordinates and call
16
+ * ids) are owned and runtime-checked by @hydraharness/harness-agent-loop and the session
17
+ * surface, not here.
18
+ */
19
+ const install = () => { };
20
+ /**
21
+ * Register this package's invariant companion.
22
+ * @param ctx - Cordis context carrying the invariant service.
23
+ * @returns the installed registration's disposer after setup succeeds.
24
+ */
25
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
26
+ /* jscpd:ignore-end */
27
+ //# sourceMappingURL=invariant.js.map
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The `sessionStats` projection unit: a pure fold of step boundaries, stream
3
+ * chunks, tool pairs, and assembled assistant messages into whole-log counts
4
+ * and wall times.
5
+ *
6
+ * `step/end` — not `assistant/message` — is the counted step event because it
7
+ * is the step lifecycle authority: the loop appends exactly one per entered
8
+ * step, in a `finally`, so completed, failed, cancelled, and max-tokens steps
9
+ * all land one. Counting assembled assistant messages instead would overcount
10
+ * max-tokens usage-host messages (empty content, excluded from the surface)
11
+ * and undercount cancelled steps (aborted before the message assembles).
12
+ *
13
+ * The wall-time folds mirror the client window fold field by field
14
+ * (`deriveStats` in @hydraharness/harness-client-ui-conversation, that fold's whole-window
15
+ * fallback role): model time is `step/start` → `assistant/message`, first
16
+ * token is the first non-empty delta chunk and survives an in-step
17
+ * `llm/retry`, decode spans first token → assembled message on steps that
18
+ * also report output tokens, and tool time pairs `tool/call` → `tool/result`
19
+ * by callId. A cancelled step assembles no message, so its partial stream
20
+ * time stays uncounted in every time figure — matching the window, which
21
+ * renders it as an untimed interrupted node.
22
+ *
23
+ * @module @hydraharness/harness-session-stats/projection
24
+ */
25
+ import { z } from 'zod';
26
+ /** Accumulated whole-log figures (the view is exactly these totals). */
27
+ interface SessionStatsTotals {
28
+ /** Distinct turns with at least one closed step so far. */
29
+ turns: number;
30
+ /** Closed steps so far. */
31
+ steps: number;
32
+ /** Summed model wall time over message-assembling steps, ms. */
33
+ llmMs: number;
34
+ /** Summed matched tool call→result wall time, ms. */
35
+ toolMs: number;
36
+ /** Summed first-token latency over `ttftSteps`, ms. */
37
+ ttftMs: number;
38
+ /** Steps carrying a recorded first token. */
39
+ ttftSteps: number;
40
+ /** Summed decode wall time over usage-reporting steps, ms. */
41
+ decodeMs: number;
42
+ /** Summed provider output tokens over the same steps. */
43
+ decodeTokens: number;
44
+ }
45
+ /**
46
+ * Fold state: the totals plus the in-flight boundaries they accrue from.
47
+ * Turn numbers are host-assigned and monotonic per session, so a single
48
+ * `lastTurn` slot decides "first closed step of a new turn"; the state is
49
+ * plain JSON per the unit contract (persisted-cache precondition).
50
+ */
51
+ interface SessionStatsState extends SessionStatsTotals {
52
+ /** Turn of the last counted `step/end`; null before the first. */
53
+ lastTurn: number | null;
54
+ /** The open step's boundary facts; null outside a step or after its message assembled. */
55
+ openStep: {
56
+ turn: number;
57
+ step: number;
58
+ startTime: number;
59
+ firstTokenTime: number | null;
60
+ } | null;
61
+ /** Dispatch times of tool calls whose result has not landed, by callId. */
62
+ pendingCalls: Record<string, number>;
63
+ }
64
+ declare module '@hydraharness/harness-session-projection/types' {
65
+ interface SessionProjectionStateMap {
66
+ sessionStats: SessionStatsState;
67
+ }
68
+ }
69
+ /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
70
+ export declare const sessionStatsProjectionDefinition: {
71
+ key: "sessionStats";
72
+ stateVersion: number;
73
+ stateSchema: z.ZodObject<{
74
+ turns: z.ZodNumber;
75
+ steps: z.ZodNumber;
76
+ llmMs: z.ZodNumber;
77
+ toolMs: z.ZodNumber;
78
+ ttftMs: z.ZodNumber;
79
+ ttftSteps: z.ZodNumber;
80
+ decodeMs: z.ZodNumber;
81
+ decodeTokens: z.ZodNumber;
82
+ lastTurn: z.ZodNullable<z.ZodNumber>;
83
+ openStep: z.ZodNullable<z.ZodObject<{
84
+ turn: z.ZodNumber;
85
+ step: z.ZodNumber;
86
+ startTime: z.ZodNumber;
87
+ firstTokenTime: z.ZodNullable<z.ZodNumber>;
88
+ }, z.core.$strip>>;
89
+ pendingCalls: z.ZodRecord<z.ZodString, z.ZodNumber>;
90
+ }, z.core.$strict>;
91
+ init: () => {
92
+ turns: number;
93
+ steps: number;
94
+ llmMs: number;
95
+ toolMs: number;
96
+ ttftMs: number;
97
+ ttftSteps: number;
98
+ decodeMs: number;
99
+ decodeTokens: number;
100
+ lastTurn: null;
101
+ openStep: null;
102
+ pendingCalls: {};
103
+ };
104
+ apply: (state: NoInfer<SessionStatsState>, event: import("@hydraharness/harness-session").SessionEvent) => SessionStatsState;
105
+ wire: {
106
+ viewSchema: z.ZodObject<{
107
+ turns: z.ZodNumber;
108
+ steps: z.ZodNumber;
109
+ llmMs: z.ZodNumber;
110
+ toolMs: z.ZodNumber;
111
+ ttftMs: z.ZodNumber;
112
+ ttftSteps: z.ZodNumber;
113
+ decodeMs: z.ZodNumber;
114
+ decodeTokens: z.ZodNumber;
115
+ }, z.core.$strict>;
116
+ view: (state: NoInfer<SessionStatsState>) => {
117
+ turns: number;
118
+ steps: number;
119
+ llmMs: number;
120
+ toolMs: number;
121
+ ttftMs: number;
122
+ ttftSteps: number;
123
+ decodeMs: number;
124
+ decodeTokens: number;
125
+ };
126
+ };
127
+ };
128
+ export {};
129
+ //# sourceMappingURL=projection.d.ts.map
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The `sessionStats` projection unit: a pure fold of step boundaries, stream
3
+ * chunks, tool pairs, and assembled assistant messages into whole-log counts
4
+ * and wall times.
5
+ *
6
+ * `step/end` — not `assistant/message` — is the counted step event because it
7
+ * is the step lifecycle authority: the loop appends exactly one per entered
8
+ * step, in a `finally`, so completed, failed, cancelled, and max-tokens steps
9
+ * all land one. Counting assembled assistant messages instead would overcount
10
+ * max-tokens usage-host messages (empty content, excluded from the surface)
11
+ * and undercount cancelled steps (aborted before the message assembles).
12
+ *
13
+ * The wall-time folds mirror the client window fold field by field
14
+ * (`deriveStats` in @hydraharness/harness-client-ui-conversation, that fold's whole-window
15
+ * fallback role): model time is `step/start` → `assistant/message`, first
16
+ * token is the first non-empty delta chunk and survives an in-step
17
+ * `llm/retry`, decode spans first token → assembled message on steps that
18
+ * also report output tokens, and tool time pairs `tool/call` → `tool/result`
19
+ * by callId. A cancelled step assembles no message, so its partial stream
20
+ * time stays uncounted in every time figure — matching the window, which
21
+ * renders it as an untimed interrupted node.
22
+ *
23
+ * @module @hydraharness/harness-session-stats/projection
24
+ */
25
+ import { z } from 'zod';
26
+ import { isTokenDelta } from '@hydraharness/harness-llm/message';
27
+ const sessionStatsSchema = z.object({
28
+ turns: z.number().int().nonnegative(),
29
+ steps: z.number().int().nonnegative(),
30
+ llmMs: z.number().nonnegative(),
31
+ toolMs: z.number().nonnegative(),
32
+ ttftMs: z.number().nonnegative(),
33
+ ttftSteps: z.number().int().nonnegative(),
34
+ decodeMs: z.number().nonnegative(),
35
+ decodeTokens: z.number().nonnegative(),
36
+ }).strict();
37
+ /**
38
+ * The fold state's shape (totals plus in-flight boundaries), validated on
39
+ * persisted-cache rows after their `ver` gate — the unit's input boundary.
40
+ * The view is a strict subset of the state, so this schema extends
41
+ * `sessionStatsSchema` (the wire output boundary) with the boundary fields.
42
+ */
43
+ const sessionStatsStateSchema = sessionStatsSchema.extend({
44
+ lastTurn: z.number().int().nonnegative().nullable(),
45
+ openStep: z.object({
46
+ turn: z.number().int().nonnegative(),
47
+ step: z.number().int().nonnegative(),
48
+ startTime: z.number().nonnegative(),
49
+ firstTokenTime: z.number().nonnegative().nullable(),
50
+ }).nullable(),
51
+ pendingCalls: z.record(z.string(), z.number().nonnegative()),
52
+ });
53
+ /**
54
+ * Provider-reported completion tokens, guarded the way the window fold guards
55
+ * node usage.
56
+ * @param usage - the assistant/message event's optional usage record.
57
+ * @returns the output-token count, or null when unreported or invalid.
58
+ */
59
+ function usageOutputTokens(usage) {
60
+ if (typeof usage !== 'object' || usage === null)
61
+ return null;
62
+ const value = usage.outputTokens;
63
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
64
+ }
65
+ /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
66
+ export const sessionStatsProjectionDefinition = {
67
+ key: 'sessionStats',
68
+ stateVersion: 1,
69
+ stateSchema: sessionStatsStateSchema,
70
+ init: () => ({
71
+ turns: 0,
72
+ steps: 0,
73
+ llmMs: 0,
74
+ toolMs: 0,
75
+ ttftMs: 0,
76
+ ttftSteps: 0,
77
+ decodeMs: 0,
78
+ decodeTokens: 0,
79
+ lastTurn: null,
80
+ openStep: null,
81
+ pendingCalls: {},
82
+ }),
83
+ apply: (state, event) => {
84
+ // Every uninteresting event returns the same reference (Object.is gates the change feed).
85
+ switch (event.type) {
86
+ case 'step/start':
87
+ return {
88
+ ...state,
89
+ openStep: { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null },
90
+ };
91
+ case 'assistant/chunk': {
92
+ const open = state.openStep;
93
+ if (open === null || open.turn !== event.data.turn || open.step !== event.data.step)
94
+ return state;
95
+ if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk))
96
+ return state;
97
+ return { ...state, openStep: { ...open, firstTokenTime: event.time } };
98
+ }
99
+ case 'assistant/message': {
100
+ const open = state.openStep;
101
+ if (open === null || open.turn !== event.data.turn || open.step !== event.data.step)
102
+ return state;
103
+ // One assembled message per step: closing the boundary means a
104
+ // defensive duplicate cannot accrue twice.
105
+ const next = {
106
+ ...state,
107
+ llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
108
+ openStep: null,
109
+ };
110
+ if (open.firstTokenTime !== null) {
111
+ next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime);
112
+ next.ttftSteps += 1;
113
+ const outputTokens = usageOutputTokens(event.data.usage);
114
+ if (outputTokens !== null) {
115
+ next.decodeMs += Math.max(0, event.time - open.firstTokenTime);
116
+ next.decodeTokens += outputTokens;
117
+ }
118
+ }
119
+ return next;
120
+ }
121
+ case 'tool/call':
122
+ return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } };
123
+ case 'tool/result': {
124
+ // Own-key check: callId is provider-minted (model/tool JSON boundary),
125
+ // so a prototype property name ('constructor', 'toString') on a result
126
+ // with no recorded call must read as unmatched, not as an inherited
127
+ // function that would poison toolMs with NaN.
128
+ const callId = event.data.message.source.callId;
129
+ const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : undefined;
130
+ if (dispatched === undefined)
131
+ return state;
132
+ const pendingCalls = Object.fromEntries(Object.entries(state.pendingCalls).filter(([id]) => id !== callId));
133
+ return { ...state, toolMs: state.toolMs + Math.max(0, event.time - dispatched), pendingCalls };
134
+ }
135
+ case 'step/end':
136
+ return {
137
+ ...state,
138
+ turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
139
+ steps: state.steps + 1,
140
+ lastTurn: event.data.turn,
141
+ openStep: null,
142
+ };
143
+ case 'turn/end':
144
+ // A call whose result never landed belongs to a cancelled or failed
145
+ // turn; results always land within their turn, so drop the leftovers
146
+ // instead of growing persisted state forever.
147
+ return Object.keys(state.pendingCalls).length === 0 ? state : { ...state, pendingCalls: {} };
148
+ default:
149
+ return state;
150
+ }
151
+ },
152
+ wire: {
153
+ viewSchema: sessionStatsSchema,
154
+ view: state => ({
155
+ turns: state.turns,
156
+ steps: state.steps,
157
+ llmMs: state.llmMs,
158
+ toolMs: state.toolMs,
159
+ ttftMs: state.ttftMs,
160
+ ttftSteps: state.ttftSteps,
161
+ decodeMs: state.decodeMs,
162
+ decodeTokens: state.decodeTokens,
163
+ }),
164
+ },
165
+ };
166
+ //# sourceMappingURL=projection.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure types of the session-stats domain: the ONE home of the `sessionStats`
3
+ * projection-key declaration, free of this package's host-side value imports
4
+ * (cordis context, zod, the llm chunk predicate). Two namespace projections
5
+ * serve it — `./types` for host consumers, `./client` for client aggregates —
6
+ * with zero content duplication.
7
+ *
8
+ * @module @hydraharness/harness-session-stats/types
9
+ */
10
+ export {};
11
+ /**
12
+ * Whole-log conversation figures, independent of how much history a client
13
+ * has paged in. Counts and wall times all fold from the complete durable log;
14
+ * every field is 0 until its first contributing event lands. Field names
15
+ * mirror the client window fold so an assembly without this unit can fall
16
+ * back to it wholesale.
17
+ */
18
+ export interface SessionStatsProjection {
19
+ /** Distinct turns carrying at least one closed step (`step/end`); rejected or empty turns are uncounted. */
20
+ turns: number;
21
+ /** Closed steps (`step/end` events) — completed, failed, and cancelled steps alike. */
22
+ steps: number;
23
+ /** Summed model wall time (`step/start` → `assistant/message`) over steps that assembled a message. */
24
+ llmMs: number;
25
+ /** Summed tool wall time over `tool/call` → `tool/result` pairs matched by callId. */
26
+ toolMs: number;
27
+ /** Summed first-token latency (`step/start` → first non-empty delta chunk) over `ttftSteps`. */
28
+ ttftMs: number;
29
+ /** Steps carrying a recorded first token. */
30
+ ttftSteps: number;
31
+ /** Summed decode wall time (first token → `assistant/message`) over steps that also report output tokens. */
32
+ decodeMs: number;
33
+ /** Summed provider output tokens over the same decode-timed steps. */
34
+ decodeTokens: number;
35
+ }
36
+ declare module '@hydraharness/harness-session-projection/types' {
37
+ interface SessionProjectionMap {
38
+ /** Whole-log turn/step counts and wall times; see {@link SessionStatsProjection}. */
39
+ sessionStats: SessionStatsProjection;
40
+ }
41
+ }
42
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Pure types of the session-stats domain: the ONE home of the `sessionStats`
3
+ * projection-key declaration, free of this package's host-side value imports
4
+ * (cordis context, zod, the llm chunk predicate). Two namespace projections
5
+ * serve it — `./types` for host consumers, `./client` for client aggregates —
6
+ * with zero content duplication.
7
+ *
8
+ * @module @hydraharness/harness-session-stats/types
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@hydraharness/harness-session-stats",
3
+ "description": "Whole-log conversation counts and wall times projection (sessionStats) for the Hydra harness",
4
+ "hydra": {
5
+ "plugin": {
6
+ "application": "Show conversation counts and elapsed times derived from the session history."
7
+ }
8
+ },
9
+ "version": "0.1.1-rc.6",
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
16
+ "directory": "packages/session/session-stats"
17
+ },
18
+ "type": "module",
19
+ "main": "lib/index.js",
20
+ "types": "lib/types/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./lib/types/index.d.ts",
24
+ "default": "./lib/index.js"
25
+ },
26
+ "./invariant": {
27
+ "types": "./lib/types/invariant.d.ts",
28
+ "default": "./lib/invariant.js"
29
+ },
30
+ "./types": {
31
+ "types": "./lib/types/types.d.ts",
32
+ "default": "./lib/types/types.js"
33
+ },
34
+ "./client": {
35
+ "types": "./lib/types/client.d.ts",
36
+ "default": "./lib/types/client.js"
37
+ },
38
+ "./src/*": "./src/*",
39
+ "./package.json": "./package.json"
40
+ },
41
+ "files": [
42
+ "lib/index.js",
43
+ "lib/invariant.js",
44
+ "lib/types/**/*.js",
45
+ "lib/types/**/*.d.ts"
46
+ ],
47
+ "license": "MIT",
48
+ "peerDependencies": {
49
+ "@hydraharness/harness-invariants": "^0.1.1-rc.6",
50
+ "@hydraharness/harness-llm": "^0.1.1-rc.6",
51
+ "@hydraharness/harness-session": "^0.1.1-rc.6",
52
+ "@hydraharness/harness-session-projection": "^0.1.1-rc.6",
53
+ "@hydraharness/cordis": "^4.0.2"
54
+ },
55
+ "dependencies": {
56
+ "zod": "^4.4.3"
57
+ },
58
+ "devDependencies": {
59
+ "@hydraharness/cordis-plugin-loader": "^1.0.3",
60
+ "@hydraharness/cordis-plugin-include": "^1.0.7",
61
+ "@hydraharness/harness-invariants": "^0.1.1-rc.6",
62
+ "@hydraharness/harness-session": "^0.1.1-rc.6",
63
+ "@hydraharness/harness-llm": "^0.1.1-rc.6",
64
+ "@hydraharness/harness-session-projection": "^0.1.1-rc.6",
65
+ "@hydraharness/cordis": "^4.0.2"
66
+ }
67
+ }