@leonhardthust/dsh-decision-engine 1.9.2 → 1.11.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/README.md +3 -2
- package/README.zh.md +2 -2
- package/config.example.yml +70 -30
- package/docs/releasing.md +4 -4
- package/lib/adapters/agent.js +167 -9
- package/lib/adapters/route.js +42 -10
- package/lib/adapters/tools.js +109 -6
- package/lib/adapters/translate.js +94 -9
- package/lib/client.js +695 -227
- package/lib/decision-log.js +501 -0
- package/lib/decision-store.js +756 -97
- package/lib/engine.js +44 -31
- package/lib/guard/effects.js +14 -0
- package/lib/guard/listener.js +28 -3
- package/lib/guard/messages.js +35 -0
- package/lib/index.js +330 -15
- package/lib/outcomes.js +3 -1
- package/lib/points/spec.js +86 -5
- package/lib/providers.js +57 -8
- package/lib/tolerance.js +50 -40
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
27
27
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
28
|
-
import { dirname, join } from "node:path";
|
|
28
|
+
import { basename, dirname, join } from "node:path";
|
|
29
29
|
import z from "@deepseek-ai/schemastery";
|
|
30
30
|
|
|
31
31
|
import { DecisionCache } from "./decision-cache.js";
|
|
@@ -46,7 +46,19 @@ import { resolveDshHome } from "./home-paths.js";
|
|
|
46
46
|
import { applyRefUpdates, listRefNames, pickRefValue } from "./credentials.js";
|
|
47
47
|
import { RANGE_LABELS, RANGE_PRESETS, aggregate, buildRecord, resolveRange, sortRows } from "./stats.js";
|
|
48
48
|
import { StatsStore } from "./stats-store.js";
|
|
49
|
-
import { buildDecisionRecord, DecisionStore, MAX_ENTRIES_PER_QUERY } from "./decision-store.js";
|
|
49
|
+
import { buildDecisionRecord, DecisionStore, MAX_ENTRIES_PER_QUERY, maskSecrets } from "./decision-store.js";
|
|
50
|
+
// 日志分片命名与紧凑格式:纯函数(零 I/O),放在独立模块里才能单测到
|
|
51
|
+
// 时区、Windows 保留名、截断哈希这些边界。
|
|
52
|
+
import {
|
|
53
|
+
DATE_SEPARATORS,
|
|
54
|
+
DEFAULT_DATE_SEPARATOR,
|
|
55
|
+
DEFAULT_RETENTION_DAYS,
|
|
56
|
+
DEFAULT_SPLIT_MODE,
|
|
57
|
+
DEFAULT_TOTAL_MAX_BYTES,
|
|
58
|
+
SPLIT_MODES,
|
|
59
|
+
normalizeDateSeparator,
|
|
60
|
+
normalizeSplitMode,
|
|
61
|
+
} from "./decision-log.js";
|
|
50
62
|
// 作用域边界(DEC-045):越出工作区的写入、模型给用户挑方案、模型等用户
|
|
51
63
|
// 澄清——这三类决定不属于本插件,遇到时让位给用户。
|
|
52
64
|
import { GUARD_FALLBACKS, guardModelCatalog } from "./guard/fallback.js";
|
|
@@ -257,6 +269,18 @@ const DEFAULTS = {
|
|
|
257
269
|
compactDistilledFloor: 0.6,
|
|
258
270
|
// 安全否决:仍需逐字原文时绝不裁剪(实测两类均 0.26,故门槛取高)。
|
|
259
271
|
compactVerbatinFloor: 0.6,
|
|
272
|
+
/**
|
|
273
|
+
* 是否允许 compact 点用「只丢工具结果」替代摘要(默认 **false**)。
|
|
274
|
+
*
|
|
275
|
+
* 为什么默认关:`distilled` 只是模型对「结论是否已落进对话」的一个概率,
|
|
276
|
+
* 而 state 里只有「有几条超长结果」这个数字——那几条结果是什么、哪些结论
|
|
277
|
+
* 真的保住了,都没有证据。而 prune **不可逆**:中间段被换成标记之后,
|
|
278
|
+
* 后续轮次再也拿不回原文。相比之下 compaction-basic 的摘要至少把信息
|
|
279
|
+
* 保成一段话。所以默认一律 summarize;需要极致省钱的部署再手改
|
|
280
|
+
* config.yml 打开它——与 `decisionsMaxBytes` 同类的**运维旋钮**,
|
|
281
|
+
* 刻意不进 `/config` 白名单。
|
|
282
|
+
*/
|
|
283
|
+
compactAllowPrune: false,
|
|
260
284
|
// ---- 轮次延续(pointTurnStop)-----------------------------------------
|
|
261
285
|
turnStopMaxSteers: 1,
|
|
262
286
|
// 实测:已完成 → closed 0.74 / unfin 0.09 / value 0.09;
|
|
@@ -354,8 +378,41 @@ const DEFAULTS = {
|
|
|
354
378
|
approvalDetailReadable: true,
|
|
355
379
|
/** 单条内容的序列化长度上限;超了按「参数 → 摘要 → 整个 state」依次裁剪。 */
|
|
356
380
|
decisionsMaxContentChars: 4000,
|
|
357
|
-
/**
|
|
381
|
+
/** 单个分片的体积上限(字节);到顶轮转成 `<分片>.1`(只留一代)。 */
|
|
358
382
|
decisionsMaxBytes: 8 * 1024 * 1024,
|
|
383
|
+
/**
|
|
384
|
+
* 决策日志的分文件方式(T3)。
|
|
385
|
+
*
|
|
386
|
+
* 默认 **date**:单文件在体量上来之后有两个硬问题——按会话查历史要扫全文件,
|
|
387
|
+
* 体积轮转还会把同一个会话的记录切开。按日期分片最贴合「今天的日志」这个
|
|
388
|
+
* 问法,也是清理(按保留天数删)最自然的单位。
|
|
389
|
+
*
|
|
390
|
+
* 其余三档给不同习惯的用户:session 按会话隔离、workspace 按项目归档、
|
|
391
|
+
* single 保留旧行为(一个文件,升级不改体验)。
|
|
392
|
+
*/
|
|
393
|
+
decisionsSplitMode: DEFAULT_SPLIT_MODE,
|
|
394
|
+
/**
|
|
395
|
+
* 日期分片的日期分隔符:`-` / `_` / `.`。
|
|
396
|
+
*
|
|
397
|
+
* 三档都是「同一种信息的三种写法」,纯粹为了兼容用户已有的脚本/别名
|
|
398
|
+
* (有人用 `2026-11-15`,也有人拿 `2026_11_15` 当搜索词)。
|
|
399
|
+
*/
|
|
400
|
+
decisionsDateSeparator: DEFAULT_DATE_SEPARATOR,
|
|
401
|
+
/**
|
|
402
|
+
* 日志保留天数:`0` = 不按天删(只受总量上限约束)。
|
|
403
|
+
*
|
|
404
|
+
* 默认 30 天:够回答「上个月这个问题出现过没有」,又不会让磁盘无限长。
|
|
405
|
+
* 清理只删**今天之前的**分片,绝不碰正在写的那个。
|
|
406
|
+
*/
|
|
407
|
+
decisionsRetentionDays: DEFAULT_RETENTION_DAYS,
|
|
408
|
+
/**
|
|
409
|
+
* `logs/` 的总量上限(字节,默认 64 MB)。
|
|
410
|
+
*
|
|
411
|
+
* 与 `decisionsMaxBytes`(单分片上限)是两个维度:前者约束整体磁盘占用,
|
|
412
|
+
* 后者约束「一个文件多大就开始轮转」。这是**运维旋钮**,刻意不进
|
|
413
|
+
* `/config` 白名单——用户日常不需要拨它,需要时手改 config.yml 即可。
|
|
414
|
+
*/
|
|
415
|
+
decisionsTotalMaxBytes: DEFAULT_TOTAL_MAX_BYTES,
|
|
359
416
|
/**
|
|
360
417
|
* 人工审核比例(tolerance,DEC-035)。
|
|
361
418
|
*
|
|
@@ -464,7 +521,7 @@ export const Config = z.object({
|
|
|
464
521
|
retryAttempts: z.number().step(1).min(0).max(3).default(DEFAULTS.retryAttempts)
|
|
465
522
|
.description("仅对超时与网络错误重试的次数;4xx/5xx 不重试(配置错与服务端故障,重发只会多烧额度)"),
|
|
466
523
|
thinkMode: z.union(["never", "auto", "always"]).default(DEFAULTS.thinkMode)
|
|
467
|
-
.description("never
|
|
524
|
+
.description("never 最省延迟;auto 由服务端决定;always 总是思考"),
|
|
468
525
|
cooldownMs: z.number().step(1).min(1000).default(DEFAULTS.cooldownMs),
|
|
469
526
|
cooldownProbeMs: z.number().step(1).min(1000).default(DEFAULTS.cooldownProbeMs)
|
|
470
527
|
.description("冷却期内每隔这么久放行一次试探请求;试探成功即彻底解除冷却"),
|
|
@@ -515,6 +572,12 @@ export const Config = z.object({
|
|
|
515
572
|
.description("判定「工具结果的信息已落地」的门槛;达线才允许只裁剪"),
|
|
516
573
|
compactVerbatinFloor: z.number().min(0).max(1).default(DEFAULTS.compactVerbatinFloor)
|
|
517
574
|
.description("仍需逐字原文的门槛;达线一律改走摘要"),
|
|
575
|
+
compactAllowPrune: z.boolean().default(DEFAULTS.compactAllowPrune)
|
|
576
|
+
.description(
|
|
577
|
+
"允许 compact 点只裁剪超长工具结果、跳过摘要(默认 false,始终交给宿主摘要)。"
|
|
578
|
+
+ "风险:裁剪不可逆,模型仅凭「结论已落地」的概率,误判会永久丢掉工具结果中间段。"
|
|
579
|
+
+ "运维旋钮,不在设置页提供",
|
|
580
|
+
),
|
|
518
581
|
// ---- 轮次延续 --------------------------------------------------------
|
|
519
582
|
turnStopMaxSteers: z.number().step(1).min(0).max(5).default(DEFAULTS.turnStopMaxSteers),
|
|
520
583
|
turnStopClosedFloor: z.number().min(0).max(1).default(DEFAULTS.turnStopClosedFloor),
|
|
@@ -572,11 +635,22 @@ export const Config = z.object({
|
|
|
572
635
|
decisionsMaxContentChars: z.number().step(1).min(200).default(DEFAULTS.decisionsMaxContentChars)
|
|
573
636
|
.description("单条决策内容的字符上限;超出时依次裁剪工具参数、对话摘要、整个 state"),
|
|
574
637
|
decisionsMaxBytes: z.number().step(1).min(1024 * 1024).default(DEFAULTS.decisionsMaxBytes)
|
|
575
|
-
.description("
|
|
638
|
+
.description("单个日志分片的体积上限(字节);到顶轮转成 <分片>.1,只留一代"),
|
|
639
|
+
decisionsSplitMode: z.union([...SPLIT_MODES]).default(DEFAULTS.decisionsSplitMode)
|
|
640
|
+
.description(
|
|
641
|
+
"日志分文件:single 单一文件 / date 按日期(默认)/ session 按会话 / workspace 按工作区。"
|
|
642
|
+
+ "文件名可能包含会话名或工作区名",
|
|
643
|
+
),
|
|
644
|
+
decisionsDateSeparator: z.union([...DATE_SEPARATORS]).default(DEFAULTS.decisionsDateSeparator)
|
|
645
|
+
.description("date 模式的日期分隔符:- / _ / .;只影响文件名"),
|
|
646
|
+
decisionsRetentionDays: z.number().step(1).min(0).max(3650).default(DEFAULTS.decisionsRetentionDays)
|
|
647
|
+
.description("日志保留天数(0 = 不按天删);每 6 小时清理一次,绝不删今天与正在写的分片"),
|
|
648
|
+
decisionsTotalMaxBytes: z.number().step(1).min(1024 * 1024).default(DEFAULTS.decisionsTotalMaxBytes)
|
|
649
|
+
.description("logs 目录的总量上限(字节,默认 64 MB);运维旋钮,不在设置页提供"),
|
|
576
650
|
tolerance: z.union([...TOLERANCE_LEVELS]).default(DEFAULTS.tolerance)
|
|
577
651
|
.description(
|
|
578
|
-
"
|
|
579
|
-
+ "default
|
|
652
|
+
"安全档位(模型无关的打扰频率):autonomous 不问(软性审查不弹窗)/ relaxed 少问(只按严重度尾部)/ "
|
|
653
|
+
+ "default 默认(沿用决策模型判断)/ cautious 多问(默认判断之上再加严重度带);四档都不改变权限强制的人工批准与拒绝",
|
|
580
654
|
),
|
|
581
655
|
});
|
|
582
656
|
|
|
@@ -748,6 +822,33 @@ export function validateProviderPatch(patch, current) {
|
|
|
748
822
|
};
|
|
749
823
|
}
|
|
750
824
|
|
|
825
|
+
// 日志分文件(T3):三档之外的值一律拒在落盘前。
|
|
826
|
+
//
|
|
827
|
+
// 为什么必须拦在这里:这些键直接决定**文件往哪写**。收到一个拼错的
|
|
828
|
+
// 模式名后若放行,`normalizeSplitMode()` 会静默退回 date——用户以为切到了
|
|
829
|
+
// 「按会话」,实际仍在按日期写,文件名里也不会出现会话名。这种静默降级
|
|
830
|
+
// 只能靠落盘前的校验暴露出来。
|
|
831
|
+
if (Object.hasOwn(patch, "decisionsSplitMode") && !SPLIT_MODES.includes(patch.decisionsSplitMode)) {
|
|
832
|
+
return {
|
|
833
|
+
ok: false,
|
|
834
|
+
error: `未知的日志分文件方式: ${String(patch.decisionsSplitMode)}(可选 ${SPLIT_MODES.join(" / ")})`,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
if (Object.hasOwn(patch, "decisionsDateSeparator") && !DATE_SEPARATORS.includes(patch.decisionsDateSeparator)) {
|
|
838
|
+
return {
|
|
839
|
+
ok: false,
|
|
840
|
+
error: `未知的日期分隔符: ${String(patch.decisionsDateSeparator)}(可选 ${DATE_SEPARATORS.join(" / ")})`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (Object.hasOwn(patch, "decisionsRetentionDays")) {
|
|
844
|
+
const days = patch.decisionsRetentionDays;
|
|
845
|
+
// 只认整数:`"30"` 这样字符串落盘后会被 Number() 读成 30,但界面的数字
|
|
846
|
+
// 输入框会把它当成非法值显示成空——两边对不上比直接拒绝更难排查。
|
|
847
|
+
if (typeof days !== "number" || !Number.isInteger(days) || days < 0 || days > 3650) {
|
|
848
|
+
return { ok: false, error: `日志保留天数必须是 0–3650 的整数: ${String(days)}` };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
751
852
|
// 变量名必须能当环境变量用,否则凭据永远读不到。
|
|
752
853
|
// 逐个预设取它自己的变量名覆盖字段,而不是写死三个名字——否则新增预设时
|
|
753
854
|
// 漏加这一项,用户填的变量名会被原样写进 yml,然后永远读不到 key
|
|
@@ -1805,7 +1906,18 @@ class StartluxClient {
|
|
|
1805
1906
|
// 这样即便服务商将来换模型名,留空配置也不会因此 400。
|
|
1806
1907
|
const model = (provider.model ?? "").trim();
|
|
1807
1908
|
|
|
1808
|
-
|
|
1909
|
+
/**
|
|
1910
|
+
* 精确键必须包含 `thinkMode`:它改变**请求体**(`think_mode` 字段),
|
|
1911
|
+
* 同一份 state 在 `never` 与 `always` 下拿到的裁决可能不同。不把它算进
|
|
1912
|
+
* 键里,用户切换思考模式后还会命中旧裁决——「改了设置没效果」。
|
|
1913
|
+
*/
|
|
1914
|
+
const exactKey = DecisionCache.exactKey([
|
|
1915
|
+
endpoint,
|
|
1916
|
+
model,
|
|
1917
|
+
normalizeThinkMode(cfg.thinkMode),
|
|
1918
|
+
call.state,
|
|
1919
|
+
call.questions,
|
|
1920
|
+
]);
|
|
1809
1921
|
// 软键(归一化「同类」复用)默认关闭,fail-closed:只有调用方显式传
|
|
1810
1922
|
// `softCacheable: true` 才启用。授权(authorize)这类安全检查绝不启用——
|
|
1811
1923
|
// 归一化签名只保留「工具名 + 命令族 + 风险标记」,丢弃了参数内容,
|
|
@@ -2271,13 +2383,33 @@ export function apply(ctx, config) {
|
|
|
2271
2383
|
getServerOverride: serverOverride,
|
|
2272
2384
|
refreshServer: refreshServerInBackground,
|
|
2273
2385
|
});
|
|
2386
|
+
/**
|
|
2387
|
+
* 会话名 / 工作区名解析器(session / workspace 分文件模式的命名依据)。
|
|
2388
|
+
*
|
|
2389
|
+
* 实现放在模块级 `createSessionMetaResolver()`:读标题的顺序、隐私清洗
|
|
2390
|
+
* 与缓存都是**纯逻辑**,可以和宿主解耦单测(见 decisions-route.test.js)。
|
|
2391
|
+
* 这里只把宿主服务接进去。
|
|
2392
|
+
*/
|
|
2393
|
+
const sessionTitleService = () => {
|
|
2394
|
+
try {
|
|
2395
|
+
return ctx.get?.("sessionTitle");
|
|
2396
|
+
} catch {
|
|
2397
|
+
return undefined;
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
const sessionMetaOf = createSessionMetaResolver({ getTitleService: sessionTitleService });
|
|
2401
|
+
|
|
2274
2402
|
/**
|
|
2275
2403
|
* 决策明细存储(「决策历史」页的数据源,见 lib/decision-store.js)。
|
|
2276
2404
|
*
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
2279
|
-
*
|
|
2280
|
-
* 只反映 `truncateContent()`
|
|
2405
|
+
* 所有取值函数都由配置现读,因此配置页改了开关/分文件方式立刻生效——
|
|
2406
|
+
* 不需要重启,与插件其余配置的处理方式一致。开关关闭时 `record()` 直接
|
|
2407
|
+
* 返回 false,既不建目录也不占磁盘;开启时明细一定带内容(每条记录上的
|
|
2408
|
+
* `capture` 只反映 `truncateContent()` 的截断结果,没有任何配置能把它
|
|
2409
|
+
* 降级成 meta)。
|
|
2410
|
+
*
|
|
2411
|
+
* 时区不单独配置:日期分片按**系统时区**切(用户看到的日历就是系统日历)。
|
|
2412
|
+
* 想按别处时区归档,是罕见需求;为它加一个配置键会把设置页撑得更长。
|
|
2281
2413
|
*/
|
|
2282
2414
|
const decisionStore = new DecisionStore({
|
|
2283
2415
|
dir: telemetryDir,
|
|
@@ -2285,6 +2417,10 @@ export function apply(ctx, config) {
|
|
|
2285
2417
|
getEnabled: () => cfg.decisionsTabEnabled !== false,
|
|
2286
2418
|
getMaxBytes: () => cfg.decisionsMaxBytes,
|
|
2287
2419
|
getMaxContentChars: () => cfg.decisionsMaxContentChars,
|
|
2420
|
+
getSplitMode: () => cfg.decisionsSplitMode,
|
|
2421
|
+
getDateSeparator: () => cfg.decisionsDateSeparator,
|
|
2422
|
+
getRetentionDays: () => cfg.decisionsRetentionDays,
|
|
2423
|
+
getTotalMaxBytes: () => cfg.decisionsTotalMaxBytes,
|
|
2288
2424
|
});
|
|
2289
2425
|
|
|
2290
2426
|
/**
|
|
@@ -2335,9 +2471,59 @@ export function apply(ctx, config) {
|
|
|
2335
2471
|
}
|
|
2336
2472
|
})
|
|
2337
2473
|
.catch(() => {
|
|
2338
|
-
/*
|
|
2474
|
+
/* 历史统计整理失败不影响插件启动 */
|
|
2475
|
+
})
|
|
2476
|
+
.then(() => {
|
|
2477
|
+
/**
|
|
2478
|
+
* 决策日志的两步整理,**必须排在统计回填之后、且自己串行**。
|
|
2479
|
+
*
|
|
2480
|
+
* 1. `migrateLegacyOnce()`:旧 `decisions.jsonl` → `logs/` 日期分片。
|
|
2481
|
+
* 它自己靠标记文件保证只做一次;失败只 warn,旧文件仍可读。
|
|
2482
|
+
* 2. `cleanupOnce()`:按保留天数 + 总量上限清理过期分片。
|
|
2483
|
+
*
|
|
2484
|
+
* 为什么不能与第 1 步并发:清理会枚举 `logs/`,若迁移正往目录里写分片,
|
|
2485
|
+
* 清理可能对着一个刚创建、还没写完的文件做删除判断(Windows 上删不掉、
|
|
2486
|
+
* 其它平台可能删掉半份数据)。迁移是毫秒级动作,串行没有成本。
|
|
2487
|
+
*
|
|
2488
|
+
* 注意上面那道 `.catch()` 的位置:统计整理失败时必须**继续**往下走,
|
|
2489
|
+
* 否则「旧遥测坏了」会连带把日志迁移也永久跳过(两个子系统本无关系)。
|
|
2490
|
+
*/
|
|
2491
|
+
return decisionStore.migrateLegacyOnce().then((migrated) => {
|
|
2492
|
+
if (migrated > 0) {
|
|
2493
|
+
ctx.logger?.info?.(
|
|
2494
|
+
`dsh-decision-engine: 已把 ${migrated} 条旧决策明细迁移到按日期分片(旧文件已改名 .migrated,未删除)`,
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
return decisionStore.cleanupOnce();
|
|
2498
|
+
});
|
|
2499
|
+
})
|
|
2500
|
+
.then((report) => {
|
|
2501
|
+
if (report && report.removed > 0) {
|
|
2502
|
+
ctx.logger?.info?.(
|
|
2503
|
+
`dsh-decision-engine: 已清理 ${report.removed} 个过期决策日志分片(释放 ${report.removedBytes} 字节)`,
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
})
|
|
2507
|
+
.catch(() => {
|
|
2508
|
+
/* 日志整理失败不影响插件启动 */
|
|
2339
2509
|
});
|
|
2340
2510
|
|
|
2511
|
+
/**
|
|
2512
|
+
* 定期清理(每 6 小时)。
|
|
2513
|
+
*
|
|
2514
|
+
* 为什么需要它:只在启动时清理的话,DSH 长期不重启(真实用法常常是开着
|
|
2515
|
+
* 几天)就会一直涨;而 `unref()` 保证这个定时器不会拦住进程退出。
|
|
2516
|
+
* 每一轮都是**尽力而为**:失败只 warn,下一轮再来。
|
|
2517
|
+
*
|
|
2518
|
+
* 6 小时的选择:清理本身很便宜(一次 readdir + 若干 unlink),而频率再高
|
|
2519
|
+
* 也不会有更多收益——保留期以「天」为单位。
|
|
2520
|
+
*/
|
|
2521
|
+
const decisionsCleanupTimer = setInterval(() => {
|
|
2522
|
+
void decisionStore.cleanupOnce().catch(() => {});
|
|
2523
|
+
}, 6 * 60 * 60 * 1000);
|
|
2524
|
+
// 定时器不该成为进程退出的理由(Node 的 unref 语义;测试进程尤为重要)。
|
|
2525
|
+
decisionsCleanupTimer.unref?.();
|
|
2526
|
+
|
|
2341
2527
|
|
|
2342
2528
|
/**
|
|
2343
2529
|
* 匿名使用统计(DEC-020)。
|
|
@@ -2623,6 +2809,8 @@ export function apply(ctx, config) {
|
|
|
2623
2809
|
...(result?.answers === undefined ? {} : { answers: result.answers }),
|
|
2624
2810
|
},
|
|
2625
2811
|
}),
|
|
2812
|
+
// 分文件模式要会话名/工作区名;守卫路径本来就握着 Session 对象。
|
|
2813
|
+
sessionMetaOf(options.session),
|
|
2626
2814
|
);
|
|
2627
2815
|
} catch (error) {
|
|
2628
2816
|
ctx.logger?.warn?.(
|
|
@@ -2664,10 +2852,14 @@ export function apply(ctx, config) {
|
|
|
2664
2852
|
* 决策明细(「决策历史」页)。与 `log`(遥测)是两条独立旁路:
|
|
2665
2853
|
* 遥测是「服务端说了什么」的结论口径,明细是本地的现场 + 会话归属。
|
|
2666
2854
|
* 这里补上限指针——截断策略属于存储层,引擎不该知道配置项叫什么。
|
|
2855
|
+
*
|
|
2856
|
+
* 第二个参数是**会话引用**(Session 对象或 id 字符串,由 engine.js 原样
|
|
2857
|
+
* 传下来):session/workspace 分文件模式要拿它的名字做文件名。
|
|
2667
2858
|
*/
|
|
2668
|
-
recordDetail: (record) =>
|
|
2859
|
+
recordDetail: (record, session) =>
|
|
2669
2860
|
decisionStore.record(
|
|
2670
2861
|
buildDecisionRecord({ ...record, maxContentChars: cfg.decisionsMaxContentChars }),
|
|
2862
|
+
sessionMetaOf(session),
|
|
2671
2863
|
),
|
|
2672
2864
|
});
|
|
2673
2865
|
|
|
@@ -3160,6 +3352,21 @@ export function apply(ctx, config) {
|
|
|
3160
3352
|
// 才是真值;回 `undefined` 会让界面把开关画成关。
|
|
3161
3353
|
approvalDetailReadable: cfg.approvalDetailReadable !== false,
|
|
3162
3354
|
decisionsFile: decisionStore.path,
|
|
3355
|
+
// 日志分文件与保留(T3):配置页的三行控件都靠这几项回显。
|
|
3356
|
+
//
|
|
3357
|
+
// **必须回传生效值**(归一化之后再回):用户可能把 config.yml 手改成
|
|
3358
|
+
// `decisionsSplitMode: Day` 这种拼错的值,`*normalize*` 会把它当 date;
|
|
3359
|
+
// 若照原样回显,界面会把「按日期」画成选中、却把一个不存在的选项
|
|
3360
|
+
// 塞进下拉——保存一次就又多了一个坏值。
|
|
3361
|
+
decisionsSplitMode: normalizeSplitMode(cfg.decisionsSplitMode),
|
|
3362
|
+
decisionsSplitModes: SPLIT_MODES,
|
|
3363
|
+
decisionsDateSeparator: normalizeDateSeparator(cfg.decisionsDateSeparator),
|
|
3364
|
+
decisionsDateSeparators: DATE_SEPARATORS,
|
|
3365
|
+
decisionsRetentionDays: decisionStore.retentionDays(),
|
|
3366
|
+
// 清理结果(文件数 / 字节数 / 上次清理时间):设置页用它说明
|
|
3367
|
+
// 「现在磁盘上大概有多少日志」。这是**上次清理时**的快照,不是实时
|
|
3368
|
+
// 扫描——状态接口不该每次都去 stat 一整个目录。
|
|
3369
|
+
decisionsStorage: decisionStore.storageReport(),
|
|
3163
3370
|
// 人工审核比例(DEC-035):配置页滑块的唯一数据源。
|
|
3164
3371
|
tolerance: tolerancePayload(),
|
|
3165
3372
|
stats: client.stats,
|
|
@@ -3282,6 +3489,12 @@ export function apply(ctx, config) {
|
|
|
3282
3489
|
// 改截断长度),不是用户日常会拨的开关,走配置页会把界面变复杂;
|
|
3283
3490
|
// 需要时手改 config.yml 即可。
|
|
3284
3491
|
"decisionsTabEnabled",
|
|
3492
|
+
// 日志分文件与保留(T3):这三个是**用户日常会拨**的开关,必须能
|
|
3493
|
+
// 保存。`decisionsTotalMaxBytes` 与 `decisionsMaxBytes` 则是运维
|
|
3494
|
+
// 旋钮,刻意不在白名单里(理由见上面那段)。
|
|
3495
|
+
"decisionsSplitMode",
|
|
3496
|
+
"decisionsDateSeparator",
|
|
3497
|
+
"decisionsRetentionDays",
|
|
3285
3498
|
// 审批卡正文可读化(DEC-042):配置页要能拨,否则拨了也存不下去。
|
|
3286
3499
|
"approvalDetailReadable",
|
|
3287
3500
|
// 服务端下发开关(DEC-030):不在白名单里的后果与上面 telemetryLevel
|
|
@@ -3684,7 +3897,8 @@ export function apply(ctx, config) {
|
|
|
3684
3897
|
* 与 `/log` 的区别不是路径而是**口径**:
|
|
3685
3898
|
*
|
|
3686
3899
|
* - `/log` 读的是 telemetry.jsonl(全机、无会话维度、只有结论);
|
|
3687
|
-
* - 这里读的是
|
|
3900
|
+
* - 这里读的是 `logs/` 下的决策明细分片(有会话归属、有决策内容;
|
|
3901
|
+
* 默认按日期分片,跨分片读取后按会话过滤)。
|
|
3688
3902
|
*
|
|
3689
3903
|
* `sessionId` 是**必填**的:没有会话参数时返回 400 而不是「返回全部」。
|
|
3690
3904
|
* 返回全部会让人以为「这个会话里居然有别的会话的记录」,而正确的回答是
|
|
@@ -3714,7 +3928,11 @@ export function apply(ctx, config) {
|
|
|
3714
3928
|
scanned: page.scanned,
|
|
3715
3929
|
truncated: page.truncated,
|
|
3716
3930
|
// 文件路径给排查用:用户常问「记录到底写到哪了」。
|
|
3931
|
+
// `file` 是**最新读到的那个分片**(兼容旧界面的单文件展示),
|
|
3932
|
+
// `files` 是本次实际读的全部分片(从新到旧)——分文件模式下
|
|
3933
|
+
// 一个会话的记录可能跨多个文件,界面要能显示「几个文件、在哪」。
|
|
3717
3934
|
file: page.file,
|
|
3935
|
+
files: page.files,
|
|
3718
3936
|
// 单条内容长度上限:界面据此说明内容可能被截断。截断是内容丢失的
|
|
3719
3937
|
// **唯一**来源——没有任何配置键能把整条内容剥成只留元数据。
|
|
3720
3938
|
contentLimit: cfg.decisionsMaxContentChars,
|
|
@@ -3769,6 +3987,103 @@ function stripUndefined(source) {
|
|
|
3769
3987
|
return out;
|
|
3770
3988
|
}
|
|
3771
3989
|
|
|
3990
|
+
/** 会话名解析缓存的默认上限(条)。 */
|
|
3991
|
+
const SESSION_META_CACHE_LIMIT = 512;
|
|
3992
|
+
|
|
3993
|
+
/**
|
|
3994
|
+
* 造一个「会话 → { sessionName, workspaceName }」的解析器。
|
|
3995
|
+
*
|
|
3996
|
+
* 这两个名字是 `session` / `workspace` 分文件模式的**文件名来源**,但它们
|
|
3997
|
+
* 只挂在宿主 Session 上(记录体里只有 id),所以要在 index 侧反查。取名字
|
|
3998
|
+
* 的顺序是「越接近用户所见越优先」:
|
|
3999
|
+
*
|
|
4000
|
+
* 1. 宿主 `sessionTitle` 服务给出的标题(DSH 界面上显示的就是它);
|
|
4001
|
+
* 2. `session.header.title`(老宿主 / 测试里的形态);
|
|
4002
|
+
* 3. 会话 cwd 的目录名(没有标题时,目录名比一串 uuid 有用得多);
|
|
4003
|
+
* 4. `unknown`。
|
|
4004
|
+
*
|
|
4005
|
+
* 工作区名:`workspaceRoots[0]`(`session.header.workspaceRoots` /
|
|
4006
|
+
* `session.workspaceRoots` / `session.header.workspaceRoot`)的基名,
|
|
4007
|
+
* 拿不到再退回 cwd 的基名,最后退回进程 cwd。
|
|
4008
|
+
*
|
|
4009
|
+
* **隐私**:名字会进文件名,所以先过 `maskSecrets()` 再截断到 64 字符——
|
|
4010
|
+
* 会话标题里贴过的一次 key 不该出现在目录列表里。`session` 模式另有 id
|
|
4011
|
+
* 后缀兜底,名字缺失时也不会串会话。
|
|
4012
|
+
*
|
|
4013
|
+
* 按 sessionId 缓存(上限 512):一条会话会写很多条明细,每次都去问宿主
|
|
4014
|
+
* 服务既慢又可能抛错;标题改名是低频事件,重启后自然刷新。
|
|
4015
|
+
*
|
|
4016
|
+
* @param options.getTitleService - 返回宿主 `sessionTitle` 服务的取值函数。
|
|
4017
|
+
* @returns `(session) => { sessionName, workspaceName }`;永不抛错。
|
|
4018
|
+
*/
|
|
4019
|
+
export function createSessionMetaResolver(options = {}) {
|
|
4020
|
+
const cache = new Map();
|
|
4021
|
+
const limit = Number.isFinite(options.cacheLimit) && options.cacheLimit > 0
|
|
4022
|
+
? Math.floor(options.cacheLimit)
|
|
4023
|
+
: SESSION_META_CACHE_LIMIT;
|
|
4024
|
+
const getTitleService =
|
|
4025
|
+
typeof options.getTitleService === "function" ? options.getTitleService : () => undefined;
|
|
4026
|
+
const basenameOfPath = (value) => {
|
|
4027
|
+
try {
|
|
4028
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
4029
|
+
return text.length > 0 ? basename(text) : "";
|
|
4030
|
+
} catch {
|
|
4031
|
+
return "";
|
|
4032
|
+
}
|
|
4033
|
+
};
|
|
4034
|
+
const privacyName = (value, fallback) => {
|
|
4035
|
+
let text = typeof value === "string" ? value.trim() : "";
|
|
4036
|
+
text = maskSecrets(text);
|
|
4037
|
+
if (text.length === 0) text = fallback;
|
|
4038
|
+
return String(text).slice(0, 64);
|
|
4039
|
+
};
|
|
4040
|
+
return (session) => {
|
|
4041
|
+
try {
|
|
4042
|
+
const id = typeof session === "string"
|
|
4043
|
+
? session
|
|
4044
|
+
: typeof session?.id === "string"
|
|
4045
|
+
? session.id
|
|
4046
|
+
: "";
|
|
4047
|
+
const cached = id.length > 0 ? cache.get(id) : undefined;
|
|
4048
|
+
if (cached !== undefined) return cached;
|
|
4049
|
+
let title = "";
|
|
4050
|
+
try {
|
|
4051
|
+
const titled = getTitleService()?.get?.(session);
|
|
4052
|
+
if (typeof titled?.title === "string") title = titled.title;
|
|
4053
|
+
} catch {
|
|
4054
|
+
/* 宿主没有这个服务时照常往下走 */
|
|
4055
|
+
}
|
|
4056
|
+
if (title.length === 0 && typeof session?.header?.title === "string") {
|
|
4057
|
+
title = session.header.title;
|
|
4058
|
+
}
|
|
4059
|
+
const cwd = typeof session?.header?.cwd === "string" ? session.header.cwd : "";
|
|
4060
|
+
if (title.length === 0) title = basenameOfPath(cwd);
|
|
4061
|
+
const roots = Array.isArray(session?.header?.workspaceRoots)
|
|
4062
|
+
? session.header.workspaceRoots
|
|
4063
|
+
: Array.isArray(session?.workspaceRoots)
|
|
4064
|
+
? session.workspaceRoots
|
|
4065
|
+
: typeof session?.header?.workspaceRoot === "string"
|
|
4066
|
+
? [session.header.workspaceRoot]
|
|
4067
|
+
: [];
|
|
4068
|
+
const workspaceRoot = typeof roots[0] === "string" ? roots[0] : "";
|
|
4069
|
+
const workspace = basenameOfPath(workspaceRoot) || basenameOfPath(cwd) || basenameOfPath(process.cwd());
|
|
4070
|
+
const meta = {
|
|
4071
|
+
// 会话名兜底用会话 id:至少文件名可读、可相关性对照。
|
|
4072
|
+
sessionName: privacyName(title, id.length > 0 ? id : "unknown"),
|
|
4073
|
+
workspaceName: privacyName(workspace, "unknown"),
|
|
4074
|
+
};
|
|
4075
|
+
if (id.length > 0) {
|
|
4076
|
+
cache.set(id, meta);
|
|
4077
|
+
if (cache.size > limit) cache.delete(cache.keys().next().value);
|
|
4078
|
+
}
|
|
4079
|
+
return meta;
|
|
4080
|
+
} catch {
|
|
4081
|
+
// 解析失败只能退化成 unknown:分文件名不该因为宿主形状变化而抛错。
|
|
4082
|
+
return { sessionName: "unknown", workspaceName: "unknown" };
|
|
4083
|
+
}
|
|
4084
|
+
};
|
|
4085
|
+
}
|
|
4086
|
+
|
|
3772
4087
|
/**
|
|
3773
4088
|
* 从完整端点里取出它的路径风格(`decisions` / `systemone`)。
|
|
3774
4089
|
*
|
package/lib/outcomes.js
CHANGED
|
@@ -59,7 +59,9 @@ export const OUTCOMES = ["allow", "ask", "reject"];
|
|
|
59
59
|
*/
|
|
60
60
|
export const OUTCOME_LABELS = {
|
|
61
61
|
allow: "放行",
|
|
62
|
-
|
|
62
|
+
// 「转人工」而不是「询问」(T4 统一口径):这一格的意思是「升级到人来确认」,
|
|
63
|
+
// 不是「问了一句」。裁决历史页的裁决格用的是同一个词,两处必须一致。
|
|
64
|
+
ask: "转人工",
|
|
63
65
|
reject: "拦截",
|
|
64
66
|
none: "未裁决",
|
|
65
67
|
};
|
package/lib/points/spec.js
CHANGED
|
@@ -121,6 +121,14 @@ function evidence(parts) {
|
|
|
121
121
|
.join(" ");
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* 技能选择的「没有更匹配」保留选项。
|
|
126
|
+
*
|
|
127
|
+
* 用双下划线包起来是为了与真实技能名分处不同命名空间:技能名里出现
|
|
128
|
+
* `__none__` 的概率远低于 `none`,而旧值 `none` 在 decide 里仍按出口兼容。
|
|
129
|
+
*/
|
|
130
|
+
const SKILL_PICK_NONE = "__none__";
|
|
131
|
+
|
|
124
132
|
/**
|
|
125
133
|
* 各决策点的规格。
|
|
126
134
|
*
|
|
@@ -212,6 +220,19 @@ export const POINT_SPECS = {
|
|
|
212
220
|
const state = {
|
|
213
221
|
turn: input.turn,
|
|
214
222
|
step: input.step,
|
|
223
|
+
/**
|
|
224
|
+
* 这段 incoming 是「谁说的」。
|
|
225
|
+
*
|
|
226
|
+
* 修之前没有这一项:incoming 是把 `payload.messages` 全拼起来的,
|
|
227
|
+
* 而插件自己注入的 `[decision-engine-*]` 提示也是 `role: 'user'`。
|
|
228
|
+
* 于是插件可能对着自己刚写下的「建议开 subagent」再判一次分派,
|
|
229
|
+
* 把一条注入提示滚成自我确认的任务。
|
|
230
|
+
*
|
|
231
|
+
* origin 让「决策历史」能回答「这次凭什么认为有新用户任务」:
|
|
232
|
+
* `user` = 宿主声明 `source.kind === 'user'`;`legacy` = 老宿主没有
|
|
233
|
+
* source 时按 `role: 'user'` 且无插件前缀兜底;`user+legacy` = 两者都有。
|
|
234
|
+
*/
|
|
235
|
+
origin: input.origin ?? "unknown",
|
|
215
236
|
incoming: input.incoming ?? "",
|
|
216
237
|
recent: input.recent ?? "",
|
|
217
238
|
};
|
|
@@ -349,6 +370,23 @@ export const POINT_SPECS = {
|
|
|
349
370
|
return { kind: "summarize", reason: `decision-engine-compact: 仍需逐字原文,交给摘要压缩(${note})` };
|
|
350
371
|
}
|
|
351
372
|
if (parsed.distilled >= cfg.compactDistilledFloor) {
|
|
373
|
+
// 默认**绝不** prune:交给宿主摘要。
|
|
374
|
+
//
|
|
375
|
+
// 修之前:distilled 达线就直接 prune。但 prune 是不可逆的——中间段
|
|
376
|
+
// 一旦被换成标记,后续轮次再也拿不回原文;而 state 里只有「有几条
|
|
377
|
+
// 超长结果」这个数字,模型看不到它们是什么、哪些结论已经真正写进
|
|
378
|
+
// 对话。用一个 0.61 的概率授权不可逆删除,证据不够。
|
|
379
|
+
//
|
|
380
|
+
// 而 compaction-basic 的摘要至少把信息保成一段话。因此默认
|
|
381
|
+
// (compactAllowPrune=false)无论 distilled 多高都返回 summarize,
|
|
382
|
+
// 把「要不要摘要」留给宿主;只有运维显式打开 compactAllowPrune,
|
|
383
|
+
// 才允许这个点按门槛跳过摘要、只裁工具结果。
|
|
384
|
+
if (cfg.compactAllowPrune !== true) {
|
|
385
|
+
return {
|
|
386
|
+
kind: "summarize",
|
|
387
|
+
reason: `decision-engine-compact: 结论看似已落地,但未开启裁剪(compactAllowPrune=false),交给摘要压缩(${note})`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
352
390
|
return { kind: "prune", reason: `decision-engine-compact: 结论已落地,只丢陈旧工具结果并跳过摘要(${note})` };
|
|
353
391
|
}
|
|
354
392
|
return { kind: "summarize", reason: `decision-engine-compact: 结论尚未落地,交给摘要压缩(${note})` };
|
|
@@ -375,6 +413,17 @@ export const POINT_SPECS = {
|
|
|
375
413
|
build(input) {
|
|
376
414
|
const state = {
|
|
377
415
|
turn: input.turn,
|
|
416
|
+
/**
|
|
417
|
+
* 用户最初提的要求(已截断)。
|
|
418
|
+
*
|
|
419
|
+
* 修之前 state 里只有 `last`(助手最后一句话):模型判断「还有没有
|
|
420
|
+
* 没干完的活」时没有对照物——它只能从自己刚说的话里猜用户要什么,
|
|
421
|
+
* 而「未完成」恰恰是相对用户原始目标才成立的事实。
|
|
422
|
+
*
|
|
423
|
+
* 只取**第一条真实用户消息**:插件自己注入的 `[decision-engine-*]`
|
|
424
|
+
* 提示不算(否则目标会漂移到「继续把没做完的部分做完」上)。
|
|
425
|
+
*/
|
|
426
|
+
goal: input.goal ?? "",
|
|
378
427
|
last: input.last ?? "",
|
|
379
428
|
pendingTools: input.pendingTools ?? 0,
|
|
380
429
|
steered: input.steered ?? 0,
|
|
@@ -419,7 +468,9 @@ export const POINT_SPECS = {
|
|
|
419
468
|
const value = readNoul(answers, "value");
|
|
420
469
|
const closed = readNoul(answers, "closed");
|
|
421
470
|
// awaiting_user 缺席**不算**形状不合格:旧端点可能不认识这个问题。
|
|
422
|
-
//
|
|
471
|
+
// 但缺席也**不再**等于「不改变行为」:边界判不出来时 decide 禁止续轮
|
|
472
|
+
// (见 decide 的「边界第二道」)。理由与 DEC-045 同一条——在「是否在
|
|
473
|
+
// 等用户」未知时续轮,就是在边界未知的情况下替用户把选择顶掉。
|
|
423
474
|
const awaitingUser = readNoul(answers, "awaiting_user");
|
|
424
475
|
if (unfinished === undefined || value === undefined || closed === undefined) return undefined;
|
|
425
476
|
return { unfinished, value, closed, awaitingUser };
|
|
@@ -438,7 +489,7 @@ export const POINT_SPECS = {
|
|
|
438
489
|
*
|
|
439
490
|
* **但 unfin 之前还有一道边界(DEC-045)**:模型在等用户时,unfin 会很高
|
|
440
491
|
* (「还没决定用哪个方案」既没干完、也不 closed)。这时续轮就是越权,
|
|
441
|
-
* 所以边界判定排在最前面,且它的结果**只可能是让位**(`defer`)——
|
|
492
|
+
* 所以边界判定排在最前面,且它的结果**只可能是让位**(`defer` / `stop`)——
|
|
442
493
|
* 本插件不会因为「在等用户」而替用户做任何决定,它只是不插手。
|
|
443
494
|
*/
|
|
444
495
|
decide(parsed, cfg) {
|
|
@@ -464,6 +515,19 @@ export const POINT_SPECS = {
|
|
|
464
515
|
reason: `decision-engine-turnstop: 这一轮在等用户决定,插件让位不介入(${note} await=${awaiting.toFixed(2)})`,
|
|
465
516
|
};
|
|
466
517
|
}
|
|
518
|
+
// 边界第二道:awaiting_user 缺席 = 边界未知 → 禁止续轮。
|
|
519
|
+
//
|
|
520
|
+
// 修之前:这里直接往下走 unfinished 判据。于是旧端点(不认识这一问)
|
|
521
|
+
// 在「你选 A 还是 B?」这类轮次上拿到 unfin 0.9 / value 0.8 → continue,
|
|
522
|
+
// 用一个小模型的判断把用户的选择权顶掉——正是 DEC-045 要防的事,只是
|
|
523
|
+
// 从「判错」换成了「判不出来还硬判」。现在缺这一问就保守收束:代价
|
|
524
|
+
// 只是少续一轮,与 defer 的取舍完全一致。
|
|
525
|
+
if (awaiting === undefined) {
|
|
526
|
+
return {
|
|
527
|
+
kind: "stop",
|
|
528
|
+
reason: `decision-engine-turnstop: 无法判断这一轮是否在等用户(awaiting_user 缺席),保守收束不续轮(${note})`,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
467
531
|
// 排在前面:确实还有活,且续一轮有增量价值 → 续。
|
|
468
532
|
if (parsed.unfinished >= cfg.turnStopUnfinishedFloor && parsed.value >= cfg.turnStopValueFloor) {
|
|
469
533
|
return { kind: "continue", reason: `decision-engine-turnstop: 仍有未完成的活,续一轮(${note})` };
|
|
@@ -540,15 +604,23 @@ export const POINT_SPECS = {
|
|
|
540
604
|
/**
|
|
541
605
|
* 判断该装哪个技能。装错再换等于付两遍技能正文的 token,因此把候选清单
|
|
542
606
|
* 交给决策模型挑一个,比让主模型自己试错便宜。
|
|
607
|
+
*
|
|
608
|
+
* choice 里「没有更匹配的技能」用保留键 `__none__`:修之前它直接叫 `none`,
|
|
609
|
+
* 一旦候选里真有一个名叫 none 的技能,criteria 里两行同名、后者覆盖前者——
|
|
610
|
+
* 模型选 `none` 时无法区分「没有更匹配」与「选了那个技能」,decide 也把
|
|
611
|
+
* 任何 `none` 当成出口,那个技能永远拿不到 redirect(正确调用被判成「没选」)。
|
|
612
|
+
* 控制流选项必须与业务数据(技能名)分处不同命名空间。
|
|
543
613
|
*/
|
|
544
614
|
skillPick: {
|
|
545
615
|
build(input) {
|
|
546
616
|
const candidates = input.candidates ?? [];
|
|
547
617
|
const criteria = {};
|
|
548
618
|
for (const item of candidates) criteria[item.name] = item.description || item.name;
|
|
549
|
-
criteria
|
|
619
|
+
criteria[SKILL_PICK_NONE] = "No listed skill applies to the current request";
|
|
550
620
|
const state = {
|
|
551
621
|
requested: input.requested ?? "",
|
|
622
|
+
// 候选名字原样进 state:它们和 criteria 的键必须一致,否则模型
|
|
623
|
+
// 选出来的名字与后续的 suggest 校验对不上。
|
|
552
624
|
candidates: candidates.map((item) => item.name),
|
|
553
625
|
recent: input.recent ?? "",
|
|
554
626
|
};
|
|
@@ -569,9 +641,18 @@ export const POINT_SPECS = {
|
|
|
569
641
|
if (best === undefined) return undefined;
|
|
570
642
|
return { best: best.choice, confidence: best.confidence };
|
|
571
643
|
},
|
|
572
|
-
decide(parsed, cfg) {
|
|
644
|
+
decide(parsed, cfg, input) {
|
|
573
645
|
const note = evidence([["conf", parsed.confidence]]);
|
|
574
|
-
|
|
646
|
+
// 出口判定:
|
|
647
|
+
// - 新保留键 `__none__` 一律是出口;
|
|
648
|
+
// - 旧值 `none`(老端点 / 旧缓存)只在候选里没有同名技能时才按出口
|
|
649
|
+
// 解释——否则「名叫 none 的那个技能」就永远无法被建议。
|
|
650
|
+
const names = new Set(
|
|
651
|
+
(Array.isArray(input?.candidates) ? input.candidates : []).map((item) =>
|
|
652
|
+
typeof item === "string" ? item : item?.name,
|
|
653
|
+
),
|
|
654
|
+
);
|
|
655
|
+
if (parsed.best === SKILL_PICK_NONE || (parsed.best === "none" && !names.has("none"))) {
|
|
575
656
|
return { kind: "allow", reason: `decision-engine-skillpick: 没有更匹配的技能,放行(${note})` };
|
|
576
657
|
}
|
|
577
658
|
if (parsed.confidence < cfg.skillPickConfidenceFloor) {
|