@deepseek-ai/dsh-session-stats 0.0.1-rc.5

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,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, DeepSeek
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/session/session-stats/README.md
5
+ README.md: 81b0de17e335b67936afd6fb11f15411beee3b76
6
+ README.zh.md: 606628ea09bc34203a5374e49db62c1646293846
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # @deepseek-ai/dsh-session-stats
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ 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.
6
+
7
+ ## Fold semantics
8
+
9
+ - `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).
10
+ - `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.
11
+ - `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).
12
+ - `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).
13
+ - `decodeMs`/`decodeTokens` sum first token → assembled message and the provider-reported output tokens, only over steps carrying both.
14
+ - `toolMs` sums `tool/call` → `tool/result` pairs matched by callId; unresolved calls are dropped at `turn/end` (results land within their turn).
15
+ - Every field is 0 until its first contributing event. A composed registry always serves the key, so clients read the value, never key presence.
16
+
17
+ ## Composition
18
+
19
+ ```yaml
20
+ - id: session-stats
21
+ name: '@deepseek-ai/dsh-session-stats'
22
+ ```
23
+
24
+ Injects `sessionProjections` — the plugin's whole purpose; in assemblies without the registry the fiber stays pending and nothing registers.
25
+
26
+ ## Model Experience
27
+
28
+ 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.
29
+
30
+ #### KV Cache effect
31
+
32
+ None; the plugin never assembles or sends provider requests.
33
+
34
+ ## Known Limitations and Deferred Work
35
+
36
+ - **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 dsh-session).
37
+ - **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.
38
+ - **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.
39
+ - **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/README.zh.md ADDED
@@ -0,0 +1,39 @@
1
+ # @deepseek-ai/dsh-session-stats
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 注册 `sessionStats` projection 单元的函数插件:从步边界、流式 chunk、工具配对与已组装的 assistant 消息折叠出全日志会话数字——轮/步计数以及 LLM、工具、首 token、解码墙钟时间——经 session-projection 缝对外提供(registry 快照、变更流,以及每一个 projection 载体:history 尾页、`session/projection` 推送帧、会话列表行)。客户端由此渲染分页与压缩都无法改变的全会话数字;参考消费者是 Web 聊天统计条,其窗口折叠以相同字段名充当无单元时的回退。
6
+
7
+ ## 折叠语义
8
+
9
+ - `steps` 统计 `step/end` 事件。agent loop 对每个进入的步在 `finally` 中恰好追加一条,因此完成、失败、取消、max-tokens 的步全部计入。若改按已组装的 assistant 消息计数,则会多算 max-tokens 的 usage 宿主消息(空内容、被排除在 surface 之外),并少算被取消的步(在消息组装前已中止)。
10
+ - `turns` 统计含至少一个已关闭步的不同 turn;被拒绝或空轮(未进入任何步即关闭)不计。turn 号由宿主分配、按会话单调递增,因此折叠只需保留最近计入的 turn。
11
+ - `llmMs` 按步累加 `step/start` → `assistant/message`(组装出消息的步;步内重试的等待与窗口折叠一样计入模型时间)。
12
+ - `ttftMs`/`ttftSteps` 累加并统计 `step/start` → 首个非空 delta chunk;首次尝试的边界在步内 `llm/retry` 后保留(与窗口 `resetForRetry` 对齐)。
13
+ - `decodeMs`/`decodeTokens` 累加首 token → 已组装消息的时长与提供方上报的输出 token,仅统计两者兼备的步。
14
+ - `toolMs` 按 callId 配对累加 `tool/call` → `tool/result`;未解决的调用在 `turn/end` 时丢弃(结果总在其轮内落地)。
15
+ - 每个字段在首个贡献事件之前均为 0。已装配的 registry 恒提供该键,客户端读取值本身,而非键的存在性。
16
+
17
+ ## 组合
18
+
19
+ ```yaml
20
+ - id: session-stats
21
+ name: '@deepseek-ai/dsh-session-stats'
22
+ ```
23
+
24
+ 注入 `sessionProjections`——这是插件的全部用途;在没有 registry 的装配中 fiber 保持挂起,不注册任何内容。
25
+
26
+ ## 模型体验
27
+
28
+ 无,因为插件只计算面向客户端的、由已写入日志的会话事件派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
29
+
30
+ #### KV Cache 影响
31
+
32
+ 无;插件从不组装或发送提供方请求。
33
+
34
+ ## 已知局限与延后工作
35
+
36
+ - **步数统计的是已发生的工作,而非可见输出**——在产生任何可见内容前就失败的步仍以 `step/end` 关闭并计入;被崩溃打断的步在会话重新加载后计入,届时崩溃恢复为其补写合成的 `step/end`(dsh-session 的 `interruptedTurnClosers`)。
37
+ - **被取消的步计数但不计时**——没有组装出 assistant 消息,其部分流式时间不进入任何墙钟数字,与窗口折叠的无计时 interrupted 节点一致;反之 max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。
38
+ - **计数是日志口径,不是 surface 口径**——消息后来被压缩掉的步仍然计入;数字描述整个会话,而非当前模型可见 surface。
39
+ - **仅挂载于 web-app bundle**——其他装配不提供 `sessionStats` 键,其消费者回退到窗口口径计数(Web 统计条的回退路径)。
package/lib/index.js ADDED
@@ -0,0 +1,177 @@
1
+ import { z } from "zod";
2
+ import { isTokenDelta } from "@deepseek-ai/dsh-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 dsh-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 @deepseek-ai/dsh-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
+ * Provider-reported completion tokens, guarded the way the window fold guards
40
+ * node usage.
41
+ * @param usage - the assistant/message event's optional usage record.
42
+ * @returns the output-token count, or null when unreported or invalid.
43
+ */
44
+ function usageOutputTokens(usage) {
45
+ if (typeof usage !== "object" || usage === null) return null;
46
+ const value = usage.outputTokens;
47
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
48
+ }
49
+ /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
50
+ const sessionStatsProjectionDefinition = {
51
+ key: "sessionStats",
52
+ schema: sessionStatsSchema,
53
+ init: () => ({
54
+ turns: 0,
55
+ steps: 0,
56
+ llmMs: 0,
57
+ toolMs: 0,
58
+ ttftMs: 0,
59
+ ttftSteps: 0,
60
+ decodeMs: 0,
61
+ decodeTokens: 0,
62
+ lastTurn: null,
63
+ openStep: null,
64
+ pendingCalls: {}
65
+ }),
66
+ apply: (state, event) => {
67
+ switch (event.type) {
68
+ case "step/start": return {
69
+ ...state,
70
+ openStep: {
71
+ turn: event.data.turn,
72
+ step: event.data.step,
73
+ startTime: event.time,
74
+ firstTokenTime: null
75
+ }
76
+ };
77
+ case "assistant/chunk": {
78
+ const open = state.openStep;
79
+ if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state;
80
+ if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk)) return state;
81
+ return {
82
+ ...state,
83
+ openStep: {
84
+ ...open,
85
+ firstTokenTime: event.time
86
+ }
87
+ };
88
+ }
89
+ case "assistant/message": {
90
+ const open = state.openStep;
91
+ if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state;
92
+ const next = {
93
+ ...state,
94
+ llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
95
+ openStep: null
96
+ };
97
+ if (open.firstTokenTime !== null) {
98
+ next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime);
99
+ next.ttftSteps += 1;
100
+ const outputTokens = usageOutputTokens(event.data.usage);
101
+ if (outputTokens !== null) {
102
+ next.decodeMs += Math.max(0, event.time - open.firstTokenTime);
103
+ next.decodeTokens += outputTokens;
104
+ }
105
+ }
106
+ return next;
107
+ }
108
+ case "tool/call": return {
109
+ ...state,
110
+ pendingCalls: {
111
+ ...state.pendingCalls,
112
+ [event.data.callId]: event.time
113
+ }
114
+ };
115
+ case "tool/result": {
116
+ const callId = event.data.message.source.callId;
117
+ const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : void 0;
118
+ if (dispatched === void 0) return state;
119
+ const pendingCalls = Object.fromEntries(Object.entries(state.pendingCalls).filter(([id]) => id !== callId));
120
+ return {
121
+ ...state,
122
+ toolMs: state.toolMs + Math.max(0, event.time - dispatched),
123
+ pendingCalls
124
+ };
125
+ }
126
+ case "step/end": return {
127
+ ...state,
128
+ turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
129
+ steps: state.steps + 1,
130
+ lastTurn: event.data.turn,
131
+ openStep: null
132
+ };
133
+ case "turn/end": return Object.keys(state.pendingCalls).length === 0 ? state : {
134
+ ...state,
135
+ pendingCalls: {}
136
+ };
137
+ default: return state;
138
+ }
139
+ },
140
+ view: (state) => ({
141
+ turns: state.turns,
142
+ steps: state.steps,
143
+ llmMs: state.llmMs,
144
+ toolMs: state.toolMs,
145
+ ttftMs: state.ttftMs,
146
+ ttftSteps: state.ttftSteps,
147
+ decodeMs: state.decodeMs,
148
+ decodeTokens: state.decodeTokens
149
+ }),
150
+ stateVersion: 1
151
+ };
152
+ //#endregion
153
+ //#region lib/types/index.js
154
+ /**
155
+ * Function plugin registering the `sessionStats` projection unit: whole-log
156
+ * turn/step counts and LLM/tool/first-token/decode wall times served through
157
+ * the session-projection seam (registry snapshot, change feed, and every
158
+ * projection carrier), so clients render full-session figures that paging and
159
+ * compaction cannot change. The plugin owns only the fold; delivery is the
160
+ * seam's.
161
+ *
162
+ * @module @deepseek-ai/dsh-session-stats
163
+ */
164
+ /** Cordis plugin name. */
165
+ const name = "session-stats";
166
+ /** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
167
+ const inject = ["sessionProjections"];
168
+ /**
169
+ * Register the `sessionStats` unit; the registration is an effect on this
170
+ * plugin's fiber, so unloading removes the key.
171
+ * @param ctx - registrant context carrying the projection registry.
172
+ */
173
+ function apply(ctx) {
174
+ ctx.sessionProjections.register(sessionStatsProjectionDefinition);
175
+ }
176
+ //#endregion
177
+ export { apply, inject, name };
@@ -0,0 +1,28 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-stats`.
4
+ * @module @deepseek-ai/dsh-session-stats/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-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 dsh-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 @deepseek-ai/dsh-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 @deepseek-ai/dsh-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 @deepseek-ai/dsh-session-stats
10
+ */
11
+ import type { Context } from '@deepseek-ai/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 @deepseek-ai/dsh-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 `@deepseek-ai/dsh-session-stats`.
3
+ * @module @deepseek-ai/dsh-session-stats/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/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 `@deepseek-ai/dsh-session-stats`.
3
+ * @module @deepseek-ai/dsh-session-stats/invariant
4
+ */
5
+ const PACKAGE_NAME = '@deepseek-ai/dsh-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 dsh-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,67 @@
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 dsh-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 @deepseek-ai/dsh-session-stats/projection
24
+ */
25
+ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
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
+ /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
65
+ export declare const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState>;
66
+ export {};
67
+ //# sourceMappingURL=projection.d.ts.map
@@ -0,0 +1,147 @@
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 dsh-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 @deepseek-ai/dsh-session-stats/projection
24
+ */
25
+ import { z } from 'zod';
26
+ import { isTokenDelta } from '@deepseek-ai/dsh-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
+ * Provider-reported completion tokens, guarded the way the window fold guards
39
+ * node usage.
40
+ * @param usage - the assistant/message event's optional usage record.
41
+ * @returns the output-token count, or null when unreported or invalid.
42
+ */
43
+ function usageOutputTokens(usage) {
44
+ if (typeof usage !== 'object' || usage === null)
45
+ return null;
46
+ const value = usage.outputTokens;
47
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
48
+ }
49
+ /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
50
+ export const sessionStatsProjectionDefinition = {
51
+ key: 'sessionStats',
52
+ schema: sessionStatsSchema,
53
+ init: () => ({
54
+ turns: 0,
55
+ steps: 0,
56
+ llmMs: 0,
57
+ toolMs: 0,
58
+ ttftMs: 0,
59
+ ttftSteps: 0,
60
+ decodeMs: 0,
61
+ decodeTokens: 0,
62
+ lastTurn: null,
63
+ openStep: null,
64
+ pendingCalls: {},
65
+ }),
66
+ apply: (state, event) => {
67
+ // Every uninteresting event returns the same reference (Object.is gates the change feed).
68
+ switch (event.type) {
69
+ case 'step/start':
70
+ return {
71
+ ...state,
72
+ openStep: { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null },
73
+ };
74
+ case 'assistant/chunk': {
75
+ const open = state.openStep;
76
+ if (open === null || open.turn !== event.data.turn || open.step !== event.data.step)
77
+ return state;
78
+ if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk))
79
+ return state;
80
+ return { ...state, openStep: { ...open, firstTokenTime: event.time } };
81
+ }
82
+ case 'assistant/message': {
83
+ const open = state.openStep;
84
+ if (open === null || open.turn !== event.data.turn || open.step !== event.data.step)
85
+ return state;
86
+ // One assembled message per step: closing the boundary means a
87
+ // defensive duplicate cannot accrue twice.
88
+ const next = {
89
+ ...state,
90
+ llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
91
+ openStep: null,
92
+ };
93
+ if (open.firstTokenTime !== null) {
94
+ next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime);
95
+ next.ttftSteps += 1;
96
+ const outputTokens = usageOutputTokens(event.data.usage);
97
+ if (outputTokens !== null) {
98
+ next.decodeMs += Math.max(0, event.time - open.firstTokenTime);
99
+ next.decodeTokens += outputTokens;
100
+ }
101
+ }
102
+ return next;
103
+ }
104
+ case 'tool/call':
105
+ return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } };
106
+ case 'tool/result': {
107
+ // Own-key check: callId is provider-minted (model/tool JSON boundary),
108
+ // so a prototype property name ('constructor', 'toString') on a result
109
+ // with no recorded call must read as unmatched, not as an inherited
110
+ // function that would poison toolMs with NaN.
111
+ const callId = event.data.message.source.callId;
112
+ const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : undefined;
113
+ if (dispatched === undefined)
114
+ return state;
115
+ const pendingCalls = Object.fromEntries(Object.entries(state.pendingCalls).filter(([id]) => id !== callId));
116
+ return { ...state, toolMs: state.toolMs + Math.max(0, event.time - dispatched), pendingCalls };
117
+ }
118
+ case 'step/end':
119
+ return {
120
+ ...state,
121
+ turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
122
+ steps: state.steps + 1,
123
+ lastTurn: event.data.turn,
124
+ openStep: null,
125
+ };
126
+ case 'turn/end':
127
+ // A call whose result never landed belongs to a cancelled or failed
128
+ // turn; results always land within their turn, so drop the leftovers
129
+ // instead of growing persisted state forever.
130
+ return Object.keys(state.pendingCalls).length === 0 ? state : { ...state, pendingCalls: {} };
131
+ default:
132
+ return state;
133
+ }
134
+ },
135
+ view: state => ({
136
+ turns: state.turns,
137
+ steps: state.steps,
138
+ llmMs: state.llmMs,
139
+ toolMs: state.toolMs,
140
+ ttftMs: state.ttftMs,
141
+ ttftSteps: state.ttftSteps,
142
+ decodeMs: state.decodeMs,
143
+ decodeTokens: state.decodeTokens,
144
+ }),
145
+ stateVersion: 1,
146
+ };
147
+ //# 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 @deepseek-ai/dsh-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 '@deepseek-ai/dsh-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 @deepseek-ai/dsh-session-stats/types
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@deepseek-ai/dsh-session-stats",
3
+ "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness",
4
+ "version": "0.0.1-rc.5",
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/session/session-stats"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./types": {
26
+ "types": "./lib/types/types.d.ts",
27
+ "default": "./lib/types/types.js"
28
+ },
29
+ "./client": {
30
+ "types": "./lib/types/client.d.ts",
31
+ "default": "./lib/types/client.js"
32
+ },
33
+ "./src/*": "./src/*",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "files": [
37
+ "lib/index.js",
38
+ "lib/invariant.js",
39
+ "lib/types/**/*.js",
40
+ "lib/types/**/*.d.ts"
41
+ ],
42
+ "license": "BSD-3-Clause",
43
+ "peerDependencies": {
44
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.5",
45
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.5",
46
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.5",
47
+ "@deepseek-ai/dsh-session-projection": "^0.0.1-rc.5",
48
+ "@deepseek-ai/cordis": "^4.0.1-rc.4"
49
+ },
50
+ "dependencies": {
51
+ "zod": "^4.4.3"
52
+ },
53
+ "devDependencies": {
54
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.2-rc.4",
55
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.5",
56
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.5",
57
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.5",
58
+ "@deepseek-ai/dsh-session-projection": "^0.0.1-rc.5",
59
+ "@deepseek-ai/cordis": "^4.0.1-rc.4",
60
+ "@deepseek-ai/cordis-plugin-include": "^1.0.6-rc.4"
61
+ }
62
+ }