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/LICENSE +21 -0
- package/README.md +51 -0
- package/README.zh.md +51 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +716 -0
- package/package.json +71 -0
- package/src/aggregate.ts +175 -0
- package/src/cordis.ts +72 -0
- package/src/scanner.ts +391 -0
- package/src/store.ts +152 -0
- package/tests/contract-pair.test.ts +108 -0
- package/tests/cordis.test.ts +96 -0
- package/tests/fold.test.ts +122 -0
- package/tests/realscan.test.ts +24 -0
- package/tests/scan.test.ts +168 -0
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/** 会话用量扫描(纯函数核心,零第三方依赖):
|
|
2
|
+
* 读 `<DSH_HOME>/sessions/<workspace-slug>/<session-dir>/session.v3.jsonl.zstd`,
|
|
3
|
+
* zstd 解压后逐行折叠成 UsageFact[]。
|
|
4
|
+
*
|
|
5
|
+
* 折叠规则(与设计文档一致):
|
|
6
|
+
* - 当前模型 = 最近一条 request/header 的 data.header.config.{provider,model};缺 header 归 unknown。
|
|
7
|
+
* - 每条 assistant/message.data.usage 归入 scope `<sessionId>:<turn>:<step>`(turn/step 由最近
|
|
8
|
+
* turn/start / step/start 记录维护);同 scope 出现新样本即替换旧样本 = 重试取末条(与 token-meter 同规则)。
|
|
9
|
+
* - inputTokens 为「未缓存输入」(total = input + output + cacheRead [+ cacheWrite] 已在真实数据核实)。
|
|
10
|
+
* - session 行补 cwd/createdAt/delegationDepth/origin;session/title 行补标题。
|
|
11
|
+
*
|
|
12
|
+
* 增量解码(会话文件是追加式多帧 zstd,每次全量重解是浪费):
|
|
13
|
+
* - `frameSpans` 按 RFC 8878 解析帧头与块头(块体按 24bit Block_Size 直接跳过,不碰 LZ4),
|
|
14
|
+
* 得到完整帧的字节区间;文件尾半帧不算数(下次追加补齐再吃),legacy 帧/坏帧即停。
|
|
15
|
+
* - `foldChunk` 把一段解压文本喂进可续跑的 FoldState(游标/计数/facts 按 scope 去重 + 残行 tail),
|
|
16
|
+
* 增量重放与全量重放逐字节等价(scope last-wins 天然幂等,计数只增)。
|
|
17
|
+
* - 持久化与失效判定在 store.ts / aggregate.ts;本文件只保证纯函数 + 解码边界正确。
|
|
18
|
+
*/
|
|
19
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
20
|
+
import { execFile, spawn } from "node:child_process";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
25
|
+
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
/** 多帧 zstd 全量解码:首选 `zstd -dc`;node:zlib 兜底仅在无 CLI 时启用(它只解首帧,多帧文件会静默丢数据)。 */
|
|
29
|
+
async function decompressZstd(path: string): Promise<string> {
|
|
30
|
+
try {
|
|
31
|
+
const { stdout } = await execFileAsync("zstd", ["-dc", path], { maxBuffer: 256 * 1024 * 1024 });
|
|
32
|
+
return stdout;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
35
|
+
const buf = await readFile(path);
|
|
36
|
+
return zstdDecompressSync(buf).toString("utf8");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UsageFact {
|
|
41
|
+
/** `<sessionId>:<turn>:<step>`,同 scope 只保留末条样本。 */
|
|
42
|
+
scope: string;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
provider: string;
|
|
45
|
+
model: string;
|
|
46
|
+
/** YYYY-MM-DD(本地时区,按记录 time)。 */
|
|
47
|
+
date: string;
|
|
48
|
+
/** 记录时间(ms epoch)。 */
|
|
49
|
+
time: number;
|
|
50
|
+
input: number;
|
|
51
|
+
output: number;
|
|
52
|
+
cacheRead: number;
|
|
53
|
+
cacheWrite: number;
|
|
54
|
+
/** usage 对象是否上报过任一缓存字段(未上报的 provider 命中率应显示“—”而非 0%)。 */
|
|
55
|
+
hasCache: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SessionMeta {
|
|
59
|
+
sessionId: string;
|
|
60
|
+
cwd: string;
|
|
61
|
+
title: string;
|
|
62
|
+
createdAt: number;
|
|
63
|
+
delegationDepth: number;
|
|
64
|
+
/** 子代理会话(origin/parentSession/delegationDepth>0 任一命中)。 */
|
|
65
|
+
subagent: boolean;
|
|
66
|
+
/** 会话级计数(逐行顺带 O(1),不做去重):user/message 行数。 */
|
|
67
|
+
userMessages: number;
|
|
68
|
+
/** assistant/message 行数(无 usage 样本的也计)。 */
|
|
69
|
+
assistantMessages: number;
|
|
70
|
+
/** tool/call 行数。 */
|
|
71
|
+
toolCalls: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Fold {
|
|
75
|
+
facts: UsageFact[];
|
|
76
|
+
meta: SessionMeta | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 可续跑的折叠状态:游标(模型归因/turn/step)+ 单调计数 + scope 去重的 facts + 半行 tail。
|
|
80
|
+
* 持久化时 scopeIndex 不写盘(恢复时从 facts 重建)。 */
|
|
81
|
+
export interface FoldState {
|
|
82
|
+
meta: SessionMeta | null;
|
|
83
|
+
provider: string;
|
|
84
|
+
model: string;
|
|
85
|
+
curTurn: number;
|
|
86
|
+
curStep: number;
|
|
87
|
+
facts: UsageFact[];
|
|
88
|
+
scopeIndex: Map<string, number>;
|
|
89
|
+
/** 上一段末尾未完成的 JSONL 行(追加式写入可能把一行拆进两帧),下一段拼回头部。 */
|
|
90
|
+
tail: string;
|
|
91
|
+
/** 会话 id 兜底(文件路径的上级目录名)。 */
|
|
92
|
+
sessionIdHint: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function newFoldState(sessionIdHint: string): FoldState {
|
|
96
|
+
return {
|
|
97
|
+
meta: null,
|
|
98
|
+
provider: "unknown",
|
|
99
|
+
model: "unknown",
|
|
100
|
+
curTurn: -1,
|
|
101
|
+
curStep: -1,
|
|
102
|
+
facts: [],
|
|
103
|
+
scopeIndex: new Map(),
|
|
104
|
+
tail: "",
|
|
105
|
+
sessionIdHint,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function foldSnapshot(s: FoldState): Fold {
|
|
110
|
+
return { facts: s.facts, meta: s.meta };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 喂入一段解压文本(允许半行截断,残尾进 tail 续下段)。JSON 解析失败行静默跳过(与全量口径一致)。 */
|
|
114
|
+
export function foldChunk(s: FoldState, text: string): void {
|
|
115
|
+
let buf = s.tail ? s.tail + text : text;
|
|
116
|
+
const nl = buf.lastIndexOf("\n");
|
|
117
|
+
if (nl < 0) {
|
|
118
|
+
s.tail = buf;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
s.tail = buf.slice(nl + 1);
|
|
122
|
+
buf = buf.slice(0, nl);
|
|
123
|
+
for (const line of buf.split("\n")) {
|
|
124
|
+
if (!line || !line.trim()) continue;
|
|
125
|
+
let r: any;
|
|
126
|
+
try {
|
|
127
|
+
r = JSON.parse(line);
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!r || typeof r !== "object") continue;
|
|
132
|
+
const t = r.type;
|
|
133
|
+
if (t === "session") {
|
|
134
|
+
// 新会话开始:模型归因状态重置(每文件一会话,防御跨会话残留)。
|
|
135
|
+
s.provider = "unknown";
|
|
136
|
+
s.model = "unknown";
|
|
137
|
+
s.curTurn = -1;
|
|
138
|
+
s.curStep = -1;
|
|
139
|
+
const depth = Number(r.delegationDepth) || 0;
|
|
140
|
+
s.meta = {
|
|
141
|
+
sessionId: String(r.id || s.sessionIdHint),
|
|
142
|
+
cwd: String(r.cwd || ""),
|
|
143
|
+
title: "",
|
|
144
|
+
createdAt: Number(r.createdAt) || 0,
|
|
145
|
+
delegationDepth: depth,
|
|
146
|
+
subagent: r.origin != null || r.parentSession != null || depth > 0,
|
|
147
|
+
userMessages: 0,
|
|
148
|
+
assistantMessages: 0,
|
|
149
|
+
toolCalls: 0,
|
|
150
|
+
};
|
|
151
|
+
} else if (t === "session/title") {
|
|
152
|
+
if (s.meta && r.data && typeof r.data.title === "string") s.meta.title = r.data.title;
|
|
153
|
+
} else if (t === "user/message") {
|
|
154
|
+
if (s.meta) s.meta.userMessages++;
|
|
155
|
+
} else if (t === "tool/call") {
|
|
156
|
+
if (s.meta) s.meta.toolCalls++;
|
|
157
|
+
} else if (t === "request/header") {
|
|
158
|
+
const c = r.data?.header?.config;
|
|
159
|
+
if (c && typeof c.model === "string") {
|
|
160
|
+
s.provider = String(c.provider || "unknown");
|
|
161
|
+
s.model = c.model;
|
|
162
|
+
}
|
|
163
|
+
} else if (t === "turn/start") {
|
|
164
|
+
s.curTurn = Number(r.data?.turn) || 0;
|
|
165
|
+
s.curStep = -1;
|
|
166
|
+
} else if (t === "step/start") {
|
|
167
|
+
s.curTurn = Number(r.data?.turn) || s.curTurn;
|
|
168
|
+
s.curStep = Number(r.data?.step) || 0;
|
|
169
|
+
} else if (t === "assistant/message") {
|
|
170
|
+
if (s.meta) s.meta.assistantMessages++;
|
|
171
|
+
const u = r.data?.usage;
|
|
172
|
+
if (!u || typeof u.inputTokens !== "number" || typeof u.outputTokens !== "number") continue;
|
|
173
|
+
if (typeof r.time !== "number" || !s.meta) continue;
|
|
174
|
+
const scope = `${s.meta.sessionId}:${s.curTurn}:${s.curStep}`;
|
|
175
|
+
const fact: UsageFact = {
|
|
176
|
+
scope,
|
|
177
|
+
sessionId: s.meta.sessionId,
|
|
178
|
+
provider: s.provider,
|
|
179
|
+
model: s.model,
|
|
180
|
+
date: localDate(r.time),
|
|
181
|
+
time: r.time,
|
|
182
|
+
input: Number(u.inputTokens) || 0,
|
|
183
|
+
output: Number(u.outputTokens) || 0,
|
|
184
|
+
cacheRead: Number(u.cacheReadTokens) || 0,
|
|
185
|
+
cacheWrite: Number(u.cacheWriteTokens) || 0,
|
|
186
|
+
hasCache: u.cacheReadTokens != null || u.cacheWriteTokens != null,
|
|
187
|
+
};
|
|
188
|
+
const prev = s.scopeIndex.get(scope);
|
|
189
|
+
if (prev !== undefined) s.facts[prev] = fact;
|
|
190
|
+
else {
|
|
191
|
+
s.scopeIndex.set(scope, s.facts.length);
|
|
192
|
+
s.facts.push(fact);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 折叠一个会话的 JSONL 文本为 UsageFact[] + SessionMeta(纯函数,可单测;= 全量 foldChunk 薄包装 + 尾行结算)。 */
|
|
199
|
+
export function foldJsonl(sessionIdHint: string, text: string): Fold {
|
|
200
|
+
const s = newFoldState(sessionIdHint);
|
|
201
|
+
foldChunk(s, text);
|
|
202
|
+
if (s.tail) foldChunk(s, "\n"); // 无换行结尾的最后一行也要结算(旧 split 语义等价)
|
|
203
|
+
return foldSnapshot(s);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const FILE_NAME = "session.v3.jsonl.zstd";
|
|
207
|
+
|
|
208
|
+
/** DSH 会话根目录:config.sessionsHome 优先,其次 $DSH_HOME/sessions,最后 ~/.dsh/sessions。 */
|
|
209
|
+
export function sessionsRoot(sessionsHome?: string): string {
|
|
210
|
+
if (sessionsHome) return sessionsHome;
|
|
211
|
+
const dshHome = process.env.DSH_HOME;
|
|
212
|
+
if (dshHome) return join(dshHome, "sessions");
|
|
213
|
+
return join(homedir(), ".dsh", "sessions");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 列出全部 workspace slug 下的会话文件(全局视图),排序保证确定性。 */
|
|
217
|
+
export async function listSessionFiles(root: string): Promise<string[]> {
|
|
218
|
+
const out: string[] = [];
|
|
219
|
+
let slugs: string[] = [];
|
|
220
|
+
try {
|
|
221
|
+
slugs = await readdir(root);
|
|
222
|
+
} catch {
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
for (const slug of slugs) {
|
|
226
|
+
let dirs: string[] = [];
|
|
227
|
+
try {
|
|
228
|
+
dirs = await readdir(join(root, slug));
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
for (const dir of dirs) {
|
|
233
|
+
const full = join(root, slug, dir, FILE_NAME);
|
|
234
|
+
try {
|
|
235
|
+
const s = await stat(full);
|
|
236
|
+
if (s.isFile()) out.push(full);
|
|
237
|
+
} catch {
|
|
238
|
+
/* 非会话目录或缺文件:跳过 */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
out.sort();
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 毫秒 → 本地日期 YYYY-MM-DD。 */
|
|
247
|
+
export function localDate(ms: number): string {
|
|
248
|
+
const d = new Date(ms);
|
|
249
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
250
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ---------------- zstd 帧边界(RFC 8878:只读帧头/块头,不解压内容) ---------------- */
|
|
254
|
+
|
|
255
|
+
const FRAME_MAGIC = 0xfd2fb528;
|
|
256
|
+
const LEGACY_MAGICS = [0x1eb52ffd, 0x2fb52815, 0x24b52f6d];
|
|
257
|
+
|
|
258
|
+
/** 从 start 走一个 zstd 帧,返回结束字节;半帧/坏帧/legacy → null。
|
|
259
|
+
* 位布局以真帧反推 + fzstd 参考实现双重对表(勿凭 RFC 记忆改动):
|
|
260
|
+
* FHD: bit7-6=FCS_flag(字节数=flag?1<<flag:single) bit5=Single_Segment bit3=Unused(须0) bit2=Checksum bit1-0=Did_flag(0/1/2/4B)。
|
|
261
|
+
* Block 头(24bit LE): bit0=Last bit2-1=Type(0=Raws体size|1=RLE体1B重复值|2=Compressed体size|3=坏) bit23-3=Size(21bit)。 */
|
|
262
|
+
function frameEnd(buf: Buffer, start: number): number | null {
|
|
263
|
+
let off = start;
|
|
264
|
+
if (off + 5 > buf.length) return null;
|
|
265
|
+
const magic = buf.readUInt32LE(off);
|
|
266
|
+
if (magic !== FRAME_MAGIC || LEGACY_MAGICS.includes(magic)) return null;
|
|
267
|
+
off += 4;
|
|
268
|
+
const fhd = buf.readUInt8(off);
|
|
269
|
+
off += 1;
|
|
270
|
+
if (fhd & 0x08) return null;
|
|
271
|
+
const single = (fhd >> 5) & 1;
|
|
272
|
+
const didFlag = fhd & 3;
|
|
273
|
+
const checksum = (fhd >> 2) & 1;
|
|
274
|
+
const fcsFlag = fhd >> 6;
|
|
275
|
+
if (!single) off += 1; // Window_Descriptor
|
|
276
|
+
off += (fcsFlag ? 1 << fcsFlag : single) + (didFlag === 3 ? 4 : didFlag);
|
|
277
|
+
for (;;) {
|
|
278
|
+
if (off + 3 > buf.length) return null;
|
|
279
|
+
const bh = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16);
|
|
280
|
+
off += 3;
|
|
281
|
+
const last = (bh & 1) !== 0;
|
|
282
|
+
const type = (bh >> 1) & 3;
|
|
283
|
+
const size = bh >>> 3;
|
|
284
|
+
if (type === 3) return null;
|
|
285
|
+
if (type === 1) {
|
|
286
|
+
if (off + 1 > buf.length) return null; // RLE:1 字节重复值
|
|
287
|
+
off += 1;
|
|
288
|
+
} else {
|
|
289
|
+
if (off + size > buf.length) return null; // Raws/Compressed:体长 = size
|
|
290
|
+
off += size;
|
|
291
|
+
}
|
|
292
|
+
if (last) break;
|
|
293
|
+
}
|
|
294
|
+
if (checksum) off += 4;
|
|
295
|
+
return off <= buf.length ? off : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 完整帧区间表(增量解码的水位线依据)。 */
|
|
299
|
+
export function frameSpans(buf: Buffer): Array<[number, number]> {
|
|
300
|
+
const spans: Array<[number, number]> = [];
|
|
301
|
+
let off = 0;
|
|
302
|
+
for (;;) {
|
|
303
|
+
const end = frameEnd(buf, off);
|
|
304
|
+
if (end === null || end <= off) break;
|
|
305
|
+
spans.push([off, end]);
|
|
306
|
+
off = end;
|
|
307
|
+
}
|
|
308
|
+
return spans;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 已消化完整帧的字节水位(= frameSpans 末区间终点;无完整帧为 0)。 */
|
|
312
|
+
export function frameWatermark(buf: Buffer): number {
|
|
313
|
+
const spans = frameSpans(buf);
|
|
314
|
+
return spans.length ? spans[spans.length - 1][1] : 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function hintOf(path: string): string {
|
|
318
|
+
const parts = path.split("/");
|
|
319
|
+
return parts[parts.length - 2] || "unknown";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 读单个会话文件并全量折叠(首轮/失效重扫路径)。解码失败上抛,空 fold 兜底在 store 层决策。 */
|
|
323
|
+
export async function readFold(path: string): Promise<Fold> {
|
|
324
|
+
return foldJsonl(hintOf(path), await decompressZstd(path));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 从持久 FoldState 续建运行时状态(scopeIndex 重建)。 */
|
|
328
|
+
export function reviveState(hint: string, saved: Pick<FoldState, "meta" | "provider" | "model" | "curTurn" | "curStep" | "facts" | "tail">): FoldState {
|
|
329
|
+
const s = newFoldState(hint);
|
|
330
|
+
s.meta = saved.meta;
|
|
331
|
+
s.provider = saved.provider;
|
|
332
|
+
s.model = saved.model;
|
|
333
|
+
s.curTurn = saved.curTurn;
|
|
334
|
+
s.curStep = saved.curStep;
|
|
335
|
+
s.facts = saved.facts;
|
|
336
|
+
s.tail = saved.tail || "";
|
|
337
|
+
for (let i = 0; i < s.facts.length; i++) s.scopeIndex.set(s.facts[i].scope, i);
|
|
338
|
+
return s;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 增量续跑:把 `fromBytes`(上次完整帧水位)之后的新增帧并进 state。
|
|
342
|
+
* @returns 新水位;无新增完整帧返回原值。解码失败抛错(调用方回退全量)。 */
|
|
343
|
+
export async function foldFrom(path: string, state: FoldState, fromBytes: number): Promise<number> {
|
|
344
|
+
const buf = await readFile(path);
|
|
345
|
+
const spans = frameSpans(buf);
|
|
346
|
+
const watermark = spans.length ? spans[spans.length - 1][1] : 0;
|
|
347
|
+
if (watermark <= fromBytes) return fromBytes;
|
|
348
|
+
const fresh = spans.filter(([s]) => s >= fromBytes);
|
|
349
|
+
const text = await decompressSpans(buf, fresh);
|
|
350
|
+
foldChunk(state, text);
|
|
351
|
+
return watermark;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 新增帧区间解压:CLI(stdin 吃拼接帧)优先;无 CLI 时 node:zlib 按帧逐个解。
|
|
355
|
+
* zstdCliMissing 一旦置真(本机没装 CLI)不再重试;输出超限等运行期错误照常抛,由调用方回退全量。 */
|
|
356
|
+
let zstdCliMissing = false;
|
|
357
|
+
async function decompressSpans(buf: Buffer, spans: Array<[number, number]>): Promise<string> {
|
|
358
|
+
if (!zstdCliMissing) {
|
|
359
|
+
try {
|
|
360
|
+
const seg = Buffer.concat(spans.map(([s, e]) => buf.subarray(s, e)));
|
|
361
|
+
return await zstdStdin(seg);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") zstdCliMissing = true;
|
|
364
|
+
else throw err;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
let out = "";
|
|
368
|
+
for (const [s, e] of spans) out += zstdDecompressSync(buf.subarray(s, e)).toString("utf8");
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** `zstd -dc` 子进程喂 stdin(execFile 的 input 选项不在 @types 类型里,自管流 + 尺寸护栏)。 */
|
|
373
|
+
function zstdStdin(seg: Buffer): Promise<string> {
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
const child = spawn("zstd", ["-dc"]);
|
|
376
|
+
let out = "";
|
|
377
|
+
let err = "";
|
|
378
|
+
child.stdout.setEncoding("utf8");
|
|
379
|
+
child.stdout.on("data", (d: string) => {
|
|
380
|
+
out += d;
|
|
381
|
+
if (out.length > 256 * 1024 * 1024) {
|
|
382
|
+
child.kill();
|
|
383
|
+
reject(new Error("zstd output exceeds 256MB guard"));
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
child.stderr.on("data", (d: string) => (err += d));
|
|
387
|
+
child.on("error", reject);
|
|
388
|
+
child.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(`zstd exit ${code}: ${err.slice(0, 200)}`))));
|
|
389
|
+
child.stdin.end(seg);
|
|
390
|
+
});
|
|
391
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** 增量扫描的存量持久化:折叠状态落 `<DSH_HOME>/cache/usage-stats.folds.json`。
|
|
2
|
+
* 语义 = 每个会话文件一行 {file, size, mtimeMs, ino, bytes(已消化完整帧水位), FoldState 快照};
|
|
3
|
+
* host 重启后:未变文件 0 解压直接复用,追加文件只解新增帧(foldFrom),
|
|
4
|
+
* size 变小 / inode 变化(文件被替换)→ 该行失效整文件重解。
|
|
5
|
+
* 水位铁律:bytes 只能是 frameWatermark 的真实完整帧边界(文件尾半帧不计),
|
|
6
|
+
* 否则追加后跨水位的帧会被漏掉;半帧尾巴等下次追加补齐。
|
|
7
|
+
* 写盘策略 = 单次扫描若有推进则 tmp+rename 原子落一次;坏文件(JSON 解析失败)静默作废从零开始。
|
|
8
|
+
* ⚠ 版本铁律:foldJsonl/foldFrom/归因语义任何变化必须 bump VERSION——旧快照里冻结的是
|
|
9
|
+
* 当时算出的 facts,永不回改(实测教训:scanner 早期误产 unknown-model facts,被增量状态
|
|
10
|
+
* 带病复用,页面 unknown 行在逻辑修复后仍不消失,只能靠版本门整体作废)。 */
|
|
11
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { foldFrom, frameWatermark, hintOf, readFold, reviveState, type Fold, type FoldState } from "./scanner.ts";
|
|
14
|
+
|
|
15
|
+
/** 存量格式+折叠语义的联合版本:与磁盘 payload.version 不符 = 冷启动全量重扫。 */
|
|
16
|
+
export const VERSION = 2;
|
|
17
|
+
|
|
18
|
+
export interface UnitRow {
|
|
19
|
+
file: string;
|
|
20
|
+
size: number;
|
|
21
|
+
mtimeMs: number;
|
|
22
|
+
ino: number;
|
|
23
|
+
/** 已消化的完整帧字节水位;0 = 帧链不可识(legacy/坏头),每次走全量。 */
|
|
24
|
+
bytes: number;
|
|
25
|
+
meta: FoldState["meta"];
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
curTurn: number;
|
|
29
|
+
curStep: number;
|
|
30
|
+
facts: FoldState["facts"];
|
|
31
|
+
tail: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class FoldStore {
|
|
35
|
+
readonly file: string;
|
|
36
|
+
rows = new Map<string, UnitRow>();
|
|
37
|
+
private states = new Map<string, FoldState>();
|
|
38
|
+
private loaded = false;
|
|
39
|
+
dirty = false;
|
|
40
|
+
|
|
41
|
+
/** root = sessions 目录;cache 落其同级的 cache/ 下(sessionsHome 自定义时同样成立)。 */
|
|
42
|
+
constructor(root: string) {
|
|
43
|
+
this.file = join(dirname(root), "cache", "usage-stats.folds.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async load(): Promise<void> {
|
|
47
|
+
if (this.loaded) return;
|
|
48
|
+
this.loaded = true;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(await readFile(this.file, "utf8"));
|
|
51
|
+
if (parsed && parsed.version === VERSION && Array.isArray(parsed.units)) {
|
|
52
|
+
for (const u of parsed.units) if (u && typeof u.file === "string" && typeof u.bytes === "number") this.rows.set(u.file, u);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
/* 缺文件/坏文件 = 冷启动 */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 取得该文件最新折叠结果;内部按行状态决定 复用 / 增量 / 全量。 */
|
|
60
|
+
async foldFor(path: string, st: { size: number; mtimeMs: number; ino: number }): Promise<Fold> {
|
|
61
|
+
const row = this.rows.get(path);
|
|
62
|
+
if (row && row.ino === st.ino && row.size === st.size && row.mtimeMs === st.mtimeMs) {
|
|
63
|
+
return this.foldOf(row); // 未变:直接复用存量
|
|
64
|
+
}
|
|
65
|
+
if (row && row.ino === st.ino && row.bytes > 0 && st.size > row.size) {
|
|
66
|
+
// 追加增长:只解新增完整帧。水位没推进(文件尾半帧未补齐)也正常,等下次。
|
|
67
|
+
try {
|
|
68
|
+
const next = await foldFrom(path, this.stateOf(path, row), row.bytes);
|
|
69
|
+
if (next !== row.bytes) this.dirty = true;
|
|
70
|
+
row.bytes = next;
|
|
71
|
+
row.size = st.size;
|
|
72
|
+
row.mtimeMs = st.mtimeMs;
|
|
73
|
+
this.syncRow(path, row);
|
|
74
|
+
return this.foldOf(row);
|
|
75
|
+
} catch {
|
|
76
|
+
/* 增量失败:落到全量 */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 冷启动 / 文件被替换 / size 倒退 / 无水位:整文件重解,水位取真实帧边界(尾半帧留给下次)。
|
|
80
|
+
let fold: Fold;
|
|
81
|
+
try {
|
|
82
|
+
fold = await readFold(path);
|
|
83
|
+
} catch {
|
|
84
|
+
// 解码失败:不落行——增量续跑要求存量状态完整(首帧含 session/meta 行),坏文件每轮重试(与旧缓存语义一致)。
|
|
85
|
+
return { facts: [], meta: null };
|
|
86
|
+
}
|
|
87
|
+
const buf = await readFile(path).catch(() => null);
|
|
88
|
+
const wm = buf ? frameWatermark(buf) : 0;
|
|
89
|
+
const fresh: UnitRow = {
|
|
90
|
+
file: path,
|
|
91
|
+
size: st.size,
|
|
92
|
+
mtimeMs: st.mtimeMs,
|
|
93
|
+
ino: st.ino,
|
|
94
|
+
bytes: wm,
|
|
95
|
+
meta: fold.meta,
|
|
96
|
+
provider: "unknown",
|
|
97
|
+
model: "unknown",
|
|
98
|
+
curTurn: -1,
|
|
99
|
+
curStep: -1,
|
|
100
|
+
facts: fold.facts,
|
|
101
|
+
tail: "",
|
|
102
|
+
};
|
|
103
|
+
this.rows.set(path, fresh);
|
|
104
|
+
this.states.delete(path); // 全量重解后旧 state 作废,下次增量从快照重建
|
|
105
|
+
this.dirty = true;
|
|
106
|
+
return this.foldOf(fresh);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 增量成功后把 state 的游标/计数/facts 回写行(bytes 由调用处单独赋值)。 */
|
|
110
|
+
private syncRow(path: string, row: UnitRow): void {
|
|
111
|
+
const s = this.states.get(path);
|
|
112
|
+
if (!s) return;
|
|
113
|
+
row.meta = s.meta;
|
|
114
|
+
row.provider = s.provider;
|
|
115
|
+
row.model = s.model;
|
|
116
|
+
row.curTurn = s.curTurn;
|
|
117
|
+
row.curStep = s.curStep;
|
|
118
|
+
row.facts = s.facts;
|
|
119
|
+
row.tail = s.tail;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private stateOf(path: string, row: UnitRow): FoldState {
|
|
123
|
+
let s = this.states.get(path);
|
|
124
|
+
if (!s) {
|
|
125
|
+
s = reviveState(hintOf(path), row);
|
|
126
|
+
this.states.set(path, s);
|
|
127
|
+
}
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private foldOf(row: UnitRow): Fold {
|
|
132
|
+
// facts/meta 是共享引用;同一次扫描内消费方只读,安全。
|
|
133
|
+
return { facts: row.facts, meta: row.meta ?? null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 清理消失文件 + 原子落盘(仅 dirty 时写)。 */
|
|
137
|
+
async flush(aliveFiles: Set<string>): Promise<void> {
|
|
138
|
+
for (const key of [...this.rows.keys()]) if (!aliveFiles.has(key)) this.rows.delete(key);
|
|
139
|
+
for (const key of [...this.states.keys()]) if (!aliveFiles.has(key)) this.states.delete(key);
|
|
140
|
+
if (!this.dirty) return;
|
|
141
|
+
this.dirty = false;
|
|
142
|
+
try {
|
|
143
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
144
|
+
const payload = { version: VERSION, units: [...this.rows.values()] };
|
|
145
|
+
const tmp = this.file + ".tmp";
|
|
146
|
+
await writeFile(tmp, JSON.stringify(payload));
|
|
147
|
+
await rename(tmp, this.file);
|
|
148
|
+
} catch {
|
|
149
|
+
/* cache 写失败不影响查询,下次再试 */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** 两端契约配对门(AGENTS「两端改名必须同步」的机器化):
|
|
2
|
+
* 服务端真解析(运行时 SRC 标记 + 网关 methodParameterNames 语义的函数源码形参解析)
|
|
3
|
+
* ↔ 浏览器半真声明(lib/client.js 文本提取的 descriptor 调用点与固定字段)。
|
|
4
|
+
* 改任一端而忘另一端 = 本测试红灯。v3 曾为 without inject 误诊半天(真因是 fiber 可见性
|
|
5
|
+
* 而非契约错位),但契约错位这条风险真实存在,从此由机器兜底而非人肉对表。
|
|
6
|
+
* descriptor 形态对齐 @deepseek-ai/dsh-typert-generator 的 codegen 产物规范
|
|
7
|
+
* (emitter.invocationLiteral:acceptsUndefined 显式可选参 + sourceLocation 出处锚点——
|
|
8
|
+
* 后者是机器可校验的:指到服务端真实定义行,方法挪位/改名即红灯)。
|
|
9
|
+
* 共享实现:本门等价于工作区 plugin-standard/(本地包暂未发 npm;上架后本文件改为
|
|
10
|
+
* contractPairSuite() 三行注册,删去本地实现——不留双轨)。 */
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { UsageStatsService } from "../src/cordis.ts";
|
|
15
|
+
|
|
16
|
+
const MARKER_KEY = "@deepseek-ai/dsh-typert-protocol/remote-methods";
|
|
17
|
+
const ROOT = new URL("..", import.meta.url);
|
|
18
|
+
const CLIENT = readFileSync(new URL("lib/client.js", ROOT), "utf8");
|
|
19
|
+
const SERVER = readFileSync(new URL("src/cordis.ts", ROOT), "utf8");
|
|
20
|
+
|
|
21
|
+
/** client 调用点的真声明提取(第三参 sourceLocation 必填——官方产物恒带)。 */
|
|
22
|
+
interface ClientCall {
|
|
23
|
+
method: string;
|
|
24
|
+
param: string;
|
|
25
|
+
file: string;
|
|
26
|
+
line: number;
|
|
27
|
+
column: number;
|
|
28
|
+
}
|
|
29
|
+
function clientDescriptorCalls(): ClientCall[] {
|
|
30
|
+
const calls: ClientCall[] = [];
|
|
31
|
+
for (const m of CLIENT.matchAll(/descriptor\(\s*"([^"]+)",\s*"([^"]+)",\s*\{\s*file:\s*"([^"]+)",\s*line:\s*(\d+),\s*column:\s*(\d+)\s*\}\s*\)/g)) {
|
|
32
|
+
calls.push({ method: m[1], param: m[2], file: m[3], line: Number(m[4]), column: Number(m[5]) });
|
|
33
|
+
}
|
|
34
|
+
return calls;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 网关 methodParameterNames 的解析复刻(与 cordis.test.ts 同规则):括号内 → 逗号切分 → trim → 纯标识符。 */
|
|
38
|
+
function srcParamNames(fn: Function): string[] {
|
|
39
|
+
const source = Function.prototype.toString.call(fn);
|
|
40
|
+
const open = source.indexOf("(");
|
|
41
|
+
const close = source.indexOf(")", open + 1);
|
|
42
|
+
const inside = source.slice(open + 1, close);
|
|
43
|
+
return inside
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((token) => token.trim())
|
|
46
|
+
.filter((token) => /^[A-Za-z_$][\w$]*$/.test(token));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RemoteMethodsMarker {
|
|
50
|
+
version: number;
|
|
51
|
+
methods: Array<{ method: string; invocation: { kind: string } }>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("client descriptor 调用点全部提取且无漏网(防绕过式新增方法)", () => {
|
|
55
|
+
const calls = clientDescriptorCalls();
|
|
56
|
+
assert.ok(calls.length > 0, "必须至少有一个带 sourceLocation 的 descriptor 调用点");
|
|
57
|
+
// descriptor( 出现次数 = 1 处定义 + N 处调用;对不上说明有调用点没被提取正则抓到。
|
|
58
|
+
const occurrences = (CLIENT.match(/\bdescriptor\(/g) ?? []).length;
|
|
59
|
+
assert.equal(occurrences, calls.length + 1, "descriptor 调用点数与提取数不一致(提取正则失配)");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("两端方法集与 wire 参数名一一对应", () => {
|
|
63
|
+
const marker = Object.getOwnPropertyDescriptor(UsageStatsService.prototype, MARKER_KEY) as { value: RemoteMethodsMarker } | undefined;
|
|
64
|
+
assert.ok(marker, "服务端必须挂 remote-methods 标记(原型字符串键)");
|
|
65
|
+
const proto = UsageStatsService.prototype as unknown as Record<string, Function>;
|
|
66
|
+
const serverMethods = new Map<string, string[]>();
|
|
67
|
+
for (const entry of marker!.value.methods) {
|
|
68
|
+
assert.equal(entry.invocation.kind, "direct", `方法 ${entry.method} 的 invocation 形态`);
|
|
69
|
+
assert.equal(typeof proto[entry.method], "function", `标记声明了 ${entry.method} 但实现缺失`);
|
|
70
|
+
serverMethods.set(entry.method, srcParamNames(proto[entry.method]!));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const clientCalls = clientDescriptorCalls();
|
|
74
|
+
assert.equal(clientCalls.length, serverMethods.size, "方法数量两端不一致");
|
|
75
|
+
for (const call of clientCalls) {
|
|
76
|
+
assert.ok(serverMethods.has(call.method), `client 声明了服务端没有的方法 ${call.method}`);
|
|
77
|
+
const params = serverMethods.get(call.method)!;
|
|
78
|
+
assert.equal(params.length, 1, `服务端 ${call.method} 应为单参数(descriptor() 只编码单参数形态)`);
|
|
79
|
+
assert.equal(params[0], call.param, `两端 ${call.method} 的 wire 参数名错位:server=${params[0]} client=${call.param}`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("sourceLocation 锚定服务端真实定义行(官方 codegen 产物规范)", () => {
|
|
84
|
+
for (const call of clientDescriptorCalls()) {
|
|
85
|
+
const path = new URL(call.file, ROOT);
|
|
86
|
+
assert.ok(existsSync(path), `sourceLocation.file 不存在:${call.file}`);
|
|
87
|
+
const line = readFileSync(path, "utf8").split("\n")[call.line - 1] ?? "";
|
|
88
|
+
assert.ok(
|
|
89
|
+
line.slice(call.column - 1).startsWith(`${call.method}(`),
|
|
90
|
+
`sourceLocation ${call.file}:${call.line}:${call.column} 未直指 ${call.method} 的定义行(内容:${JSON.stringify(line.trim())})——方法挪位/改名后同步 descriptor 的 line`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("descriptor 固定字段与客户端挂载路径两端一致", () => {
|
|
96
|
+
// id 前缀 = SETTINGS_NS + "#usageStats/";service/namespace 字面量 = usageStats。
|
|
97
|
+
assert.match(CLIENT, /id:\s*SETTINGS_NS\s*\+\s*"#usageStats\/"\s*\+\s*method/);
|
|
98
|
+
assert.match(CLIENT, /service:\s*"usageStats"/);
|
|
99
|
+
assert.match(CLIENT, /namespace:\s*"usageStats"/);
|
|
100
|
+
assert.match(CLIENT, /source:\s*"json"/);
|
|
101
|
+
assert.match(CLIENT, /mode:\s*"strict"/);
|
|
102
|
+
assert.match(CLIENT, /acceptsUndefined:\s*true/, "filter 可省略必须用官方显式字段声明(codegen 规范)");
|
|
103
|
+
// 服务端 provide 的命名空间必须同为 usageStats(网关按 namespace/method 路由)。
|
|
104
|
+
assert.match(SERVER, /provide\(\s*"usageStats"/);
|
|
105
|
+
// 浏览器半取服务必须走名字解析(属性式在动态 fiber 被可见性隔离拒绝,见 AGENTS 宿主契约)。
|
|
106
|
+
assert.match(CLIENT, /ctx\.get\("remote\.usageStats"\)/);
|
|
107
|
+
assert.doesNotMatch(CLIENT, /ctx\.remote\.usageStats\b/, "属性式访问会 throw without inject");
|
|
108
|
+
});
|