@leonhardthust/dsh-decision-engine 0.0.0-stage → 1.9.1

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -2
  3. package/README.zh.md +57 -0
  4. package/config.example.yml +436 -0
  5. package/cordis.patch.yml +14 -0
  6. package/docs/assets/config-page.png +0 -0
  7. package/docs/assets/decisions-tab.png +0 -0
  8. package/docs/assets/hero.jpeg +0 -0
  9. package/docs/assets/stats-charts-preview.png +0 -0
  10. package/docs/assets/stats-donut-toggle.png +0 -0
  11. package/docs/assets/stats-interactive-charts.png +0 -0
  12. package/docs/assets/stats-point-donut.png +0 -0
  13. package/docs/assets/stats-ranking-bars.png +0 -0
  14. package/docs/auto-review-hotfix.md +33 -0
  15. package/docs/lessons/cloudflare-builds-root-directory.md +51 -0
  16. package/docs/lessons/cold-start-timeout.md +134 -0
  17. package/docs/lessons/endpoint-path-vs-model.md +26 -0
  18. package/docs/lessons/model-field-required-by-provider.md +31 -0
  19. package/docs/local-dev-link.md +54 -0
  20. package/docs/releasing.md +69 -0
  21. package/docs/security-revision.md +130 -0
  22. package/icon.svg +6 -0
  23. package/lib/adapters/agent.js +314 -0
  24. package/lib/adapters/route.js +144 -0
  25. package/lib/adapters/tools.js +186 -0
  26. package/lib/adapters/translate.js +143 -0
  27. package/lib/approval-detail.js +325 -0
  28. package/lib/client.charts.js +36 -0
  29. package/lib/client.js +8715 -0
  30. package/lib/config-file.js +136 -0
  31. package/lib/credentials.js +187 -0
  32. package/lib/decision-cache.js +188 -0
  33. package/lib/decision-points.js +205 -0
  34. package/lib/decision-store.js +774 -0
  35. package/lib/endpoint-fallback.js +143 -0
  36. package/lib/engine.js +312 -0
  37. package/lib/guard/advisory.js +100 -0
  38. package/lib/guard/audit.js +81 -0
  39. package/lib/guard/context.js +128 -0
  40. package/lib/guard/effects.js +104 -0
  41. package/lib/guard/fallback.js +139 -0
  42. package/lib/guard/listener.js +214 -0
  43. package/lib/guard/messages.js +31 -0
  44. package/lib/guard/policy.js +28 -0
  45. package/lib/home-paths.js +86 -0
  46. package/lib/index.js +3823 -0
  47. package/lib/outcomes.js +169 -0
  48. package/lib/points/spec.js +645 -0
  49. package/lib/providers.js +958 -0
  50. package/lib/resilience.js +308 -0
  51. package/lib/scope.js +67 -0
  52. package/lib/server-config.js +351 -0
  53. package/lib/session-events.js +75 -0
  54. package/lib/stats-store.js +916 -0
  55. package/lib/stats.js +1293 -0
  56. package/lib/telemetry.js +465 -0
  57. package/lib/tokenizer.js +201 -0
  58. package/lib/tolerance.js +498 -0
  59. package/lib/vendor/microcharts-charts.css +47 -0
  60. package/lib/vendor-src/entry.js +13 -0
  61. package/lib/vendor-src/jsx-shim.js +34 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/package.json +101 -3
  65. package/scripts/migrate-profile.mjs +102 -0
@@ -0,0 +1,465 @@
1
+ /**
2
+ * 匿名使用统计的**上报端**(客户端部分)。
3
+ *
4
+ * ## 为什么单独一个模块
5
+ *
6
+ * 与 `stats-store.js` 同一个理由:统计路径最容易出「数字对不上」和「悄悄多打
7
+ * 了一次网络请求」两类问题,而它们都没法靠读代码确认。因此这里把**纯计算**
8
+ * (哪天算一天、该不该发、发什么)与**副作用**(读写状态文件、发 HTTP)分开:
9
+ * 纯函数可以被 `test/telemetry.test.js` 直接断言,副作用部分接受注入的
10
+ * `fetchImpl` / `clock` / `dir`,测试里完全不碰网络。
11
+ *
12
+ * ## 三条硬承诺
13
+ *
14
+ * 1. **关掉就是真的不联网**。`level === "off"` 时连状态文件都不读,更不发请求。
15
+ * 2. **每台机器每天最多一次**。状态文件记 `lastAttemptDay`;同一天重复启动不会
16
+ * 重发。这既是隐私承诺(不做行为追踪),也是服务端幂等的前提。
17
+ * 3. **绝不阻塞插件**。`start()` 由调用方 `void` 掉,内部所有错误都被吞成
18
+ * 一条警告:统计失败不能变成插件故障。
19
+ *
20
+ * ## 发什么、绝不发什么
21
+ *
22
+ * 发送的字段一共七个(见 `buildPayload`):随机安装 id、插件版本、平台、架构、
23
+ * 宿主版本、当天决策数、事件种类。**不发送**:API key、prompt / 工具参数、
24
+ * 文件路径、会话内容、provider 网址、用户名、IP(服务端也不读
25
+ * `CF-Connecting-IP`)。安装 id 是首次运行时生成的随机 UUIDv4,与任何账号
26
+ * 无关;删除 `telemetry.json` 即换一个新 id。
27
+ *
28
+ * ## 为什么用固定 +8 时区算「一天」
29
+ *
30
+ * 中国自 1991 年起不再使用夏令时,`Asia/Shanghai` 恒为 UTC+8,因此
31
+ * `(ms + 8h)` 之后取日历日就是当地日期,不需要 Intl / 时区数据库——后者在
32
+ * Electron 里还取决于 ICU 的打包方式。服务端用同一算法,两端对日一致。
33
+ *
34
+ * @module dsh-decision-engine/telemetry
35
+ */
36
+
37
+ import { randomUUID } from "node:crypto";
38
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
39
+ import { join } from "node:path";
40
+
41
+ /** 默认上报端点(站点 Worker 的采集入口)。 */
42
+ export const DEFAULT_ENDPOINT = "https://dsh.zuos.us/api/e";
43
+
44
+ /** 统计档位:`off` 完全不联网,`install` 只在首次运行报一次,`daily` 每天一次。 */
45
+ export const TELEMETRY_LEVELS = ["off", "install", "daily"];
46
+
47
+ /**
48
+ * 状态文件名(与 config.yml、stats.jsonl 同目录)。
49
+ *
50
+ * **不能叫 `telemetry.json`**:同目录下已有 `telemetry.jsonl`(本插件的本地决策
51
+ * 流水账,用户会 `tail -f` 的那个)。两个只差一个字母、口径完全不同的文件放在
52
+ * 一起,迟早有人清缓存时删错——而删错流水账等于丢掉用户的决策审计记录。
53
+ */
54
+ export const STATE_FILE = "analytics.json";
55
+
56
+ /** 时区偏移(毫秒)。中国无夏令时,固定 +8 是精确的。 */
57
+ const TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
58
+
59
+ /** 单次上报超时:宁可失败也不能拖住进程退出。 */
60
+ const REQUEST_TIMEOUT_MS = 5000;
61
+
62
+ /** 连续失败多少次后彻底降级(不再尝试,避免每天打一次注定失败的请求)。 */
63
+ const DEGRADE_AFTER_FAILURES = 30;
64
+
65
+ /**
66
+ * 把时间戳格式化成 `Asia/Shanghai` 的日历日 `YYYY-MM-DD`。
67
+ *
68
+ * @param ms - 毫秒时间戳。
69
+ * @returns 当地日期字符串;输入非数字时返回空串。
70
+ */
71
+ export function shanghaiDay(ms) {
72
+ const value = Number(ms);
73
+ if (!Number.isFinite(value)) return "";
74
+ return new Date(value + TZ_OFFSET_MS).toISOString().slice(0, 10);
75
+ }
76
+
77
+ /**
78
+ * 安装 id 的合法性:UUIDv4 形态。服务端按同一规则校验。
79
+ *
80
+ * @param value - 待检查的值。
81
+ * @returns 是否合法。
82
+ */
83
+ export function isValidInstallId(value) {
84
+ return typeof value === "string"
85
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
86
+ }
87
+
88
+ /** 生成一个新的安装 id(随机 UUIDv4,与任何账号 / 机器指纹无关)。 */
89
+ export function newInstallId() {
90
+ return randomUUID();
91
+ }
92
+
93
+ /**
94
+ * 解析状态文件内容。任何损坏都退化成「全新安装」而不是抛错——
95
+ * 统计状态坏掉时,正确行为是当作没装过、重新报一次,而不是永远不报。
96
+ *
97
+ * @param text - 文件全文,可能为空或损坏。
98
+ * @returns 规范化后的状态对象。
99
+ */
100
+ export function parseState(text) {
101
+ let raw = null;
102
+ try {
103
+ raw = JSON.parse(String(text ?? ""));
104
+ } catch {
105
+ raw = null;
106
+ }
107
+ const source = raw !== null && typeof raw === "object" ? raw : {};
108
+ const firstRun = Number(source.firstRunAt);
109
+ return {
110
+ installId: isValidInstallId(source.installId) ? source.installId : null,
111
+ firstRunAt: Number.isFinite(firstRun) && firstRun > 0 ? firstRun : null,
112
+ firstVersion: typeof source.firstVersion === "string" ? source.firstVersion : "",
113
+ lastSentDay: typeof source.lastSentDay === "string" ? source.lastSentDay : "",
114
+ lastAttemptDay: typeof source.lastAttemptDay === "string" ? source.lastAttemptDay : "",
115
+ lastOkDay: typeof source.lastOkDay === "string" ? source.lastOkDay : "",
116
+ failures: Number.isFinite(Number(source.failures)) ? Math.max(0, Number(source.failures)) : 0,
117
+ degraded: source.degraded === true,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * 序列化状态。字段顺序固定,便于人直接读这个文件。
123
+ *
124
+ * @param state - `parseState` 形状的对象。
125
+ * @returns JSON 文本(带结尾换行)。
126
+ */
127
+ export function serializeState(state) {
128
+ return `${JSON.stringify({
129
+ installId: state?.installId ?? null,
130
+ firstRunAt: state?.firstRunAt ?? null,
131
+ firstVersion: state?.firstVersion ?? "",
132
+ lastSentDay: state?.lastSentDay ?? "",
133
+ lastAttemptDay: state?.lastAttemptDay ?? "",
134
+ lastOkDay: state?.lastOkDay ?? "",
135
+ failures: state?.failures ?? 0,
136
+ degraded: state?.degraded === true,
137
+ }, null, 2)}\n`;
138
+ }
139
+
140
+ /**
141
+ * 决定本次启动要不要上报、报哪一种。纯函数,测试的主入口。
142
+ *
143
+ * @param state - 当前状态(`parseState` 形状)。
144
+ * @param options.today - 当地日期 `YYYY-MM-DD`。
145
+ * @param options.level - `off` / `install` / `daily`。
146
+ * @returns `"install"` / `"heartbeat"` / `null`(null = 本次不发)。
147
+ */
148
+ export function decideKind(state, { today, level } = {}) {
149
+ if (level === "off") return null;
150
+ if (typeof today !== "string" || today.length === 0) return null;
151
+ if (state?.degraded === true) return null;
152
+ // 同一当地日重复启动不重发:这是「每天最多一次」的唯一执行点。
153
+ if (state?.lastAttemptDay === today) return null;
154
+ const fresh = !isValidInstallId(state?.installId) || !Number.isFinite(Number(state?.firstRunAt));
155
+ if (fresh) return "install";
156
+ if (level === "install") return null;
157
+ return "heartbeat";
158
+ }
159
+
160
+ /**
161
+ * 版本号清洗:只保留可打印的版本字符,防注入与超长。
162
+ *
163
+ * @param value - 原始版本号。
164
+ * @returns 清洗后的版本号,可能为空串。
165
+ */
166
+ export function normalizeVersion(value) {
167
+ return String(value ?? "").trim().replace(/[^0-9A-Za-z._+-]/g, "").slice(0, 32);
168
+ }
169
+
170
+ /** 平台 / 架构等短字段的统一清洗。 */
171
+ function shortField(value, max = 24) {
172
+ return String(value ?? "").trim().replace(/[^0-9A-Za-z._+-]/g, "").slice(0, max);
173
+ }
174
+
175
+ /**
176
+ * 插件标识(npm 包名的合法形态):小写,字母数字与 `-` / `.` / `_`,最长 48。
177
+ *
178
+ * **这是多插件共存的前提**:上报体必须自报家门,否则 D1 里分不清哪条数据属于
179
+ * 哪个插件。将来 dsh 生态里出现第二个、第三个插件时,统计天然分开,不需要改
180
+ * 协议版本,也不必给每张表临时补迁移。
181
+ *
182
+ * 上限 48 而不是更长:一个表的主键前缀越长,索引越占空间,而 npm 包名超过
183
+ * 48 字符的情况极其罕见。
184
+ *
185
+ * @param value - 原始值(通常是 package.json 的 name)。
186
+ * @returns 合法插件 id;非法时返回空串(由调用方决定是回退还是拒发)。
187
+ */
188
+ export function normalizePluginId(value) {
189
+ const text = String(value ?? "").trim().toLowerCase();
190
+ return /^[a-z0-9][a-z0-9._-]{0,47}$/.test(text) ? text : "";
191
+ }
192
+
193
+ /**
194
+ * 构造上报体。**字段名刻意用短键**:心跳是免费的,但没必要把字节花在字段名
195
+ * 上;短键同时天然限制了能塞进去的东西(没有地方放 prompt)。
196
+ *
197
+ * @param input.pluginId - 本插件的 npm 包名(必填,服务端按它分插件统计)。
198
+ * @param input.installId - 安装 id。
199
+ * @param input.kind - `install` / `heartbeat`。
200
+ * @param input.version - 本插件版本。
201
+ * @param input.platform - `process.platform`。
202
+ * @param input.arch - `process.arch`。
203
+ * @param input.hostVersion - 宿主版本(拿不到时为空串)。
204
+ * @param input.decisions - 当天决策数(0 表示没有,不上报该字段)。
205
+ * @returns 可直接 `JSON.stringify` 的对象。
206
+ */
207
+ export function buildPayload(input = {}) {
208
+ const payload = {
209
+ v: 1,
210
+ // `p` = plugin:谁在报。客户端没有合法插件 id 时在 prepare 阶段就拦下,
211
+ // 这里只负责清洗。
212
+ p: normalizePluginId(input.pluginId),
213
+ id: input.installId,
214
+ k: input.kind,
215
+ ver: normalizeVersion(input.version) || "unknown",
216
+ os: shortField(input.platform),
217
+ arch: shortField(input.arch),
218
+ };
219
+ const host = normalizeVersion(input.hostVersion);
220
+ if (host.length > 0) payload.host = host;
221
+ const decisions = Number(input.decisions);
222
+ if (Number.isFinite(decisions) && decisions > 0) payload.d = Math.min(Math.trunc(decisions), 100000);
223
+ return payload;
224
+ }
225
+
226
+ /**
227
+ * 校验并规范化上报端点。只接受 http/https;留空或非法时回落默认值
228
+ * (统计不该因为一个写错的地址就整体失败)。
229
+ *
230
+ * @param raw - 配置里的地址。
231
+ * @returns 完整 URL。
232
+ */
233
+ export function resolveEndpoint(raw) {
234
+ const text = String(raw ?? "").trim();
235
+ if (text.length === 0) return DEFAULT_ENDPOINT;
236
+ try {
237
+ const url = new URL(text);
238
+ if (url.protocol !== "https:" && url.protocol !== "http:") return DEFAULT_ENDPOINT;
239
+ return url.href;
240
+ } catch {
241
+ return DEFAULT_ENDPOINT;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * 把档位规范化成三个合法值之一;未知值按默认值处理。
247
+ *
248
+ * 默认是 **`daily`**。这里推翻了最初的 `install` 方案,理由是它会让统计
249
+ * **彻底失效**:`install` 档只在首次运行报一次,已装用户第二天起不再上报,
250
+ * 于是 `daily_active` 永远没有新行、DAU/WAU 恒为 0——「每天看一次数据」
251
+ * 这个需求根本不可能满足。
252
+ *
253
+ * 与「默认不擅自介入」的关系:那条姿态针对的是**守卫**(`enabled: false`
254
+ * 意味着不干预会话)。上报不发请求给 provider、不改任何裁决、不增加延迟
255
+ * (`apply()` 里 `void` 掉),隐私边界也写死在上报体(7 个字段、无内容)。
256
+ * 因此默认 `daily` 不违背该姿态;`off` 依然是一个请求都不发的硬承诺。
257
+ *
258
+ * @param raw - 配置里的值。
259
+ * @param fallback - 未知值时回落到什么档位,默认 `daily`。
260
+ * @returns 合法档位。
261
+ */
262
+ export function resolveLevel(raw, fallback = "daily") {
263
+ const text = String(raw ?? "").trim();
264
+ if (TELEMETRY_LEVELS.includes(text)) return text;
265
+ return TELEMETRY_LEVELS.includes(fallback) ? fallback : "daily";
266
+ }
267
+
268
+ /**
269
+ * 上报器。副作用全部集中在这里,且只在 `start()` 里发生一次。
270
+ *
271
+ * 与 `StatsStore` 同一模式:注入 `dir` / `logger` / `fetchImpl` / `clock`,
272
+ * 测试不需要环境与网络。
273
+ */
274
+ export class TelemetryReporter {
275
+ #dir;
276
+ #logger;
277
+ #endpoint;
278
+ #pluginId;
279
+ #level;
280
+ #fetchImpl;
281
+ #clock;
282
+ #warned = false;
283
+
284
+ /**
285
+ * @param options.dir - 状态文件所在目录(插件数据目录)。
286
+ * @param options.logger - 宿主 logger,可缺省。
287
+ * @param options.endpoint - 上报地址;留空用默认。
288
+ * @param options.level - `off` / `install` / `daily`。
289
+ * @param options.pluginId - 本插件标识(npm 包名)。**必填**:没有它服务端
290
+ * 分不清数据属于哪个插件,因此非法/缺失时整条上报被拒(见 `prepare`)。
291
+ * @param options.fetchImpl - 注入的 fetch,测试用。
292
+ * @param options.clock - 注入的时钟,测试用。
293
+ */
294
+ constructor(options = {}) {
295
+ this.#dir = options.dir ?? ".";
296
+ this.#logger = options.logger;
297
+ this.#endpoint = resolveEndpoint(options.endpoint);
298
+ this.#pluginId = normalizePluginId(options.pluginId);
299
+ this.#level = resolveLevel(options.level ?? "daily");
300
+ this.#fetchImpl = options.fetchImpl ?? globalThis.fetch;
301
+ this.#clock = typeof options.clock === "function" ? options.clock : () => Date.now();
302
+ }
303
+
304
+ /** 当前档位(`off` 时调用方可以直接跳过整个流程)。 */
305
+ get level() {
306
+ return this.#level;
307
+ }
308
+
309
+ /** 当前生效的上报端点(排查时直接看日志即可)。 */
310
+ get endpoint() {
311
+ return this.#endpoint;
312
+ }
313
+
314
+ /** 当前插件标识;为空串表示配置有误、不会上报。 */
315
+ get pluginId() {
316
+ return this.#pluginId;
317
+ }
318
+
319
+ /** 状态文件绝对路径(用户排查时会直接看这个文件)。 */
320
+ get statePath() {
321
+ return join(this.#dir, STATE_FILE);
322
+ }
323
+
324
+ /**
325
+ * 读状态文件;不存在或损坏时返回「全新安装」的初始状态。
326
+ * 注意:**不创建文件**——只读,不产生副作用。
327
+ *
328
+ * @returns 状态对象。
329
+ */
330
+ async readState() {
331
+ try {
332
+ return parseState(await readFile(this.statePath, "utf8"));
333
+ } catch {
334
+ return parseState("");
335
+ }
336
+ }
337
+
338
+ /**
339
+ * 原子写状态:先写临时文件再 rename,避免「写到一半被杀」留下半截 JSON。
340
+ *
341
+ * @param state - 完整状态。
342
+ */
343
+ async writeState(state) {
344
+ await mkdir(this.#dir, { recursive: true });
345
+ const tmp = `${this.statePath}.tmp`;
346
+ await writeFile(tmp, serializeState(state), { encoding: "utf8", mode: 0o600 });
347
+ await rename(tmp, this.statePath);
348
+ }
349
+
350
+ /**
351
+ * 计算本次应发的上报体(不发送、不落盘,便于测试与干跑)。
352
+ *
353
+ * @param input.version - 插件版本。
354
+ * @param input.platform - 平台。
355
+ * @param input.arch - 架构。
356
+ * @param input.hostVersion - 宿主版本。
357
+ * @param input.decisions - 当日决策数。
358
+ * @returns `{ state, payload, today, kind }`;不该发时 null。
359
+ */
360
+ async prepare(input = {}) {
361
+ if (this.#level === "off") return null;
362
+ // 没有合法的插件标识就**不发**。宁可少一条数据,也不能往公共统计里塞
363
+ // 一条无法归属的记录——那正是多插件共存时最难收拾的一类脏数据。
364
+ if (this.#pluginId.length === 0) return null;
365
+ const now = this.#clock();
366
+ const today = shanghaiDay(now);
367
+ if (today.length === 0) return null;
368
+ const state = await this.readState();
369
+ const kind = decideKind(state, { today, level: this.#level });
370
+ if (kind === null) return null;
371
+
372
+ const installId = state.installId ?? newInstallId();
373
+ const next = {
374
+ ...state,
375
+ installId,
376
+ firstRunAt: state.firstRunAt ?? now,
377
+ firstVersion: state.firstVersion.length > 0 ? state.firstVersion : normalizeVersion(input.version),
378
+ lastAttemptDay: today,
379
+ };
380
+ const payload = buildPayload({
381
+ pluginId: this.#pluginId,
382
+ installId,
383
+ kind,
384
+ version: input.version,
385
+ platform: input.platform,
386
+ arch: input.arch,
387
+ hostVersion: input.hostVersion,
388
+ decisions: input.decisions,
389
+ });
390
+ return { state: next, payload, today, kind };
391
+ }
392
+
393
+ /**
394
+ * 执行一次上报。**绝不抛错**:所有失败只记一条警告并把状态留到下次。
395
+ *
396
+ * @param input - 同 `prepare`。
397
+ * @returns `{ sent: boolean, kind: string|null, reason: string }`,便于测试断言。
398
+ */
399
+ async start(input = {}) {
400
+ if (this.#level === "off") return { sent: false, kind: null, reason: "off" };
401
+
402
+ let prepared = null;
403
+ try {
404
+ prepared = await this.prepare(input);
405
+ } catch (err) {
406
+ this.#warn(`统计状态读取失败(不影响插件):${String(err?.message ?? err)}`);
407
+ return { sent: false, kind: null, reason: "state-error" };
408
+ }
409
+ if (prepared === null) return { sent: false, kind: null, reason: "not-due" };
410
+
411
+ const { state, payload, today } = prepared;
412
+
413
+ // 先落状态再发送:即便发送过程中进程被杀,也不会因为「没记过」而在同一天
414
+ // 反复重发。代价是发送失败的那一天不会重试——下次启动就是明天了。
415
+ try {
416
+ await this.writeState(state);
417
+ } catch (err) {
418
+ this.#warn(`统计状态写入失败(不影响插件):${String(err?.message ?? err)}`);
419
+ }
420
+
421
+ const sent = await this.#post(payload);
422
+ try {
423
+ await this.writeState({
424
+ ...state,
425
+ lastSentDay: sent ? today : state.lastSentDay,
426
+ lastOkDay: sent ? today : state.lastOkDay,
427
+ failures: sent ? 0 : state.failures + 1,
428
+ degraded: sent ? false : state.failures + 1 >= DEGRADE_AFTER_FAILURES,
429
+ });
430
+ } catch {
431
+ /* 第二次写失败只影响下次重试,不必再警告 */
432
+ }
433
+ if (!sent) this.#warn(`上报未成功(端点 ${this.#endpoint}),下次启动再试;不影响插件`);
434
+ return { sent, kind: payload.k, reason: sent ? "ok" : "http-failed" };
435
+ }
436
+
437
+ /** 真正发请求;返回是否 2xx。超时 / 网络错误一律当作「没发成功」。 */
438
+ async #post(payload) {
439
+ const fetchImpl = this.#fetchImpl;
440
+ if (typeof fetchImpl !== "function") return false;
441
+ const controller = new AbortController();
442
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
443
+ try {
444
+ const response = await fetchImpl(this.#endpoint, {
445
+ method: "POST",
446
+ headers: { "content-type": "application/json" },
447
+ body: JSON.stringify(payload),
448
+ signal: controller.signal,
449
+ });
450
+ return Boolean(response?.ok);
451
+ } catch {
452
+ // 离线、被墙、端点 5xx:都只是「这次没报上去」,插件照常工作。
453
+ return false;
454
+ } finally {
455
+ clearTimeout(timer);
456
+ }
457
+ }
458
+
459
+ /** 同一类警告只发一次,避免每天刷屏。 */
460
+ #warn(message) {
461
+ if (this.#warned) return;
462
+ this.#warned = true;
463
+ this.#logger?.warn?.(`dsh-decision-engine: ${message}`);
464
+ }
465
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * 零依赖的 token 估算。
3
+ *
4
+ * 为什么需要它:决策端点的 `usage` 字段**不一定**带 token 计数
5
+ * (实测 bamboonode 带、硅基流动带,但自建 llama.cpp / vLLM 这类服务通常
6
+ * 不带)。统计功能要求「优先采用 API 返回计数,缺失时用 tokenizer 估算」,
7
+ * 因此这里提供一个**永远可用**的兜底估算。
8
+ *
9
+ * 为什么不引第三方 tokenizer:`tiktoken` / `js-tiktoken` 都是几百 KB 的依赖,
10
+ * 而本插件的硬约束是**零运行时依赖**(见 home-paths.js 的说明:宿主依赖缺失
11
+ * 不能让插件无法加载)。为此引入一个依赖,去换取一个「只在 API 没报数时才会
12
+ * 用到的估算值」,不划算。
13
+ *
14
+ * 系数来自**实测标定**(2026-10-27,真实 key 打真实端点,14 组不同长度与
15
+ * 语种的请求体):
16
+ *
17
+ * tokens ≈ 90 + 1.0 × 中日韩字符数 + (其它字符数) / 3.0
18
+ *
19
+ * 其中 90 是请求体的固定结构开销(JSON 脚手架 + 服务端的对话模板),
20
+ * 1.0 是中文「一字一 token」,1/3 是英文与代码约 3 字符一 token。
21
+ * 14 组样本上的平均相对误差约 6%,排除「重复同一字符」这类病态样本后
22
+ * 约 2.5%。作为**兜底**估算,这个精度完全够用——真正的数字始终优先取
23
+ * API 返回值。
24
+ *
25
+ * @module dsh-decision-engine/tokenizer
26
+ */
27
+
28
+ /**
29
+ * 中日韩表意文字、假名、韩文与全角标点。
30
+ *
31
+ * 用显式 `\u` 转义而不是字面字符:字面写法在源文件被以非 UTF-8 读取时
32
+ * 会静默变成乱码,范围随之失效(把「一个字符」误判成「三个字符」,
33
+ * 估算结果直接翻倍)。转义写法与源文件编码无关。
34
+ *
35
+ * 覆盖:
36
+ * 3000-303F CJK 符号与标点(含全角逗号、句号)
37
+ * 3040-30FF 平假名与片假名
38
+ * 3400-4DBF CJK 扩展 A
39
+ * 4E00-9FFF CJK 统一表意文字
40
+ * AC00-D7AF 韩文音节
41
+ * F900-FAFF CJK 兼容表意文字
42
+ * FF00-FFEF 全角形式
43
+ */
44
+ const CJK_RANGES = [
45
+ [0x3000, 0x303f],
46
+ [0x3040, 0x30ff],
47
+ [0x3400, 0x4dbf],
48
+ [0x4e00, 0x9fff],
49
+ [0xac00, 0xd7af],
50
+ [0xf900, 0xfaff],
51
+ [0xff00, 0xffef],
52
+ ];
53
+
54
+ /**
55
+ * CJK 预筛:**非全局**,只回答「这段文本里有没有 CJK」。
56
+ *
57
+ * 为什么需要它:纯 ASCII 文本(英文代码、日志、JSON 脚手架)在这种守卫里
58
+ * 占多数,而查表法必须逐码元走一遍——512KB 纯英文要 0.52 ms。正则引擎对
59
+ * 「一次都不命中」有 SIMD 优化,同样的串只要几纳秒,且**不分配**
60
+ * (非全局 `test` 不像 `match(/g)` 那样把命中项收进数组)。
61
+ *
62
+ * 所以先用它筛一遍:没有 CJK 就直接返回 0,有 CJK 才进查表。
63
+ * 纯英文回到旧实现的纳秒级,纯中文/混合文本则比旧实现快 8 倍。
64
+ */
65
+ const CJK_TEST =
66
+ /[\u3000-\u303F\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFF00-\uFFEF]/;
67
+
68
+ /**
69
+ * BMP 码元 → 是否 CJK 的查表(64KB,模块加载时构建一次)。
70
+ *
71
+ * 为什么不是逐区间比较:实测 512KB 中文 state 上「每字符跑一遍 7 个区间」
72
+ * 仍需 3.4 ms——分支预测在混合文本上失效。查表把判断压成一次数组下标,
73
+ * 同一输入降到 0.2 ms 量级,且仍然零分配。
74
+ *
75
+ * 表只覆盖 BMP,因为原正则的最大码位就是 U+FFEF,而 `String#length` 也按
76
+ * UTF-16 码元计数:代理对的高位/低位码元都落在 U+D800-U+DFFF,查表得 0,
77
+ * 与旧实现 `match().length` 的口径完全一致。(穷举 U+0000-U+FFFF 全码元
78
+ * 与 3000 组随机串验证过,见提交说明。)
79
+ */
80
+ const CJK_BMP = buildCjkTable();
81
+
82
+ /**
83
+ * 构建 BMP 查表。
84
+ *
85
+ * @returns 长度 65536 的 Uint8Array,1 表示该码元属于 CJK 区间。
86
+ */
87
+ function buildCjkTable() {
88
+ const table = new Uint8Array(0x10000);
89
+ for (const range of CJK_RANGES) {
90
+ table.fill(1, range[0], range[1] + 1);
91
+ }
92
+ return table;
93
+ }
94
+
95
+ export const REQUEST_OVERHEAD_TOKENS = 90;
96
+
97
+ /**
98
+ * 统计一段文本的字符构成。
99
+ *
100
+ * @param text - 任意文本。
101
+ * @returns `{ chars, cjk, other }`:字符总数、中日韩字符数、其余字符数。
102
+ */
103
+ export function tokenProfile(text) {
104
+ const source = typeof text === "string" ? text : "";
105
+ const chars = source.length;
106
+ if (chars === 0) return { chars: 0, cjk: 0, other: 0 };
107
+ const cjk = countCjk(source);
108
+ return { chars, cjk, other: chars - cjk };
109
+ }
110
+
111
+ /**
112
+ * 统计字符串里落在 CJK 区间的**码元**个数(与旧实现 `match().length` 同口径)。
113
+ *
114
+ * 为什么不用正则:`source.match(/.../g)?.length` 会把**每一个**命中的字符
115
+ * 都收进数组,只为取个长度。实测 512KB 的中文 state 上这一行要 5.28 ms、
116
+ * 分配 1.1 MB——而它只是想要一个数字。查表计数后同一输入降到亚毫秒,
117
+ * 且零分配。
118
+ *
119
+ * 按**码元**(charCodeAt)而不是码点(codePointAt)遍历,是刻意与原正则
120
+ * 对齐:原模式只覆盖 BMP,`String#length` 也按码元计;若改用码点,代理对
121
+ * 会被算成一个字符,`chars - cjk` 的差值就变了。
122
+ *
123
+ * 先用 `CJK_TEST` 预筛,纯 ASCII 文本不进循环(见该常量的说明)。
124
+ *
125
+ * @param source - 已确认非空的字符串。
126
+ * @returns 落在 CJK 区间的码元个数。
127
+ */
128
+ function countCjk(source) {
129
+ if (!CJK_TEST.test(source)) return 0;
130
+ let count = 0;
131
+ const length = source.length;
132
+ // 四路展开:减少循环开销与 charCodeAt 的下标运算,实测再快约 20%。
133
+ const unrolled = length - (length % 4);
134
+ for (let i = 0; i < unrolled; i += 4) {
135
+ count += CJK_BMP[source.charCodeAt(i)];
136
+ count += CJK_BMP[source.charCodeAt(i + 1)];
137
+ count += CJK_BMP[source.charCodeAt(i + 2)];
138
+ count += CJK_BMP[source.charCodeAt(i + 3)];
139
+ }
140
+ for (let i = unrolled; i < length; i += 1) {
141
+ count += CJK_BMP[source.charCodeAt(i)];
142
+ }
143
+ return count;
144
+ }
145
+
146
+ /**
147
+ * 估算**一段纯文本**的 token 数。
148
+ *
149
+ * 用于「这段 state 文本本身值多少 token」这类问题(例如估算未截断时的
150
+ * 开销)。语料级的换算:中文一字一 token,英文/代码约四字符一 token。
151
+ *
152
+ * 不含请求体开销——那是 `estimateRequestTokens` 的事。
153
+ *
154
+ * @param text - 任意文本。
155
+ * @returns 估算的 token 数(整数,最小 0)。
156
+ */
157
+ export function estimateTextTokens(text) {
158
+ const profile = tokenProfile(text);
159
+ return Math.max(0, Math.round(profile.cjk + profile.other / 4));
160
+ }
161
+
162
+ /**
163
+ * 估算**一个请求体**的 token 数。
164
+ *
165
+ * 与 `estimateTextTokens` 的区别是加上固定结构开销,并按实测系数
166
+ * (约三字符一 token)而不是语料系数(四字符)折算非中文部分——
167
+ * JSON 的引号、键名与标点比自然语言更难压缩。
168
+ *
169
+ * @param bodyText - 完整请求体的 JSON 文本。
170
+ * @returns 估算的 token 数(整数,最小 0)。
171
+ */
172
+ export function estimateRequestTokens(bodyText) {
173
+ const profile = tokenProfile(bodyText);
174
+ if (profile.chars === 0) return 0;
175
+ return Math.max(0, Math.round(REQUEST_OVERHEAD_TOKENS + profile.cjk + profile.other / 3));
176
+ }
177
+
178
+ /**
179
+ * 按「字符数比例」把一组 token 数放大到另一个体量。
180
+ *
181
+ * 用于估算「如果当初没截断,这次请求会花多少 token」:截断只改少了
182
+ * 哪些字符,token 数不会等比例变化,但字符比例是最接近的可得近似
183
+ * (中文占比高的部分尤其不能用字符数直接乘 token)。
184
+ *
185
+ * **固定开销不参与缩放**:结构开销与用户数据无关,把 state 加长一倍
186
+ * 不会让那 90 token 也翻倍。
187
+ *
188
+ * @param actualTokens - 实际(或估算的)token 数。
189
+ * @param actualChars - 实际发送的字符数。
190
+ * @param targetChars - 若不截断会发送的字符数。
191
+ * @returns 放大后的 token 数。
192
+ */
193
+ export function scaleTokensByChars(actualTokens, actualChars, targetChars) {
194
+ const from = Number(actualChars);
195
+ const to = Number(targetChars);
196
+ if (!Number.isFinite(actualTokens) || actualTokens <= 0) return 0;
197
+ if (!Number.isFinite(from) || from <= 0 || !Number.isFinite(to) || to <= from) return Math.round(actualTokens);
198
+ const variable = Math.max(0, actualTokens - REQUEST_OVERHEAD_TOKENS);
199
+ const scaled = REQUEST_OVERHEAD_TOKENS + (variable * to) / from;
200
+ return Math.round(scaled);
201
+ }