dsh-decision-engine 0.0.0-stage → 0.5.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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 模型路由:`agent/request` 适配器。
3
+ *
4
+ * 这个点是**热路径**——每步一次模型请求就触发一次。若每次都问决策模型,
5
+ * 等于每步多等约 2 秒,比省下的 token 更贵。因此这里用三道闸把它压下来:
6
+ *
7
+ * 闸1 步数闸 每轮只在前 N 步判断(默认 3)。后续步骤沿用本轮的裁决——
8
+ * 一个任务的「要不要强模型」在开头就定了,逐步重问是浪费。
9
+ * 闸2 路由闸 提议的 config 与上一次实际使用的不同才问。同一 provider/model
10
+ * 连续多步时,裁决已经生效,不必再问。
11
+ * 闸3 机械闸 只有本地判定「看起来机械」的步骤才问:上一步有工具结果、
12
+ * 且最近没有用户新输入。新推理多半需要强模型,问了也是 keep。
13
+ *
14
+ * 生效方式:返回替换后的 LlmCallConfig。observe 只记录「本该换成什么」。
15
+ *
16
+ * 安全约束(照 `agent/request` 的契约):这个 waterfall 不能改 messages,
17
+ * 只能改 config。因此这里只动 provider/model 两个字段,其余字段原样透传,
18
+ * 否则会把 maxTokens、reasoningEffort 等宿主已解析的默认值弄丢。
19
+ *
20
+ * @module dsh-decision-engine/adapters/route
21
+ */
22
+
23
+ /**
24
+ * 挂载模型路由监听器。
25
+ * @param ctx - cordis 插件上下文。
26
+ * @param deps - 运行期依赖。
27
+ */
28
+ export function mountRoutePoint(ctx, deps) {
29
+ const { runner, getConfig, digestRecent, telemetry } = deps;
30
+
31
+ /**
32
+ * 每轮的路由状态:turn → { askedSteps, lastProvider, lastModel }。
33
+ * 用 WeakMap 挂在 session 上,会话销毁即回收,不担心泄漏。
34
+ */
35
+ const perSession = new WeakMap();
36
+
37
+ const stateFor = (session) => {
38
+ let state = perSession.get(session);
39
+ if (state === undefined) {
40
+ state = { turn: -1, askedSteps: new Set(), lastProvider: undefined, lastModel: undefined };
41
+ perSession.set(session, state);
42
+ }
43
+ // 换轮即重置:新的一轮是一个新任务,前一轮的「机械」判断不适用。
44
+ return state;
45
+ };
46
+
47
+ /** 最近一条 user 消息是否比最近一条工具结果更新(= 有新推理进入)。 */
48
+ const hasFreshUserInput = (session) => {
49
+ let events;
50
+ try {
51
+ events = session.events ?? [];
52
+ } catch {
53
+ return false;
54
+ }
55
+ for (let index = events.length - 1; index >= 0; index -= 1) {
56
+ const event = events[index];
57
+ if (event?.type === "tool/result") return false;
58
+ if (event?.type === "user/message") return true;
59
+ }
60
+ return false;
61
+ };
62
+
63
+ const listener = async (payload, next) => {
64
+ const proposed = await next();
65
+ const agent = payload?.agent;
66
+ if (agent?.session === undefined) return proposed;
67
+ if (!runner.participates("route")) return proposed;
68
+
69
+ try {
70
+ const cfg = getConfig();
71
+ // 下沉目标没配就完全不判断:留空等于「不路由」,问一次模型再放弃是纯浪费。
72
+ if (
73
+ typeof cfg.routeCheapProvider !== "string" ||
74
+ cfg.routeCheapProvider.length === 0 ||
75
+ typeof cfg.routeCheapModel !== "string" ||
76
+ cfg.routeCheapModel.length === 0
77
+ ) {
78
+ return proposed;
79
+ }
80
+ const state = stateFor(agent.session);
81
+ if (state.turn !== payload.turn) {
82
+ state.turn = payload.turn;
83
+ state.askedSteps.clear();
84
+ }
85
+
86
+ // 闸1:本轮已问够步数就不再问,沿用当前(可能已被替换的)路由。
87
+ if (state.askedSteps.size >= Math.max(1, cfg.routeMaxStepsPerTurn ?? 3)) return proposed;
88
+
89
+ // 闸2:路由没变过就不必重问——裁决已经在上一问里生效。
90
+ if (state.lastProvider === proposed.provider && state.lastModel === proposed.model) {
91
+ // 但同一轮里第一次见到这个路由时,还是要问一次。
92
+ if (state.askedSteps.size > 0) return proposed;
93
+ }
94
+
95
+ // 闸3:新用户输入带来的步骤通常是新推理,问了也多半 keep。
96
+ if (hasFreshUserInput(agent.session) && state.askedSteps.size > 0) return proposed;
97
+
98
+ // 已经在用便宜模型了,再往下沉没有意义。
99
+ if (
100
+ proposed.provider === cfg.routeCheapProvider &&
101
+ proposed.model === cfg.routeCheapModel
102
+ ) {
103
+ return proposed;
104
+ }
105
+
106
+ state.askedSteps.add(payload.step);
107
+
108
+ const result = await runner.ask(
109
+ "route",
110
+ {
111
+ turn: payload.turn,
112
+ step: payload.step,
113
+ current: { provider: proposed.provider, model: proposed.model },
114
+ recent: digestRecent(agent.session, cfg.recentMessages, cfg.recentMessageChars),
115
+ },
116
+ { signal: payload.signal },
117
+ );
118
+
119
+ if (result.action?.kind !== "switch") return proposed;
120
+ // 校验目标路由确实配好了:留空等于「不路由」,不能真去请求空 provider。
121
+ const provider = result.action.provider;
122
+ const model = result.action.model;
123
+ if (typeof provider !== "string" || provider.length === 0) return proposed;
124
+ if (typeof model !== "string" || model.length === 0) return proposed;
125
+ if (provider === proposed.provider && model === proposed.model) return proposed;
126
+
127
+ // observe 到这里就结束:记录已写,但不改路由。
128
+ if (!runner.enforces("route")) return proposed;
129
+
130
+ state.lastProvider = provider;
131
+ state.lastModel = model;
132
+ ctx.logger?.info?.(
133
+ `dsh-decision-engine: 第 ${payload.turn}/${payload.step} 步路由 ${proposed.provider}/${proposed.model} → ${provider}/${model}`,
134
+ );
135
+ // 只覆盖 provider/model,其余字段(maxTokens / reasoningEffort / temperature)
136
+ // 原样保留——它们是宿主已解析好的,弄丢会改变请求语义。
137
+ return { ...proposed, provider, model };
138
+ } catch (error) {
139
+ telemetry.noteAdapterError("route", error, ctx);
140
+ return proposed;
141
+ }
142
+ };
143
+
144
+ ctx.on("agent/request", listener);
145
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * 检索必要性 + 技能选择的 tools/pre-execute 适配器。
3
+ *
4
+ * 这两个点都挂在 `tools/pre-execute` 上,但只对「特定工具」生效:
5
+ *
6
+ * 检索必要性 只有 web_search / web_fetch / research 等检索工具才判断。
7
+ * 其余工具一行 `includes` 就返回,零请求零延迟。
8
+ * 技能选择 只有 `skill` 工具、且候选清单非空时才判断。
9
+ *
10
+ * 「只对特定工具生效」不是优化,而是必要条件:这两个点在注册表里都是
11
+ * 非热路径,但 `tools/pre-execute` 本身是每次工具调用都会走的,
12
+ * 若不按工具名过滤,就等于给每个 bash/read/edit 都加 2 秒。
13
+ *
14
+ * 两者的生效方式不同:
15
+ * 检索 deny 直接拦下(这是唯一能真省下搜索 token 的方式)
16
+ * 技能 deny 会给出更匹配的名字(拦掉错的、指对对的)
17
+ *
18
+ * @module dsh-decision-engine/adapters/tools
19
+ */
20
+
21
+ /** 检索类工具名(默认值;可在配置里用 retrievalTools 覆盖)。 */
22
+ export const DEFAULT_RETRIEVAL_TOOLS = ["web_search", "web_fetch", "research", "multi_search", "advanced_search"];
23
+
24
+ /** 技能加载工具名。 */
25
+ export const SKILL_TOOL = "skill";
26
+
27
+ /**
28
+ * 判断一次工具调用是不是检索。
29
+ * @param name - 工具名。
30
+ * @param tools - 配置里的检索工具名集合。
31
+ * @returns 是否按检索处理。
32
+ */
33
+ export function isRetrievalTool(name, tools) {
34
+ return typeof name === "string" && tools.has(name);
35
+ }
36
+
37
+ /**
38
+ * 从工具参数里摘出「这次要检索什么」。
39
+ *
40
+ * 不同检索工具的参数名不一样(web_search 用 queries,web_fetch 用 url,
41
+ * research 用 question),逐个认;认不出来就退回整段参数预览。
42
+ * 这个字符串同时是软缓存的签名——同一个问题问第二遍应该命中缓存。
43
+ *
44
+ * @param args - 工具参数。
45
+ * @returns 用于 state 与缓存的查询文本。
46
+ */
47
+ export function retrievalQuery(args) {
48
+ if (args === null || typeof args !== "object") return "";
49
+ const candidates = [args.query, args.question, args.url, args.prompt];
50
+ if (Array.isArray(args.queries)) candidates.push(args.queries.join(" | "));
51
+ for (const candidate of candidates) {
52
+ if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim();
53
+ }
54
+ try {
55
+ return JSON.stringify(args).slice(0, 400);
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 挂载检索必要性 + 技能选择两个监听器。
63
+ *
64
+ * 两者合成一个 `tools/pre-execute` 监听器而不是注册两个:一个监听器里先按
65
+ * 工具名分派,省掉一次 dispatch 往返,也让「这次调用归哪个点管」在日志里
66
+ * 只有一个明确答案(不会出现两个点同时记录同一次调用)。
67
+ *
68
+ * @param ctx - cordis 插件上下文。
69
+ * @param deps - 运行期依赖。
70
+ */
71
+ export function mountToolPoints(ctx, deps) {
72
+ const { runner, getConfig, digestRecent, telemetry } = deps;
73
+
74
+ const listener = async (exec, next) => {
75
+ if (exec?.agent === undefined) return next();
76
+ const name = exec.name;
77
+ const cfg = getConfig();
78
+
79
+ // ---- 技能选择 ------------------------------------------------------
80
+ if (name === SKILL_TOOL && runner.participates("skillPick")) {
81
+ try {
82
+ const requested = typeof exec.arguments?.name === "string" ? exec.arguments.name : "";
83
+ const candidates = await loadSkillCandidates(ctx, exec, cfg);
84
+ // 候选太少时挑选没有意义,直接放行——别为一次「只有一个选项」付费。
85
+ if (candidates.length >= 2) {
86
+ const result = await runner.ask(
87
+ "skillPick",
88
+ {
89
+ requested,
90
+ candidates,
91
+ recent: digestRecent(exec.agent.session, cfg.recentMessages, cfg.recentMessageChars),
92
+ },
93
+ { tool: name, signal: exec.signal, softCacheable: (value) => value !== undefined },
94
+ );
95
+ // observe 只记录;enforce 且判出更匹配的技能时才改写这次调用。
96
+ if (runner.enforces("skillPick") && result.action?.kind === "redirect") {
97
+ const suggest = result.action.suggest;
98
+ if (typeof suggest === "string" && suggest.length > 0 && suggest !== requested) {
99
+ // 不改写参数(那会替模型做决定),而是拦下并告知更匹配的那个:
100
+ // 模型看到原因后可以自己重发,避免「装错再换」付两遍正文 token。
101
+ return {
102
+ kind: "deny",
103
+ reason: `${result.action.reason}。若要装该技能,请改用以 skill(name: "${suggest}") 重试。`,
104
+ info: { name: "StartluxSkillPickError", code: "STARTLUX_SKILLPICK_REDIRECT", suggest },
105
+ };
106
+ }
107
+ }
108
+ }
109
+ } catch (error) {
110
+ telemetry.noteAdapterError("skillPick", error, ctx);
111
+ }
112
+ return next();
113
+ }
114
+
115
+ // ---- 检索必要性 ----------------------------------------------------
116
+ if (runner.participates("retrieval") && isRetrievalTool(name, deps.retrievalTools())) {
117
+ try {
118
+ const query = retrievalQuery(exec.arguments);
119
+ const result = await runner.ask(
120
+ "retrieval",
121
+ {
122
+ tool: name,
123
+ query,
124
+ recent: digestRecent(exec.agent.session, cfg.recentMessages, cfg.recentMessageChars),
125
+ },
126
+ {
127
+ tool: name,
128
+ signal: exec.signal,
129
+ // 只有「检索有必要」的裁决可跨调用复用;skip 永不复用,
130
+ // 免得一次「已知」把后续同类问题全部拦掉。
131
+ softCacheable: (value) => value !== undefined,
132
+ },
133
+ );
134
+ if (runner.enforces("retrieval") && result.action?.kind === "skip") {
135
+ return {
136
+ kind: "deny",
137
+ reason: result.action.reason,
138
+ info: { name: "StartluxRetrievalError", code: "STARTLUX_RETRIEVAL_SKIP" },
139
+ };
140
+ }
141
+ } catch (error) {
142
+ telemetry.noteAdapterError("retrieval", error, ctx);
143
+ }
144
+ return next();
145
+ }
146
+
147
+ return next();
148
+ };
149
+
150
+ ctx.on("tools/pre-execute", listener);
151
+ }
152
+
153
+ /**
154
+ * 取当前可见的技能候选清单(名称 + 描述)。
155
+ *
156
+ * 只带名字和一行描述,绝不带正文:决策模型每次约 1.9k token,把技能正文
157
+ * 塞进 state 会让这个「省钱的点」变成主要花销。正文只在模型真正加载时付费。
158
+ *
159
+ * @param ctx - 插件上下文(用于 `ctx.get('skills')`)。
160
+ * @param exec - 待执行的工具调用(提供 agent scope 与 cwd)。
161
+ * @param cfg - 当前配置。
162
+ * @returns 候选数组;取不到时返回空数组。
163
+ */
164
+ async function loadSkillCandidates(ctx, exec, cfg) {
165
+ const skills = ctx.get?.("skills");
166
+ if (skills === undefined || typeof skills.list !== "function") return [];
167
+ let summaries;
168
+ try {
169
+ summaries = await skills.list({
170
+ cwd: exec.agent?.session?.header?.cwd,
171
+ signal: exec.signal,
172
+ scope: exec.agent,
173
+ });
174
+ } catch {
175
+ return [];
176
+ }
177
+ // 决策模型每次只有很小的一段 state;候选超过上限就截断,宁可少挑不可超预算。
178
+ const limit = Math.max(2, cfg.skillPickCandidates ?? 8);
179
+ return summaries.slice(0, limit).map((summary) => ({
180
+ name: String(summary.name ?? ""),
181
+ description: String(summary.description ?? "").slice(0, 160),
182
+ }));
183
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * 思维链翻译门槛:包住 `translator.translate()`。
3
+ *
4
+ * 翻译链路没有提供任何 hook —— `ctx.translator` 是一个 Service,`cotTranslation`
5
+ * 直接调用它的 `translate(spec, signal)`。所以这里只能包住那个方法。
6
+ *
7
+ * 包装是「有边界的、可撤销的」,不是野生 monkeypatch:
8
+ *
9
+ * - 用 `ctx.effect(...)` 注册,卸载时一定把原方法装回去;
10
+ * - 原方法的 `this` 保持为 translator 实例,签名与返回值完全不变;
11
+ * - 判定失败一律调用原方法(fail-open),绝不让翻译因为守卫而不可用;
12
+ * - 返回一个空串表示「不需要翻译」会破坏调用方语义,因此**跳过翻译时
13
+ * 返回原文**——调用方拿到的是「已经是目标语言」的合理解释。
14
+ *
15
+ * 生效方式与省 token 的关系:翻译按条计费。判为 skip 时直接返回原文,
16
+ * 那条就完全不发请求;判为 translate 时正常走下原方法。
17
+ *
18
+ * @module dsh-decision-engine/adapters/translate
19
+ */
20
+
21
+ /** 判断一段文本里「值得翻译的正文」占比是否够高(本地预筛,免费的)。 */
22
+ function looksLikeProse(text) {
23
+ if (typeof text !== "string" || text.length === 0) return false;
24
+ // 去掉代码块、行内代码、URL —— 这些不需要翻译。
25
+ const prose = text
26
+ .replace(/```[\s\S]*?```/g, " ")
27
+ .replace(/`[^`]*`/g, " ")
28
+ .replace(/https?:\/\/\S+/g, " ");
29
+ // 统计字母/汉字数量;纯符号/数字/路径不值得发一次翻译请求。
30
+ let letters = 0;
31
+ for (const char of prose) {
32
+ if (/[\p{L}]/u.test(char)) letters += 1;
33
+ }
34
+ return letters >= 4;
35
+ }
36
+
37
+ /**
38
+ * 包住 translator.translate。
39
+ *
40
+ * @param ctx - cordis 插件上下文。
41
+ * @param deps - 运行期依赖。
42
+ * @returns 是否成功挂载。
43
+ */
44
+ export function mountCotTranslatePoint(ctx, deps) {
45
+ const { runner, getConfig, telemetry } = deps;
46
+
47
+ const translator = ctx.get?.("translator");
48
+ if (translator === undefined || typeof translator.translate !== "function") {
49
+ ctx.logger?.info?.("dsh-decision-engine: 未找到 translator 服务,思维链翻译门槛不挂载");
50
+ return false;
51
+ }
52
+
53
+ const original = translator.translate;
54
+ /** 命中缓存的「需要翻译」判断:同一段文本重复翻译时不再问模型。 */
55
+ const decisions = new Map();
56
+ const MAX_DECISIONS = 256;
57
+
58
+ const remember = (key, value) => {
59
+ if (decisions.has(key)) decisions.delete(key);
60
+ decisions.set(key, value);
61
+ while (decisions.size > MAX_DECISIONS) {
62
+ const oldest = decisions.keys().next();
63
+ if (oldest.done) break;
64
+ decisions.delete(oldest.value);
65
+ }
66
+ };
67
+
68
+ const wrapped = async function translate(spec, signal) {
69
+ // 这个点关掉时连一次判断都不做,直接走原路径。
70
+ if (!runner.participates("cotTranslate")) return original.call(this, spec, signal);
71
+
72
+ try {
73
+ const text = typeof spec?.text === "string" ? spec.text : "";
74
+ // 本地预筛:明显不需要翻译的文本零成本跳过,不必问模型。
75
+ if (!looksLikeProse(text)) {
76
+ await telemetry.log({
77
+ ts: new Date().toISOString(),
78
+ point: "cotTranslate",
79
+ tag: "cotTranslate",
80
+ action: "skip",
81
+ detail: "decision-engine-cot: 本地预筛判定为无正文,跳过翻译",
82
+ latencyMs: 0,
83
+ cached: false,
84
+ });
85
+ return text;
86
+ }
87
+
88
+ // 缓存键:目标语言 + 文本摘要。同一段文本重复翻译(重放、重渲染)很常见。
89
+ const key = `${spec.targetLanguage ?? "auto"}|${text.length}|${text.slice(0, 64)}`;
90
+ let verdict = decisions.get(key);
91
+ if (verdict === undefined) {
92
+ const cfg = getConfig();
93
+ const result = await runner.ask(
94
+ "cotTranslate",
95
+ {
96
+ text,
97
+ targetLanguage: spec.targetLanguage,
98
+ sourceLanguage: spec.sourceLanguage,
99
+ head: text.slice(0, 400),
100
+ },
101
+ { signal },
102
+ );
103
+ // 降级 / observe / 关闭:一律按「需要翻译」处理,走原方法。
104
+ if (result.action === undefined || !runner.enforces("cotTranslate")) {
105
+ return original.call(this, spec, signal);
106
+ }
107
+ verdict = result.action.kind === "translate";
108
+ remember(key, verdict);
109
+ }
110
+
111
+ if (verdict === true) return original.call(this, spec, signal);
112
+
113
+ // 判为不需要翻译:返回原文。这与「已经是目标语言」的语义一致,
114
+ // 调用方不会看到异常,用户看到的还是那段文本。
115
+ await telemetry.log({
116
+ ts: new Date().toISOString(),
117
+ point: "cotTranslate",
118
+ tag: "cotTranslate",
119
+ action: "skip",
120
+ detail: "decision-engine-cot: 决策判定无需翻译,返回原文",
121
+ latencyMs: 0,
122
+ cached: true,
123
+ });
124
+ return text;
125
+ } catch (error) {
126
+ telemetry.noteAdapterError("cotTranslate", error, ctx);
127
+ // 任何意外都必须回到原生行为:翻译不能因为守卫而不可用。
128
+ return original.call(this, spec, signal);
129
+ }
130
+ };
131
+
132
+ translator.translate = wrapped;
133
+ // 卸载时恢复原方法:否则插件热卸载后 translator 会一直指着我们的包装。
134
+ ctx.effect(() => () => {
135
+ if (translator.translate === wrapped) translator.translate = original;
136
+ });
137
+ return true;
138
+ }
package/lib/budget.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 决策模型自身的 token 预算。
3
+ *
4
+ * 引入理由:StartLux 每次决策实测约 1,900 token,p50 延迟约 2 秒。
5
+ * 把这个判断层挂到多个决策点上,调用量会成倍上升——如果不设上限,
6
+ * 「省 token 的插件」反而会变成主要的 token 消耗方。
7
+ *
8
+ * 预算按滑动窗口统计(默认 1 小时)。一旦超限,所有决策点立刻退回
9
+ * 本地启发式并 fail-open,等窗口滚过去自动恢复;不需要重启,也不会
10
+ * 让工具调用被卡住。
11
+ */
12
+ export class TokenBudget {
13
+ /**
14
+ * @param getLimitTokens - 返回当前上限的取值函数;0 表示不限制。
15
+ * 刻意接收函数而不是数值:本插件的原则是「配置每次现读」,
16
+ * 若在构造时固化,用户在配置页改了预算就得重启才生效。
17
+ * @param getWindowMs - 返回当前窗口长度的取值函数。
18
+ */
19
+ constructor(getLimitTokens, getWindowMs) {
20
+ this.getLimitTokens = getLimitTokens;
21
+ this.getWindowMs = getWindowMs;
22
+ /** @type {Array<{at: number, tokens: number}>} */
23
+ this.entries = [];
24
+ this.blocked = 0;
25
+ }
26
+
27
+ /** 当前生效的上限(读不到时按不限制处理,绝不因预算自身出错而卡住决策)。 */
28
+ get limitTokens() {
29
+ const raw = typeof this.getLimitTokens === "function" ? this.getLimitTokens() : this.getLimitTokens;
30
+ const value = Number(raw);
31
+ return Number.isFinite(value) && value > 0 ? value : 0;
32
+ }
33
+
34
+ /** 当前生效的窗口长度。 */
35
+ get windowMs() {
36
+ const raw = typeof this.getWindowMs === "function" ? this.getWindowMs() : this.getWindowMs;
37
+ const value = Number(raw);
38
+ return Number.isFinite(value) && value > 0 ? value : 3600000;
39
+ }
40
+
41
+ /** 丢弃窗口外的记录。 */
42
+ prune(now = Date.now()) {
43
+ const cutoff = now - this.windowMs;
44
+ let drop = 0;
45
+ while (drop < this.entries.length && this.entries[drop].at < cutoff) drop += 1;
46
+ if (drop > 0) this.entries.splice(0, drop);
47
+ }
48
+
49
+ /** 窗口内已消耗的 token。 */
50
+ used(now = Date.now()) {
51
+ this.prune(now);
52
+ let total = 0;
53
+ for (const entry of this.entries) total += entry.tokens;
54
+ return total;
55
+ }
56
+
57
+ /** 预算是否已耗尽(未设置上限时永远 false)。 */
58
+ exhausted(now = Date.now()) {
59
+ if (this.limitTokens <= 0) return false;
60
+ return this.used(now) >= this.limitTokens;
61
+ }
62
+
63
+ /**
64
+ * 记一次消耗。
65
+ * @param tokens - 本次估算或实测的 input token 数。
66
+ */
67
+ spend(tokens) {
68
+ const amount = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
69
+ if (amount === 0) return;
70
+ this.entries.push({ at: Date.now(), tokens: amount });
71
+ }
72
+
73
+ /** 记一次「因为预算耗尽而放弃判断」,用于配置页展示。 */
74
+ noteBlocked() {
75
+ this.blocked += 1;
76
+ }
77
+
78
+ /** 供 UI 展示的快照。 */
79
+ get report() {
80
+ const now = Date.now();
81
+ const used = this.used(now);
82
+ const limit = this.limitTokens;
83
+ return {
84
+ used,
85
+ limit,
86
+ windowMs: this.windowMs,
87
+ remaining: limit <= 0 ? null : Math.max(0, limit - used),
88
+ exhausted: this.exhausted(now),
89
+ blocked: this.blocked,
90
+ };
91
+ }
92
+ }