dsh-plugin-usage-stats 0.4.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/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "dsh-plugin-usage-stats",
3
+ "version": "0.4.0",
4
+ "type": "module",
5
+ "description": "DSH (DeepSeek Harness) Web GUI plugin: global model token usage stats as a dedicated \"Token 用量\" page in Settings — incremental session-file scanning with a persistent fold cache, date-range picker with presets, model/provider filters, per-day and per-model breakdowns, cache hit rate, and optional cost via a configurable price table. Per-session stats are built into the host; read-only.",
6
+ "keywords": [
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "dsh-plugin",
10
+ "usage",
11
+ "statistics",
12
+ "tokens",
13
+ "cache-hit-rate"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "ksxh0524",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ksxh0524/dsh-plugin-usage-stats.git"
20
+ },
21
+ "main": "src/cordis.ts",
22
+ "exports": {
23
+ ".": "./src/cordis.ts",
24
+ "./client": "./lib/client.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "lib",
30
+ "tests",
31
+ "cordis.patch.yml",
32
+ "README.md",
33
+ "README.zh.md"
34
+ ],
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "inject": [
41
+ "@deepseek-ai/dsh-api-remotes",
42
+ "@deepseek-ai/dsh-client-ui-settings"
43
+ ],
44
+ "platform": "web"
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@commitlint/cli": "^21.0.2",
49
+ "@commitlint/config-conventional": "^21.0.2",
50
+ "@types/node": "^24.2.0",
51
+ "husky": "^9.1.7",
52
+ "lint-staged": "^15.2.10",
53
+ "prettier": "^3.8.4",
54
+ "typescript": "^5.9.2"
55
+ },
56
+ "engines": {
57
+ "node": ">=22.18.0"
58
+ },
59
+ "scripts": {
60
+ "prepare": "husky",
61
+ "test": "node --test tests/*.test.ts",
62
+ "typecheck": "tsc --noEmit",
63
+ "format": "prettier --write .",
64
+ "format:check": "prettier --check .",
65
+ "check": "pnpm format:check && pnpm typecheck && pnpm test"
66
+ },
67
+ "lint-staged": {
68
+ "*.{ts,tsx,js,mjs,cjs,json,jsonc,md,yml,yaml,html}": "prettier --write"
69
+ },
70
+ "packageManager": "pnpm@11.5.3"
71
+ }
@@ -0,0 +1,175 @@
1
+ /** 聚合层:增量扫描调度(状态经 store.ts 持久化)+ 全局总计(四路 token / 命中率 / byModel),
2
+ * overview 支持 model/provider 过滤(fact 层先过滤再聚合,与时间过滤同构)。
3
+ * 所有导出结构保持 JSON-safe,直接作为 Remote 返回值。宿主自带会话统计(轮/步/tok·s/缓存),
4
+ * 本包只做全局视角;v4 起 UI 不再展示消息数/按天/费用,对应 API 字段同步移除(不留死契约)。 */
5
+ import { stat } from "node:fs/promises";
6
+ import { listSessionFiles, type Fold, type UsageFact } from "./scanner.ts";
7
+ import { FoldStore } from "./store.ts";
8
+
9
+ export interface ScanResult {
10
+ folds: Fold[];
11
+ files: number;
12
+ reloaded: number;
13
+ }
14
+
15
+ /** 扫描全部会话:未变文件复用持久行(0 解压),追加文件只解新增帧,其余整文件重解;
16
+ * 每文件之间让出事件循环,避免首扫长时间占用宿主。 */
17
+ export async function scanFolds(root: string, store: FoldStore): Promise<ScanResult> {
18
+ await store.load();
19
+ const files = await listSessionFiles(root);
20
+ let reloaded = 0;
21
+ const folds: Fold[] = [];
22
+ const alive = new Set<string>();
23
+ for (const file of files) {
24
+ let s;
25
+ try {
26
+ s = await stat(file);
27
+ } catch {
28
+ continue;
29
+ }
30
+ alive.add(file);
31
+ const before = store.rows.get(file);
32
+ try {
33
+ folds.push(await store.foldFor(file, { size: s.size, mtimeMs: s.mtimeMs, ino: s.ino }));
34
+ } catch {
35
+ folds.push({ facts: [], meta: null });
36
+ }
37
+ const after = store.rows.get(file);
38
+ if (after && (!before || before.size !== after.size || before.bytes !== after.bytes)) reloaded++;
39
+ await new Promise((r) => setImmediate(r));
40
+ }
41
+ await store.flush(alive);
42
+ return { folds, files: files.length, reloaded };
43
+ }
44
+
45
+ export interface Range {
46
+ from?: string;
47
+ to?: string;
48
+ /** 精确模型匹配("provider/model" 全键或裸 model 键)。 */
49
+ model?: string;
50
+ /** 服务商前缀匹配("provider/" 之前的字段)。 */
51
+ provider?: string;
52
+ }
53
+
54
+ export function normalizeRange(raw: unknown): Range {
55
+ const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
56
+ const out: Range = {};
57
+ const ok = (v: unknown) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
58
+ if (ok(r.from)) out.from = String(r.from);
59
+ if (ok(r.to)) out.to = String(r.to);
60
+ if (out.from && out.to && out.from > out.to) {
61
+ const t = out.from;
62
+ out.from = out.to;
63
+ out.to = t;
64
+ }
65
+ if (typeof r.model === "string" && r.model.trim()) out.model = r.model.trim();
66
+ if (typeof r.provider === "string" && r.provider.trim()) out.provider = r.provider.trim();
67
+ return out;
68
+ }
69
+
70
+ function inRange(date: string, range: Range): boolean {
71
+ if (range.from && date < range.from) return false;
72
+ if (range.to && date > range.to) return false;
73
+ return true;
74
+ }
75
+
76
+ /** 模型/服务商过滤在 fact 层生效(全键 "prov/model" 精确,裸 model 兜底;provider 全等段匹配)。 */
77
+ function matchesDims(f: UsageFact, range: Range): boolean {
78
+ if (range.provider && f.provider !== range.provider) return false;
79
+ if (range.model && f.model !== range.model && `${f.provider}/${f.model}` !== range.model) return false;
80
+ return true;
81
+ }
82
+
83
+ function collectFacts(folds: Fold[], range: Range): { facts: UsageFact[]; metaById: Map<string, Fold["meta"]> } {
84
+ const facts: UsageFact[] = [];
85
+ const metaById = new Map<string, Fold["meta"]>();
86
+ const noDim = !range.model && !range.provider;
87
+ for (const fold of folds) {
88
+ for (const f of fold.facts) {
89
+ if (!inRange(f.date, range)) continue;
90
+ if (noDim) {
91
+ facts.push(f);
92
+ if (fold.meta && !metaById.has(f.sessionId)) metaById.set(f.sessionId, fold.meta);
93
+ } else if (matchesDims(f, range)) {
94
+ facts.push(f);
95
+ // 维度过滤后,消息计数只统计「命中维度」的会话(否则口径与过滤后 totals 不一致)。
96
+ if (fold.meta && !metaById.has(f.sessionId)) metaById.set(f.sessionId, null);
97
+ }
98
+ }
99
+ }
100
+ return { facts, metaById };
101
+ }
102
+
103
+ export interface Totals {
104
+ requests: number;
105
+ input: number;
106
+ output: number;
107
+ cacheRead: number;
108
+ cacheWrite: number;
109
+ total: number;
110
+ /** 窗口内是否有任一 usage 样本上报过缓存字段(未上报 → 命中率无口径,显示“—”)。 */
111
+ reportsCache: boolean;
112
+ }
113
+
114
+ export function emptyTotals(): Totals {
115
+ return { requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, reportsCache: false };
116
+ }
117
+
118
+ function add(t: Totals, f: UsageFact): void {
119
+ t.requests += 1;
120
+ t.input += f.input;
121
+ t.output += f.output;
122
+ t.cacheRead += f.cacheRead;
123
+ t.cacheWrite += f.cacheWrite;
124
+ t.total += f.input + f.output + f.cacheRead + f.cacheWrite;
125
+ if (f.hasCache) t.reportsCache = true;
126
+ }
127
+
128
+ /** 命中率 = cacheRead / (cacheRead + 未缓存输入);缓存字段未上报的口径返回 null(UI 显示“—”)。 */
129
+ export function hitRate(t: Totals): number | null {
130
+ if (!t.reportsCache) return null;
131
+ const denom = t.cacheRead + t.input;
132
+ if (denom <= 0) return null;
133
+ return t.cacheRead / denom;
134
+ }
135
+
136
+ export interface ModelRow extends Totals {
137
+ provider: string;
138
+ model: string;
139
+ key: string;
140
+ hitRate: number | null;
141
+ }
142
+
143
+ export interface Overview {
144
+ totals: Totals;
145
+ hitRate: number | null;
146
+ byModel: ModelRow[];
147
+ sessionCount: number;
148
+ scannedFiles: number;
149
+ generatedAt: number;
150
+ }
151
+
152
+ export function buildOverview(folds: Fold[], range: Range): Overview {
153
+ const { facts, metaById } = collectFacts(folds, range);
154
+ const totals = emptyTotals();
155
+ const modelRows = new Map<string, ModelRow>();
156
+ for (const f of facts) {
157
+ add(totals, f);
158
+ const key = `${f.provider}/${f.model}`;
159
+ let m = modelRows.get(key);
160
+ if (!m) {
161
+ m = { ...emptyTotals(), provider: f.provider, model: f.model, key, hitRate: null };
162
+ modelRows.set(key, m);
163
+ }
164
+ add(m, f);
165
+ }
166
+ const byModel = [...modelRows.values()].map((m) => ({ ...m, hitRate: hitRate(m) })).sort((a, b) => b.total - a.total);
167
+ return {
168
+ totals,
169
+ hitRate: hitRate(totals),
170
+ byModel,
171
+ sessionCount: metaById.size || new Set(facts.map((f) => f.sessionId)).size,
172
+ scannedFiles: folds.length,
173
+ generatedAt: Date.now(),
174
+ };
175
+ }
package/src/cordis.ts ADDED
@@ -0,0 +1,72 @@
1
+ /** Cordis 入口:注册 `usageStats` 服务(Typert Gateway 只读 Remote:overview 单方法)。
2
+ * 会话级视图宿主自带(底部计数条 + 会话统计对话框),本包只做全局汇总——不预留下钻接口。
3
+ *
4
+ * 设计约束(勿改成 import 官方包):
5
+ * - 本包运行在宿主 node 进程里,但被 pnpm 链接在文件目录下,若 import
6
+ * `@deepseek-ai/dsh-typert-protocol` / `@deepseek-ai/cordis` 会解析出与宿主不同的第二副本,
7
+ * Service 原型链与 instanceof 判定跨副本失效(零依赖铁律)。
8
+ * - 因此这里按协议文档的形态手工落两件套:
9
+ * ① 实例字段 `typertRemote = { service, serviceKey, namespace }`(gateway validateBinding 读它);
10
+ * ② 原型字符串键 `"@deepseek-ai/dsh-typert-protocol/remote-methods"`(remoteMethods() 跨副本可读,
11
+ * version 1 校验通过即可被 collectSrcClaims 认领 SRC endpoint)。
12
+ * - 服务注册走 `ctx.reflect.provide(name, instance)`,与 Service 基类构造函数所做的事等价,
13
+ * fiber 卸载时自动注销。
14
+ * - SRC 模式参数约束:方法参数必须是不带默认值/解构/rest 的单一标识符(gateway 取函数源码切分参数名,
15
+ * 客户端 contribution descriptor 的 wire 名与之一一对应)。
16
+ * 实测(dsh-api-gateway methodParameterNames + Node type-strip 行为):`: unknown` 这类简单类型注解
17
+ * strip 后替换为空白、解析时按 trim 保留标识符,可安全携带(tsc strict 需要);默认值/解构/rest 禁止。
18
+ */
19
+ import { buildOverview, normalizeRange, scanFolds } from "./aggregate.ts";
20
+ import { sessionsRoot } from "./scanner.ts";
21
+ import { FoldStore } from "./store.ts";
22
+
23
+ export type CordisConfig = {
24
+ /** 会话根目录覆盖(默认 $DSH_HOME/sessions 或 ~/.dsh/sessions)。 */
25
+ sessionsHome?: string;
26
+ };
27
+
28
+ const REMOTE_METHODS_KEY = "@deepseek-ai/dsh-typert-protocol/remote-methods";
29
+
30
+ class UsageStatsService {
31
+ ctx: any;
32
+ config: CordisConfig;
33
+ typertRemote: { service: UsageStatsService; serviceKey: string; namespace: string };
34
+ /** 增量扫描存量(懒初始化:sessions 根要到首次查询才确定)。 */
35
+ private store: FoldStore | null = null;
36
+
37
+ constructor(ctx: any, config: CordisConfig) {
38
+ this.ctx = ctx;
39
+ this.config = config || {};
40
+ this.typertRemote = Object.freeze({ service: this, serviceKey: "usageStats", namespace: "usageStats" });
41
+ }
42
+
43
+ /** 全局汇总视图:`{ from?, to?, model?, provider? }`(日期 YYYY-MM-DD 本地时区),缺省全部。 */
44
+ async overview(filter: unknown) {
45
+ const root = sessionsRoot(this.config.sessionsHome);
46
+ if (!this.store) this.store = new FoldStore(root);
47
+ const { folds } = await scanFolds(root, this.store);
48
+ return buildOverview(folds, normalizeRange(filter));
49
+ }
50
+ }
51
+
52
+ /** 手写 SRC Remote 标记(形态 = typert-protocol mark() 产物:{version:1, methods:[...]})。 */
53
+ Object.defineProperty(UsageStatsService.prototype, REMOTE_METHODS_KEY, {
54
+ configurable: true,
55
+ value: Object.freeze({
56
+ version: 1,
57
+ methods: Object.freeze([Object.freeze({ method: "overview", invocation: Object.freeze({ kind: "direct" }) })]),
58
+ }),
59
+ });
60
+
61
+ export const name = "usage-stats";
62
+ export const inject: string[] = [];
63
+
64
+ export function applyCordis(ctx: any, config?: CordisConfig) {
65
+ const service = new UsageStatsService(ctx, config || {});
66
+ ctx.reflect.provide("usageStats", service);
67
+ ctx.logger?.info?.("[plugin-usage-stats] usageStats remote online");
68
+ return service;
69
+ }
70
+
71
+ export default { name, inject, apply: applyCordis };
72
+ export { UsageStatsService };