dsh-rule-engine 0.5.10 → 0.5.12
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 +76 -9
- package/lib/core/authorization.js +517 -468
- package/lib/core/contract.js +41 -0
- package/lib/core/guard-core.js +754 -654
- package/lib/core/hotwords.js +86 -0
- package/lib/core/intent.js +51 -14
- package/lib/core/judge.js +72 -0
- package/lib/core/label-fingerprint.js +145 -0
- package/lib/core/lexicon.js +72 -0
- package/lib/core/llm-intent.js +10 -2
- package/lib/core/llm-understander.js +15 -0
- package/lib/core/patterns.js +126 -23
- package/lib/core/state.js +306 -301
- package/lib/core/text-detect.js +67 -11
- package/lib/core/tool-catalog.js +3 -0
- package/lib/core/understander.js +1 -1
- package/lib/core/version-guard.js +6 -3
- package/lib/index.js +1752 -1506
- package/package.json +1 -1
- package/scripts/probe-home.mjs +3 -0
- package/scripts/probe-intent.mjs +14 -0
- package/scripts/verify-all.mjs +44 -2
- package/scripts/verify-guard-live.mjs +30 -0
- package/scripts/verify-v474.mjs +14 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// hotwords.js - 会话热词学习(B1,2026-08-29 方案甲由用户拍板)。
|
|
2
|
+
// 背景:词表是主裁决器(快/免费/可复现),但人工枚举永远补不完(v4.74/82 连补 4 轮仍漏——
|
|
3
|
+
// "落/认可/清理/移除/清空"逐个补);0.5.12 A2 已有 LLM 兜底:词表未命中但 LLM 判为执行分点 → 放行。
|
|
4
|
+
// 本模块把"LLM 已确认过"的动作词沉淀为热词:下次同词直接词表命中,不再依赖 LLM(省成本 + 更快 + 可复现)。
|
|
5
|
+
//
|
|
6
|
+
// 原则:
|
|
7
|
+
// 1. 热词只服务"意图判定"(hasAction → execute 分点),绝不 bypass deny/授权——其他拦截链不受影响;
|
|
8
|
+
// 2. 学习只在 LLM 兜底明确判 hasExecute=true 且词表未命中时发生(非对称:词表不放行时不学);
|
|
9
|
+
// 3. 持久化 ~/.dsh/rule-engine-hotwords.json(原子写 + 上限 + 容错;写失败静默回退,不影响主流程);
|
|
10
|
+
// 4. 审计留痕 kind=hotword-learn(来源会话/词);
|
|
11
|
+
// 5. 手动补词(lexicon.js)仍保留且优先:热词是自动通道,不替代编译词表。
|
|
12
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { dshHome } from "./paths.js";
|
|
15
|
+
import { audit } from "./audit.js";
|
|
16
|
+
|
|
17
|
+
const MAX_WORDS = 500;
|
|
18
|
+
|
|
19
|
+
export function hotwordFilePath() {
|
|
20
|
+
return join(dshHome(), "rule-engine-hotwords.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function emptyStore() {
|
|
24
|
+
return { versions: 1, words: [], updatedAt: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 读取热词文件(容错:缺失/损坏/非数组 → 空表) */
|
|
28
|
+
export function loadHotwords() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(hotwordFilePath(), "utf8");
|
|
31
|
+
const data = JSON.parse(raw);
|
|
32
|
+
const words = Array.isArray(data?.words) ? data.words : [];
|
|
33
|
+
// 元数据迁移容错:老格式 words 可能是字符串数组
|
|
34
|
+
return {
|
|
35
|
+
versions: Number(data?.versions) || 1,
|
|
36
|
+
words: words.filter((w) => typeof w === "string" && w.length >= 2 && w.length <= 12),
|
|
37
|
+
updatedAt: data?.updatedAt || null
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
return emptyStore();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 原子写(tmp + rename);失败静默(学习不影响主流程) */
|
|
45
|
+
export function saveHotwords(store) {
|
|
46
|
+
try {
|
|
47
|
+
const p = hotwordFilePath();
|
|
48
|
+
mkdirSync(dshHome(), { recursive: true });
|
|
49
|
+
const tmp = p + ".tmp";
|
|
50
|
+
writeFileSync(tmp, JSON.stringify(store, null, 2), "utf8");
|
|
51
|
+
renameSync(tmp, p);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 文本是否命中热词(子串匹配;热词为空直接 false) */
|
|
59
|
+
export function hasHotwordAction(text) {
|
|
60
|
+
const t = String(text || "").toLowerCase();
|
|
61
|
+
const { words } = loadHotwords();
|
|
62
|
+
for (const w of words) {
|
|
63
|
+
if (w && t.includes(w.toLowerCase())) return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 学习一个动作词(LLM 兜底确认 execute 且词表未命中时调用)。
|
|
70
|
+
* 返回 true=已写入;false=校验失败/写失败。
|
|
71
|
+
* 校验:2-12 字符、去空白、不重复、上限 500(旧→新保留)。
|
|
72
|
+
*/
|
|
73
|
+
export function learnHotword(word) {
|
|
74
|
+
const clean = String(word || "").replace(/[\s"'“”‘’?,。;:!!??、]/g, "").trim();
|
|
75
|
+
if (clean.length < 2 || clean.length > 12) return false;
|
|
76
|
+
const store = loadHotwords();
|
|
77
|
+
if (store.words.includes(clean)) return true;
|
|
78
|
+
store.words.push(clean);
|
|
79
|
+
if (store.words.length > MAX_WORDS) store.words = store.words.slice(-MAX_WORDS);
|
|
80
|
+
store.updatedAt = new Date().toISOString();
|
|
81
|
+
const ok = saveHotwords(store);
|
|
82
|
+
if (ok) {
|
|
83
|
+
audit({ kind: "hotword-learn", rule: "22", name: "热词学习", reason: `LLM 确认动作词入热词表:${clean}`, session: "" });
|
|
84
|
+
}
|
|
85
|
+
return ok;
|
|
86
|
+
}
|
package/lib/core/intent.js
CHANGED
|
@@ -18,25 +18,49 @@
|
|
|
18
18
|
// 消灭“两套疑问词判定不一致”(如 authorization 的“影响吗”与 intent 的“怎么”)。
|
|
19
19
|
// - 新增 STATUS_SIGNAL_RE / isStatusSignal():状态信号分类(宽松版,危险词排除,
|
|
20
20
|
// 12A/13A 兜底),与“无用户消息回合”同等待遇。
|
|
21
|
+
// 0.5.11(用户拍板):词表唯一源 = lib/core/lexicon.js——下面 5 张表全部从那里 import,
|
|
22
|
+
// 本文件不再自持定义(原定义删除);对外 export 名保持不变(QUESTION_RE/STATUS_SIGNAL_RE
|
|
23
|
+
// 仍对外可见,/guard 与测试引用不受影响)。
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
QUESTION_WORDS_RE,
|
|
27
|
+
ACTION_WORDS_RE,
|
|
28
|
+
STRONG_EXEC_RE as STRONG_EXEC_LEX,
|
|
29
|
+
STATUS_SIGNAL_WORDS_RE,
|
|
30
|
+
DANGEROUS_ACTION_WORDS_RE
|
|
31
|
+
} from "./lexicon.js";
|
|
32
|
+
import { hasHotwordAction } from "./hotwords.js";
|
|
21
33
|
|
|
22
34
|
export const TAG_RE = /【(执行|方案|问询|信息)】/;
|
|
23
35
|
|
|
24
|
-
//
|
|
25
|
-
export const QUESTION_RE =
|
|
36
|
+
// 统一疑问词表(唯一源:lexicon.js;此处再导出保持外部引用兼容)
|
|
37
|
+
export const QUESTION_RE = QUESTION_WORDS_RE;
|
|
26
38
|
|
|
27
39
|
const DEFER_RE = /待确认|确认后再|等我确认|先方案|先别|不要执行|之后再说|确认后|再确认|先确认|不要直接/;
|
|
28
|
-
const ACTION_RE =
|
|
40
|
+
const ACTION_RE = ACTION_WORDS_RE;
|
|
29
41
|
const PLAN_RE = /方案|建议|评估|分析|设计|规划|计划|给出|提供/;
|
|
30
|
-
const STRONG_EXEC_RE =
|
|
42
|
+
const STRONG_EXEC_RE = STRONG_EXEC_LEX;
|
|
31
43
|
const NUMBERED_ITEM_RE = /^\s*(?:\(?(\d+)[\)、..)]\s*)/;
|
|
44
|
+
// A1(2026-08-28 阶段一):具体动作词(区别于"执行/开始/进行"等宽泛词)。
|
|
45
|
+
// 用途:hasAction+hasPlan 时区分"给出执行方案"(方案请求)与"将建议书转化到业务通告"(执行指令)。
|
|
46
|
+
const SPECIFIC_ACTION_RE =
|
|
47
|
+
/写入|写(?:下|好|完|成|作|文件)?|保存|另存为|删除|修改|改(?:为|成|动|一下|进|善)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)?|创建|安装|发布|下载|提交|运行|修复|替换|重建|启动|重启|停止|卸载|改进|优化|增强|完善|升级|迁移|整理|调整|实现|实施|添加|增加|补充|重写|改造|调试|排查|处理|解决|更新|部署|核实|梳理|诊断|对齐|跟进|落实|设计并实现|调查|产出|编写|撰写|生成|审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|调研|研究|转化|转换|读取|读(?:完|出|一下|一遍|出来)?|展示|打开|提取|还原|导入|导出|采纳|选择|选定|落盘|落地|清理|移除|清空|合入|合并|并入|融合|整合|起草|开展|搭建|重构|兼容|投产|校验|核验|过一遍|跑一遍|复验/i;
|
|
48
|
+
|
|
49
|
+
// A3(2026-08-28,阶段一):@文件引用信号——用户消息中 @ 引用路径/文件(DSH 官方
|
|
50
|
+
// file-reference 语法,@path 或 @"path with spaces"/@file.docx),且含处理动词 → 视为执行分点。
|
|
51
|
+
// 边界:@ 前必须是非词字符(开头/空白/标点),避免邮箱 a@b.com 误伤。
|
|
52
|
+
const AT_REF_RE = /(?:^|[\s((【《"',。;:!?])\s*@(?:["']?(?:[A-Za-z]:[\\/]|[^"'\\\s,。;:!?()【】《》、]+\.(?:docx?|md|txt|pdf|json|ya?ml|xlsx?|csv|pptx?|html?|zip|mjs|js|ts|py|exe|ps1)))/i;
|
|
53
|
+
const AT_ACTION_RE = /看|读|改|写|转|查|处理|提取|打开|展示|审阅|检查|分析|整理|校对|核对|还原|更新|删除|移动|执行|处理/i;
|
|
32
54
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
/**
|
|
56
|
+
* 状态信号:对先前指令的就绪/完成确认(“我已重启 / 已输入 / 重启完成 / 好了 / done”)。
|
|
57
|
+
* 语义 = 与“无用户消息回合”同等待遇:不触发规则 22 的“无执行分点”判定(规则 22⑩)。
|
|
58
|
+
* 唯一源:lexicon.js STATUS_SIGNAL_WORDS_RE;此处再导出保持外部引用兼容。
|
|
59
|
+
*/
|
|
60
|
+
export const STATUS_SIGNAL_RE = STATUS_SIGNAL_WORDS_RE;
|
|
37
61
|
|
|
38
62
|
// 危险动作词:命中则不当状态信号(防“我已删除了某文件”被当成就绪确认)
|
|
39
|
-
const DANGEROUS_ACTION_RE =
|
|
63
|
+
const DANGEROUS_ACTION_RE = DANGEROUS_ACTION_WORDS_RE;
|
|
40
64
|
|
|
41
65
|
/** DSH 在会话上下文里注入的系统文本(工作区指令/技能目录等),不是用户的真实消息 */
|
|
42
66
|
export function isInjectedContextMessage(text) {
|
|
@@ -114,10 +138,12 @@ function splitClauses(text) {
|
|
|
114
138
|
const raw = String(text || "").trim();
|
|
115
139
|
if (!raw) return [];
|
|
116
140
|
|
|
117
|
-
// 同行内多个编号("1、A 2、B"
|
|
141
|
+
// 同行内多个编号("1、A 2、B"):在编号前插入换行,统一按行切分。
|
|
118
142
|
// 修复 2026-08-24 实测缺陷:同行编号列表被并入单子句,"方案"等词导致整条误判 plan。
|
|
119
|
-
//
|
|
120
|
-
|
|
143
|
+
// 修复 2026-08-28(用户"1、问 2、问 3、跑"被整条吞):编号前为**空白或标点**(?。;、等)也切——
|
|
144
|
+
// 用户在中文标点后直接发编号(无空格),"?2、"应切为列表项"2、";保守保留"执行第2、3条"不切
|
|
145
|
+
//("第"是汉字非标点,编号前非空白非标点 → 整体词不切)。
|
|
146
|
+
const normalized = raw.replace(/(?:[\s。;,、??!!])(\d+[\)、..)])/g, "\n$1");
|
|
121
147
|
const lines = normalized.split(/\r?\n/);
|
|
122
148
|
const hasNumbered = lines.some((line) => NUMBERED_ITEM_RE.test(line));
|
|
123
149
|
const hasTagLines = lines.filter((line) => TAG_RE.test(line)).length >= 2;
|
|
@@ -178,9 +204,12 @@ function classifyClause(clause) {
|
|
|
178
204
|
const tagType = normalizeTag(tag);
|
|
179
205
|
const hasQuestion = QUESTION_RE.test(text);
|
|
180
206
|
const hasDefer = DEFER_RE.test(text);
|
|
181
|
-
|
|
207
|
+
// B1(2026-08-29):词表命中 ∪ 热词命中(热词=LLM 兜底已确认过的动作词,见 hotwords.js)
|
|
208
|
+
const hasAction = ACTION_RE.test(text) || hasHotwordAction(text);
|
|
182
209
|
const hasPlan = PLAN_RE.test(text);
|
|
183
210
|
const hasStrongExec = STRONG_EXEC_RE.test(text);
|
|
211
|
+
// A3(阶段一):@文件引用信号 + 处理动词 → 执行分点(用户用 @ 引用文件即"处理它")
|
|
212
|
+
const hasAtRef = AT_REF_RE.test(text) && AT_ACTION_RE.test(text);
|
|
184
213
|
|
|
185
214
|
let type = "info";
|
|
186
215
|
let ambiguous = false;
|
|
@@ -198,9 +227,17 @@ function classifyClause(clause) {
|
|
|
198
227
|
type = "status";
|
|
199
228
|
} else if (hasDefer && hasAction) {
|
|
200
229
|
type = "plan";
|
|
201
|
-
} else if (hasPlan && !
|
|
230
|
+
} else if (hasAction && hasPlan && !SPECIFIC_ACTION_RE.test(text)) {
|
|
231
|
+
// 只有宽泛动作词(执行/开始/进行…)+ 方案词 → 方案请求("给出执行方案"≠执行指令)
|
|
202
232
|
type = "plan";
|
|
203
233
|
} else if (hasAction) {
|
|
234
|
+
// A1(2026-08-28 阶段一):动作词优先于方案词——"将建议书内容转化到业务通告中"
|
|
235
|
+
// 含"建议"(PLAN_RE)却被判 plan 导致无执行分点(真实事故根因);有明确动作词即执行分点。
|
|
236
|
+
type = "execute";
|
|
237
|
+
} else if (hasPlan && !hasStrongExec) {
|
|
238
|
+
type = "plan";
|
|
239
|
+
} else if (hasAtRef) {
|
|
240
|
+
// 词表未命中但含 @文件引用 + 处理动词:判执行分点(词表盲区治本方向之一)
|
|
204
241
|
type = "execute";
|
|
205
242
|
} else if (hasPlan) {
|
|
206
243
|
type = "plan";
|
package/lib/core/judge.js
CHANGED
|
@@ -83,3 +83,75 @@ export async function judgeViolation(ctx, state, sessionId, violation, text) {
|
|
|
83
83
|
return { action: "unavailable", note: `裁决调用失败:${error instanceof Error ? error.message : String(error)}` };
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 批量裁决(0.5.11,用户定稿"频率不高"假设打穿修复):一次回复的全部嫌疑 → **一次** LLM 调用、
|
|
89
|
+
* **一次**预算消耗、对每条嫌疑给独立判定。
|
|
90
|
+
* 背景:此前 deliverSuspects 对每条嫌疑单独 judgeViolation(每条一次 LLM + 一条预算)——长会话
|
|
91
|
+
* 大量嫌疑一天 200+ 次调用,50/日预算早上就满(judge-unavailable 89 条实证);单次调用聚合后,
|
|
92
|
+
* "一次回复一次裁决"才是设计时"裁决频率不高"的真实粒度。
|
|
93
|
+
* @returns {Promise<Array<{violation, verdict}>>} 每条嫌疑对应一个 verdict(deliver/suppress/unavailable)
|
|
94
|
+
*/
|
|
95
|
+
export async function judgeViolationsBatch(ctx, state, sessionId, violations, text) {
|
|
96
|
+
if (!Array.isArray(violations) || violations.length === 0) return [];
|
|
97
|
+
const bKey = judgeBudgetKey(sessionId);
|
|
98
|
+
const budget = state.judgeBudget?.get(bKey) || { count: 0 };
|
|
99
|
+
if (budget.count >= JUDGE_DAILY_LIMIT) {
|
|
100
|
+
return violations.map((v) => ({ violation: v, verdict: { action: "unavailable", note: `裁决日预算已满(${JUDGE_DAILY_LIMIT}/日/会话)` } }));
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const route = await resolveRoute(ctx);
|
|
104
|
+
if (!route) return violations.map((v) => ({ violation: v, verdict: { action: "unavailable", note: "无法解析模型路由(追随会话模型解析失败)" } }));
|
|
105
|
+
// 一次性列出全部嫌疑(编号+规则+提示),一次判定
|
|
106
|
+
const list = violations.map((v, i) => {
|
|
107
|
+
const rawCfg = Array.isArray(state.configs) ? state.configs.find((c) => String(c.ruleId) === String(v.ruleId)) : undefined;
|
|
108
|
+
return [
|
|
109
|
+
`[${i + 1}] 规则 ${v.ruleId}(${v.title})`,
|
|
110
|
+
`检测提示:${String(v.reason || "")}`,
|
|
111
|
+
`规则正文:${rawCfg?.body || "(未提供)"}`
|
|
112
|
+
].join("\n");
|
|
113
|
+
}).join("\n\n");
|
|
114
|
+
const prompt = [
|
|
115
|
+
"你是 DSH 规则裁决器。判断下面这段【执行者回复】是否符合下列【每条规则】。只输出 JSON,不要其他内容。",
|
|
116
|
+
'JSON 格式:{"results": [{"index": 1, "violated": true|false, "note": "一句话理由"}, ...]}',
|
|
117
|
+
"判定要点:",
|
|
118
|
+
"1. 回复若是在表达合规声明(如“我不再/将避免/规则 X 已自证/无重复推销”)或引述规则文本——violated=false;",
|
|
119
|
+
"2. 只有确实发生规则禁止的行为才 violated=true;",
|
|
120
|
+
"3. 拿不准时给出倾向性判断(false 偏不打扰);",
|
|
121
|
+
"4. 每条嫌疑都给独立判定,index 对应下面列号。",
|
|
122
|
+
"嫌疑列表:",
|
|
123
|
+
list,
|
|
124
|
+
"执行者回复:",
|
|
125
|
+
text,
|
|
126
|
+
"只输出 JSON。"
|
|
127
|
+
].join("\n");
|
|
128
|
+
const messages = [{ role: "user", content: [{ type: "text", text: prompt }] }];
|
|
129
|
+
let out = "";
|
|
130
|
+
for await (const chunk of ctx.llm.stream({
|
|
131
|
+
provider: route.provider,
|
|
132
|
+
model: route.model,
|
|
133
|
+
messages,
|
|
134
|
+
maxTokens: 800,
|
|
135
|
+
temperature: 0
|
|
136
|
+
})) {
|
|
137
|
+
if (chunk && chunk.type === "text-delta" && typeof chunk.text === "string") out += chunk.text;
|
|
138
|
+
}
|
|
139
|
+
budget.count += 1;
|
|
140
|
+
state.judgeBudget.set(bKey, budget); // 一次调用 = 一条预算(聚合粒度,而非逐条)
|
|
141
|
+
const parsed = parseJsonOutput(out);
|
|
142
|
+
const results = Array.isArray(parsed?.results) ? parsed.results : [];
|
|
143
|
+
return violations.map((v, i) => {
|
|
144
|
+
const r = results.find((x) => Number(x?.index) === i + 1);
|
|
145
|
+
return {
|
|
146
|
+
violation: v,
|
|
147
|
+
verdict: {
|
|
148
|
+
action: r?.violated === true ? "deliver" : "suppress",
|
|
149
|
+
note: typeof r?.note === "string" ? r.note.slice(0, 120) : "",
|
|
150
|
+
model: route.model
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return violations.map((v) => ({ violation: v, verdict: { action: "unavailable", note: `批量裁决调用失败:${error instanceof Error ? error.message : String(error)}` } }));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// label-fingerprint.js - 打标命令指纹记忆(F2,0.5.12)
|
|
2
|
+
// 目标:`/guard label ERR-xxx incorrect` 后,同指纹命令直接放行并记 label-hits
|
|
3
|
+
// 审计;持久化到 ~/.dsh/rule-engine-labels.json。
|
|
4
|
+
// 安全规格(重证报告 §3.2 补充):
|
|
5
|
+
// - 开关/写参数(-Recurse/-Force 等)**必须保留**在指纹中——只归一化无害部分;
|
|
6
|
+
// - 破坏类命令(rm/move/Remove-Item/irm/iwr 等)**永不进指纹放行**;
|
|
7
|
+
// - 持久化带 TTL(默认 7 天)+ 可撤销(/guard label clear <fingerprint>)。
|
|
8
|
+
// 纯函数,可独立测试;不依赖 Cordis。
|
|
9
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { normalizePath } from "./authorization.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 命令指纹归一化规则。
|
|
14
|
+
* 保留:命令名(首个 token 的小写规范形式)、开关类参数(-x/--xx)、位置参数中的"参数名前缀"(= 左侧)。
|
|
15
|
+
* 归一化(无害化):路径前缀(盘符/工作区路径)、时间戳、随机串、引号、大小写、空格压缩。
|
|
16
|
+
*/
|
|
17
|
+
export function fingerprintCommand(command) {
|
|
18
|
+
const s = String(command || "").trim();
|
|
19
|
+
if (!s) return "";
|
|
20
|
+
// 只取主命令链的第一段(; | & 换行之后的副作用不在本命令指纹范围内——由守卫整体判定)
|
|
21
|
+
const head = s.split(/[;|&\r\n]/)[0].trim();
|
|
22
|
+
// 去除路径前缀(盘符路径/引号包裹路径 → 只保留最后一段 basename 与扩展名;目录变化不影响指纹)
|
|
23
|
+
const depathed = head
|
|
24
|
+
.replace(/(?:\\?['"]?[A-Za-z]:[\\/][^'"\s]*|['"][^'"]+|\\\/?) /g, " ")
|
|
25
|
+
.replace(/\b(path|pathprefix|workdir)\s*[=:]\s*(?:['"]?[^'"\s]+['"]?)/gi, "")
|
|
26
|
+
.replace(/[\\/](?:node_modules|projects|workspace)[\\/][^'"\s]*/gi, " ")
|
|
27
|
+
.replace(/[\\/]+/g, "/");
|
|
28
|
+
// 去除时间戳/随机串(8-16 位十六进制/数字串)
|
|
29
|
+
const denormalized = depathed.replace(/\b[0-9a-f]{8,16}\b/gi, "<TS>").replace(/\b\d{6,}\b/g, "<N>");
|
|
30
|
+
// 规范形式:小写 + 空白压缩
|
|
31
|
+
const canonical = denormalized
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/\s+/g, " ")
|
|
34
|
+
.replace(/\s*([=&])\s*/g, "$1")
|
|
35
|
+
.trim();
|
|
36
|
+
return canonical || "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 是否破坏类命令(永不进指纹放行)——写/删除/移动/网络下载类 */
|
|
40
|
+
export function isDangerousCommand(command) {
|
|
41
|
+
const s = String(command || "").toLowerCase();
|
|
42
|
+
return /(?:remove-item|rm(?:dir)?\b|del(?:ete)?\b|move-item|move\b|copy-item|cp\b|mv\b|irm|iwr|\bcurl\b|\bwget\b|invoke-exp(?:ression)?|iex\b|new-item|set-content|add-content|out-file|rename-item|clear-content|git\s+(?:push|commit|reset|checkout|clean))/i.test(s);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 从命令文本提取可保留的"写/开关参数"(保留在指纹里,防止 -Recurse 被归一化放行) */
|
|
46
|
+
export function extractSignificantParams(command) {
|
|
47
|
+
const s = String(command || "");
|
|
48
|
+
// 开关:-x / --xx / -Force / -Recurse 等(含 = 值)
|
|
49
|
+
const flags = [];
|
|
50
|
+
const flagRe = /(?:^|\s)(--?[A-Za-z][\w-]*)(?:(?:=([^\s]+))|(\s+[^\s-][^\s]*\s*))?/g;
|
|
51
|
+
let m;
|
|
52
|
+
while ((m = flagRe.exec(s))) {
|
|
53
|
+
if (m[1]) flags.push(m[1].toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
return flags;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 计算放行指纹 = 命令骨架 + 保留参数(开关原样 + 位置参数的"名称=值"对)。
|
|
60
|
+
* @returns {string} 指纹字符串(稳定、可比较);空串表示不可指纹化
|
|
61
|
+
*/
|
|
62
|
+
export function labelFingerprint(command) {
|
|
63
|
+
const cmd = String(command || "").trim();
|
|
64
|
+
if (!cmd) return "";
|
|
65
|
+
if (isDangerousCommand(cmd)) return ""; // 危险命令永不进指纹(返回空 = 不可放行)
|
|
66
|
+
const canonical = fingerprintCommand(cmd);
|
|
67
|
+
if (!canonical) return "";
|
|
68
|
+
const params = extractSignificantParams(cmd).sort();
|
|
69
|
+
return `${canonical}|flags=${params.join(",")}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 打标台账记录结构 */
|
|
73
|
+
export function labelEntry(fingerprint, label, sid, now = Date.now(), ttlMs = 7 * 24 * 60 * 60 * 1000) {
|
|
74
|
+
return { fingerprint, label, session: sid || "global", at: now, expiresAt: now + ttlMs };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 从原始 JSON 解析台账(容错:损坏返回空数组 + 旧格式兼容) */
|
|
78
|
+
export function parseLabels(raw) {
|
|
79
|
+
try {
|
|
80
|
+
const arr = JSON.parse(raw || "[]");
|
|
81
|
+
if (!Array.isArray(arr)) return [];
|
|
82
|
+
return arr
|
|
83
|
+
.map((it) => {
|
|
84
|
+
if (typeof it === "string") return { fingerprint: it, label: "incorrect", session: null, at: null, expiresAt: null };
|
|
85
|
+
if (it && typeof it.fingerprint === "string") {
|
|
86
|
+
return {
|
|
87
|
+
fingerprint: it.fingerprint,
|
|
88
|
+
label: typeof it.label === "string" ? it.label : "incorrect",
|
|
89
|
+
session: typeof it.session === "string" ? it.session : null,
|
|
90
|
+
at: typeof it.at === "number" ? it.at : null,
|
|
91
|
+
expiresAt: typeof it.expiresAt === "number" ? it.expiresAt : null
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
})
|
|
96
|
+
.filter(Boolean);
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 持枪裁判:一份台账中某指纹是否命中"incorrect 放行"(且未过期) */
|
|
103
|
+
export function labelsAllowFingerprint(rows, fingerprint, now = Date.now()) {
|
|
104
|
+
if (!fingerprint) return false;
|
|
105
|
+
return rows.some((r) => r && r.fingerprint === fingerprint && r.label === "incorrect" && (!r.expiresAt || r.expiresAt > now));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 写台账(保留未过期 + 去重:同指纹同标签更新 at/expiresAt) */
|
|
109
|
+
export function serializeLabels(rows, now = Date.now()) {
|
|
110
|
+
const keep = rows.filter((r) => r && r.fingerprint && (!r.expiresAt || r.expiresAt > now));
|
|
111
|
+
// 同指纹同 label 只保留最新(去重)
|
|
112
|
+
const map = new Map();
|
|
113
|
+
for (const r of keep) map.set(`${r.fingerprint}|${r.label}`, r);
|
|
114
|
+
return JSON.stringify([...map.values()], null, 2);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 持久化路径(与 toolsWhitelistFilePath 同模式) */
|
|
118
|
+
export function labelsFilePath(dshHome) {
|
|
119
|
+
return `${dshHome}/rule-engine-labels.json`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 从磁盘加载台账(纯函数输入输出:读取失败 → 空数组) */
|
|
123
|
+
export function loadLabelsFromDisk(filePath) {
|
|
124
|
+
try {
|
|
125
|
+
return parseLabels(readFileSync(filePath, "utf8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 写磁盘(失败静默——审计链路不因台账读写失败而阻断) */
|
|
132
|
+
export function saveLabelsToDisk(filePath, rows) {
|
|
133
|
+
try {
|
|
134
|
+
writeFileSync(filePath, serializeLabels(rows), "utf8");
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 去重复存:同指纹同 label 记录只留最新一条(新增/更新均用) */
|
|
142
|
+
export function upsertLabel(rows, entry) {
|
|
143
|
+
const others = rows.filter((r) => !(r.fingerprint === entry.fingerprint && r.label === entry.label));
|
|
144
|
+
return [...others, entry];
|
|
145
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// lexicon.js - 规则 22 语义判定的唯一词表源(0.5.11,用户拍板"唯一词表源由引擎维护")。
|
|
2
|
+
// 背景(2026-08-28 全量审查):正文曾手抄词表(L144/148),引擎内部 intent.js 与 authorization.js
|
|
3
|
+
// 又各写一份(许可词在 authorization 出现 3 次:AUTH_WORDS_RE/APPROVAL_RE/APPROVAL_EXEC_RE)——
|
|
4
|
+
// 同语义多处定义 = 漂移根源。本文件 = 五表唯一源:
|
|
5
|
+
// ① 执行许可词表(许可词 + 执行语,两者缺一才不构成许可)
|
|
6
|
+
// ② 执行动作词表(意图/授权判定共用)
|
|
7
|
+
// ③ 疑问词表(问询分点判定)
|
|
8
|
+
// ④ 状态信号词表("我已重启/好了/done"等,状态信号不触发无执行分点)
|
|
9
|
+
// ⑤ 豁免工具清单(规则 22 ③ "只读/展示/ask/todo_write 等豁免"的明细)
|
|
10
|
+
// 调用方(intent.js/authorization.js)一律从这里 import,禁止再定义第二份。
|
|
11
|
+
// 设计原则:词表可以在,但只能有一份;语义变更改这里,正文措辞改 AGENTS.md,二者成对。
|
|
12
|
+
|
|
13
|
+
// ── ① 执行许可词表 ──
|
|
14
|
+
// 许可词:用户确认/同意/批准/授权/可以/允许/好/行/ok/yes 等 + 执行语(即可/现在/马上/…)
|
|
15
|
+
// 组合规则:许可词 [0,16 字符内] 执行语 → 构成执行许可;纯"确认方案/理解/方向"不构成。
|
|
16
|
+
export const APPROVAL_WORDS_RE = /(?:确认|同意|批准|授权|可以|允许|好|行|ok|yes)/i;
|
|
17
|
+
export const EXEC_FOLLOW_RE = /(?:即可|现在|马上|继续|开始|执行|落盘|去做|实施|进行|删除|修改|改|补|修|做|写入|创建|安装|发布|下载|提交|运行)/i;
|
|
18
|
+
export const APPROVAL_EXEC_RE = new RegExp(
|
|
19
|
+
APPROVAL_WORDS_RE.source.replace(/\\\//g, "/") +
|
|
20
|
+
"[^\\n]{0,16}" +
|
|
21
|
+
EXEC_FOLLOW_RE.source.replace(/\\\//g, "/"),
|
|
22
|
+
"i"
|
|
23
|
+
);
|
|
24
|
+
// 纯方案/理解/方向确认(不构成执行授权)
|
|
25
|
+
export const PLAN_ONLY_RE = /确认方案|确认理解|确认方向|确认无误|理解了|明白方向|了解|没问题|收到/i;
|
|
26
|
+
|
|
27
|
+
// ── ② 执行动作词表(intent.ACTION_RE / authorization.EXEC_ACTION_RE 的合并唯一源)──
|
|
28
|
+
// 注意:单一表同时服务"意图判定"(这句话是不是执行分点)与"ask 结果批准判定"(选择=批准),
|
|
29
|
+
// 语义一致,不另分裂;新增动作词只改这里。
|
|
30
|
+
export const ACTION_WORDS_RE =
|
|
31
|
+
/执行|跑|落|落盘|认可|开始|写入|写(?:下|好|完|成|作|文件)?|保存|另存为|删除|修改|改(?:为|成|动|一下|进|善)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)?|创建|安装|发布|下载|提交|运行|修复|替换|重建|启动|重启|停止|卸载|开始改|开始写|改进|优化|增强|完善|升级|迁移|整理|调整|实现|实施|添加|增加|补充|重写|改造|调试|排查|处理|解决|继续|推进|更新|部署|核实|梳理|诊断|对齐|跟进|落实|设计并实现|调查|开工|动手|进行|完成|选择|采纳|选定|产出|编写|撰写|生成|审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|调研|研究|转化|转换|读取|读(?:出|一下|一遍|完|出来)?|展示|打开|提取|还原|导入|导出|清理|移除|清空|残留|合入|合并|并入|融合|整合|起草|开展|搭建|重构|兼容|投产|校验|核验|过一遍|跑一遍|复验/i;
|
|
32
|
+
|
|
33
|
+
// 强执行语(直接执行指令,不等授权确认——用于意图置信提升)
|
|
34
|
+
export const STRONG_EXEC_RE =
|
|
35
|
+
/执行吧|开始执行|直接执行|立即执行|现在执行|马上执行|请落盘|开始写|开始改|执行以下|执行第|执行这个|执行它|落盘|开始蒸馏|开始迁移|开始修复|开始改进|开始优化|采纳|选定/i;
|
|
36
|
+
|
|
37
|
+
// 指令式动作词(用户命令式指令 = 视为授权执行;与批准词不同源、用途不同)
|
|
38
|
+
export const DIRECTIVE_WORDS_RE =
|
|
39
|
+
/(?:请\s*)?(?:删除|移除|修改|编辑|替换|写入|写(?:下|好|完|成|作|文件)?|保存|另存为|创建|复制|移动|执行|运行|下载|提交|推送|备份|安装|卸载|清理|启动|停止|重建|修复|改(?:为|成|动|一下)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)?|实施|推进|继续|处理|解决)/i;
|
|
40
|
+
|
|
41
|
+
// 拒绝词(授权/批准判定用)
|
|
42
|
+
export const REJECTION_WORDS_RE = /(?:不允许|拒绝|不要|不同意|取消(?:这次|并停)?|(?:^|[^是])否(?:[^则]|$)|no(?!tes?|thing|pe)\b|\bno\b|No)/i;
|
|
43
|
+
|
|
44
|
+
// ── ③ 疑问词表(问询分点判定;authorization.isQuestionMessage 同源)──
|
|
45
|
+
export const QUESTION_WORDS_RE =
|
|
46
|
+
/[??]|吗|为什么|是否|能不能|行不行|难道|是不是|能否|怎么|为何|是否要|影响吗|可以吗|要不要|需不需要/;
|
|
47
|
+
|
|
48
|
+
// ── ④ 状态信号词表("我已重启/已输入/好了/done";触发时不判"无执行分点")──
|
|
49
|
+
export const STATUS_SIGNAL_WORDS_RE =
|
|
50
|
+
/(?:我已|我已经|我(?:们)?(?:刚)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好))(?:了|完毕|完成|好)?|(?:^|\s)(?:已|已经)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好|unlock)(?:了|完毕|完成|好)?(?:\s|$)|(?:^|\s)已经(?:刚)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好|unlock)[^\s]*(?:\s|$)|(?:^|\s)(?:重启|输入|命令|操作)(?:完成|完毕|好了|已输入)(?:\s|$)|(?:^|\s)好了(?:\s|$)|^(?:done|restarted|finished|entered)$/i;
|
|
51
|
+
// 危险动作词:命中则不当作状态信号(防"我已删除了某文件"被当成就绪确认)
|
|
52
|
+
export const DANGEROUS_ACTION_WORDS_RE = /删除|覆盖|移除|清空|卸载|remove-item|rm\s+-r|del\s+/i;
|
|
53
|
+
|
|
54
|
+
// ── ⑤ 豁免工具清单(规则 22 ③ "只读/展示/ask/todo_write 等豁免"明细)──
|
|
55
|
+
// 只读/展示/询问/产物类工具在"无执行分点"回合也可调用(引擎 tool-catalog 的 analysis/artifact 分类
|
|
56
|
+
// 为总开关;本清单 = 规则正文"等豁免"所指的可枚举集,与 tool-catalog 一致,此处集中注释成对)。
|
|
57
|
+
export const EXEMPT_TOOL_NAMES = new Set([
|
|
58
|
+
// 只读/查询
|
|
59
|
+
"read", "grep", "glob", "read_image",
|
|
60
|
+
// 视觉分析
|
|
61
|
+
"vision_describe", "vision_detect", "vision_ground", "vision_colors", "vision_ocr", "vision_bootstrap",
|
|
62
|
+
// 状态查询
|
|
63
|
+
"job_list", "job_output", "list_agents", "get_goal", "schedule_list",
|
|
64
|
+
"engram_recall", "engram_detail", "engram_model",
|
|
65
|
+
"esr_status", "esr_ready", "esr_model",
|
|
66
|
+
// 网络只读
|
|
67
|
+
"web_fetch", "web_search",
|
|
68
|
+
// 询问/展示/产物
|
|
69
|
+
"ask_user_question", "todo_write", "visualize", "report",
|
|
70
|
+
// inspect 只读
|
|
71
|
+
"cordis_inspect_list", "cordis_inspect_query", "cordis_inspect_self"
|
|
72
|
+
]);
|
package/lib/core/llm-intent.js
CHANGED
|
@@ -9,10 +9,12 @@ import { createHash } from "node:crypto";
|
|
|
9
9
|
import { parseUserIntents, shouldDenyMutation } from "./intent.js";
|
|
10
10
|
import { resolveRoute, callLlm } from "./llm-understander.js";
|
|
11
11
|
import { audit } from "./audit.js";
|
|
12
|
+
import { learnHotword } from "./hotwords.js";
|
|
13
|
+
import { ACTION_WORDS_RE } from "./lexicon.js";
|
|
12
14
|
|
|
13
15
|
const LLM_PROMPT = [
|
|
14
16
|
"你是 DSH 规则引擎的意图判定器。输出严格 JSON,不要输出其他内容:",
|
|
15
|
-
'{"hasExecute":bool,"hasPlan":bool,"hasQuestion":bool,"ambiguous":bool,"reason":"一句话","confidence":0-1}',
|
|
17
|
+
'{"hasExecute":bool,"hasPlan":bool,"hasQuestion":bool,"ambiguous":bool,"reason":"一句话","confidence":0-1,"actionWord":"句中明确动作词(2-6字,如:通读、校对、梳理;无明确动作词则空串)"}',
|
|
16
18
|
"规则:hasExecute=消息含明确执行指令(动作词+非疑问句);hasQuestion=含疑问/征询;hasPlan=要方案/评估/设计;ambiguous=同一消息内标签或语气冲突(如【执行】标签+疑问正文);confidence=你的把握。",
|
|
17
19
|
"待判定用户消息:{{TEXT}}"
|
|
18
20
|
].join("\n");
|
|
@@ -99,8 +101,14 @@ export async function enrichIntentWithLlm(ctx, state, sid, text, cfg) {
|
|
|
99
101
|
hasQuestion: Boolean(out.hasQuestion),
|
|
100
102
|
ambiguous: Boolean(out.ambiguous),
|
|
101
103
|
reason: String(out.reason || "").slice(0, 120),
|
|
102
|
-
confidence: Number(out.confidence) || 0
|
|
104
|
+
confidence: Number(out.confidence) || 0,
|
|
105
|
+
actionWord: String(out.actionWord || "")
|
|
103
106
|
};
|
|
107
|
+
// B1(2026-08-29 方案甲):LLM 明确判 execute 且词表未命中 → 学动作词入热词表
|
|
108
|
+
//(下次同词词表直接命中,不再依赖 LLM;学习失败静默,不影响主流程)
|
|
109
|
+
if (verdict.hasExecute && verdict.actionWord && !ACTION_WORDS_RE.test(text)) {
|
|
110
|
+
learnHotword(verdict.actionWord);
|
|
111
|
+
}
|
|
104
112
|
const ms = Date.now() - started;
|
|
105
113
|
session.turn.llmIntent = { raw: text, verdict, at: Date.now(), ms };
|
|
106
114
|
session.turn.intentState = "llm-ready";
|
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
export async function resolveRoute(ctx) {
|
|
6
6
|
if (!ctx?.llm) return null;
|
|
7
7
|
try {
|
|
8
|
+
// 0.5.11(用户定稿,2026-08-28):后台判定(judge/llm-intent/规则理解)必须用
|
|
9
|
+
// **当前会话模型**,而不是"模型列表第一个"(此前列表第一=dsf 基础模型,与主会话
|
|
10
|
+
// dsfve 能力不一致——裁决结果与主会话判断脱节)。
|
|
11
|
+
// 优先级:① 官方当前模型选择(agentDefaultModel.currentSelection:provider+model)
|
|
12
|
+
// ② 进程环境变量(DSH_LLM_MODEL/DSH_LLM_PROVIDER,显式覆盖)
|
|
13
|
+
// ③ 模型列表第一个(原兜底)
|
|
14
|
+
try {
|
|
15
|
+
const sel = ctx.get?.("agentDefaultModel")?.currentSelection?.();
|
|
16
|
+
if (sel && typeof sel.model === "string" && sel.model.length > 0) {
|
|
17
|
+
const provider = sel.provider || process.env.DSH_LLM_PROVIDER || "";
|
|
18
|
+
if (provider) return { provider, model: sel.model };
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// currentSelection 失败 → 继续走兜底
|
|
22
|
+
}
|
|
8
23
|
const providers = ctx.llm.listProviders?.() || [];
|
|
9
24
|
if (providers.length === 0) return null;
|
|
10
25
|
// v0.5.7 修复(实弹抓到,2026-08-26):必须用 provider 的 **id**(如 "deepseek-official")调
|