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,136 @@
1
+ /**
2
+ * `config.yml` 的极简读写。
3
+ *
4
+ * 为什么自己写:本插件的配置全是「标量 + 字符串数组」,为它引入一个 YAML
5
+ * 依赖不值得(插件目标之一是零运行时依赖)。但这套读写必须**可测**——
6
+ * 「保存后再打开没回显」「切换 provider 把别的配置冲掉」这类问题都出在这里,
7
+ * 而它们无法靠读代码确认,只能靠往返测试。
8
+ *
9
+ * 因此把它从 `index.js` 拆出来:本模块零依赖、无 I/O,只做纯文本变换,
10
+ * 可以被 `test/config-file.test.js` 直接调用。
11
+ *
12
+ * 支持的语法(够用即止):
13
+ *
14
+ * key: value 标量(含引号、行尾注释)
15
+ * key: 显式空值(保留为 "",语义是「用户要求留空」)
16
+ * key: [a, b] 字符串数组
17
+ * # 注释 / 空行 忽略
18
+ *
19
+ * @module dsh-decision-engine/config-file
20
+ */
21
+
22
+ /**
23
+ * 解析配置文件文本。
24
+ *
25
+ * @param text - 文件全文。
26
+ * @returns 键值对;无法识别的行被忽略。
27
+ */
28
+ export function parseConfigText(text) {
29
+ const out = {};
30
+ for (const line of String(text ?? "").split(/\r?\n/)) {
31
+ const trimmed = line.trim();
32
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
33
+ const sep = trimmed.indexOf(":");
34
+ if (sep <= 0) continue;
35
+ const key = trimmed.slice(0, sep).trim();
36
+ let value = trimmed.slice(sep + 1).trim();
37
+ // 去掉行尾注释(只处理 ` #` 之后的,避免把 URL 里的 # 当注释)
38
+ const hashAt = value.indexOf(" #");
39
+ if (hashAt >= 0) value = value.slice(0, hashAt).trim();
40
+ // 显式空值(`model:` 后面什么都不写)必须被保留为 ""。
41
+ // 若在这里丢弃,键会缺失并回落到内置默认模型名,「自动」就永远无法生效。
42
+ if (value.length === 0) {
43
+ out[key] = "";
44
+ continue;
45
+ }
46
+ if (isSingleQuoted(value)) {
47
+ out[key] = value.slice(1, -1);
48
+ continue;
49
+ }
50
+ if (isDoubleQuoted(value)) {
51
+ // 双引号内允许 `\"`:面板保存带引号的值(如 JSON 形态的请求头)时
52
+ // 会转义,读回必须还原,否则每次保存都多一层反斜杠。
53
+ out[key] = value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
54
+ continue;
55
+ }
56
+ // 标量类型推断
57
+ if (value === "true") out[key] = true;
58
+ else if (value === "false") out[key] = false;
59
+ else if (/^-?\d+$/.test(value)) out[key] = Number(value);
60
+ else if (value.startsWith("[") && value.endsWith("]")) {
61
+ out[key] = value
62
+ .slice(1, -1)
63
+ .split(",")
64
+ .map((part) => part.trim().replace(/^["']|["']$/g, ""))
65
+ .filter((part) => part.length > 0);
66
+ } else out[key] = value;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** 值是否为成对单引号包裹(未闭合不算)。 */
72
+ function isSingleQuoted(value) {
73
+ return value.length >= 2 && value.startsWith("'") && value.endsWith("'");
74
+ }
75
+
76
+ /** 值是否为成对双引号包裹(未闭合不算)。 */
77
+ function isDoubleQuoted(value) {
78
+ return value.length >= 2 && value.startsWith('"') && value.endsWith('"');
79
+ }
80
+
81
+ /**
82
+ * 把值渲染成 yml 右侧的文本。
83
+ *
84
+ * 需要引号的情况:含 `: # [ ] { } ,` 或首尾空白。优先用单引号,
85
+ * 因为值里带 `"` 时单引号不需要转义;只有同时含单引号与特殊字符时才退回
86
+ * 双引号并转义。
87
+ *
88
+ * @param value - 任意标量。
89
+ * @returns 渲染结果(不含 `key: ` 前缀)。
90
+ */
91
+ export function renderConfigValue(value) {
92
+ if (typeof value === "boolean") return String(value);
93
+ if (typeof value === "number") return String(value);
94
+ if (Array.isArray(value)) {
95
+ return `[${value.map((item) => String(item ?? "").trim()).filter((item) => item.length > 0).join(", ")}]`;
96
+ }
97
+ const text = String(value ?? "");
98
+ if (text.length === 0) return "";
99
+ const needsQuote = /[:#\[\]{},]/.test(text) || text !== text.trim() || text.includes("\n");
100
+ if (!needsQuote) return text;
101
+ if (!text.includes("'") && !text.includes("\n")) return `'${text}'`;
102
+ return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
103
+ }
104
+
105
+ /**
106
+ * 把若干键值写回配置文本的行数组。
107
+ *
108
+ * 保留原有注释与顺序,只替换/追加目标键——用户的注释是说明书,
109
+ * 整文件重写一次就全丢,而这份文件是用户唯一能看懂配置的地方。
110
+ *
111
+ * @param lines - 现有行(会被修改副本,不原地改动入参)。
112
+ * @param updates - 要写入的键值;`undefined` 视为「不修改」。
113
+ * @returns 新行数组。
114
+ */
115
+ export function applyConfigUpdates(lines, updates) {
116
+ const out = [...lines];
117
+ for (const [key, value] of Object.entries(updates ?? {})) {
118
+ if (value === undefined) continue;
119
+ const rendered = `${key}: ${renderConfigValue(value)}`.trimEnd();
120
+ let replaced = false;
121
+ for (let index = 0; index < out.length; index += 1) {
122
+ const trimmed = out[index].trim();
123
+ if (trimmed.startsWith("#")) continue;
124
+ const sep = trimmed.indexOf(":");
125
+ if (sep <= 0) continue;
126
+ if (trimmed.slice(0, sep).trim() !== key) continue;
127
+ // 保留原有的缩进,避免把用户手动缩进的块写歪。
128
+ const indent = out[index].slice(0, out[index].length - out[index].trimStart().length);
129
+ out[index] = indent + rendered;
130
+ replaced = true;
131
+ break;
132
+ }
133
+ if (!replaced) out.push(rendered);
134
+ }
135
+ return out;
136
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `~/.dsh/.credentials.yaml` 的 `refs:` 段读写。
3
+ *
4
+ * 为什么单独一层:密钥存哪里是本插件的一个显式选项(`keyStorage`),
5
+ * 「写进 refs 段」这件事必须**可测**——写坏一个共享凭据文件,代价是
6
+ * 用户所有服务(DeepSeek / ModelScope / 智谱 …)的 key 一起失效。
7
+ *
8
+ * 因此这里只做纯文本变换:给定原文与键值,返回新文本。
9
+ * 文件 I/O 留给调用方,本模块零依赖、可被单测直接驱动。
10
+ *
11
+ * 只碰 `refs:` 段,`records:` 段(DSH 的授权记录,带嵌套结构)一个字节都不改。
12
+ *
13
+ * @module dsh-decision-engine/credentials
14
+ */
15
+
16
+ /**
17
+ * 在 `refs:` 段里查找某个键的值。
18
+ *
19
+ * @param raw - 文件全文。
20
+ * @param name - 键名。
21
+ * @returns 值(已去引号);未找到返回 undefined。
22
+ */
23
+ export function pickRefValue(raw, name) {
24
+ const refs = parseRefsBlock(raw);
25
+ if (refs === undefined) return undefined;
26
+ for (const entry of refs.entries) {
27
+ if (entry.name === name) return entry.value;
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ /**
33
+ * 列出 `refs:` 段里的所有键名(不含值)。
34
+ *
35
+ * 配置页用它显示「哪些 key 已经配好」,而不必把明文读进内存。
36
+ *
37
+ * @param raw - 文件全文。
38
+ * @returns 键名数组;没有 refs 段时为空数组。
39
+ */
40
+ export function listRefNames(raw) {
41
+ const refs = parseRefsBlock(raw);
42
+ if (refs === undefined) return [];
43
+ return refs.entries.map((entry) => entry.name);
44
+ }
45
+
46
+ /**
47
+ * 定位 `refs:` 段:起始行、结束行与段内条目。
48
+ *
49
+ * @param raw - 文件全文。
50
+ * @returns `{ start, end, indent, entries }`;没有 refs 段时返回 undefined。
51
+ */
52
+ function parseRefsBlock(raw) {
53
+ const lines = String(raw ?? "").split(/\r?\n/);
54
+ let start = -1;
55
+ let refsIndent = -1;
56
+ /** @type {Array<{line: number, name: string, value: string}>} */
57
+ const entries = [];
58
+
59
+ for (let index = 0; index < lines.length; index += 1) {
60
+ const line = lines[index];
61
+ const trimmed = line.trim();
62
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
63
+ const indent = line.length - line.trimStart().length;
64
+
65
+ if (trimmed === "refs:") {
66
+ start = index;
67
+ refsIndent = indent;
68
+ continue;
69
+ }
70
+ if (start < 0) continue;
71
+ // 缩进不深于 refs 的顶层键 = refs 段结束。
72
+ if (indent <= refsIndent) {
73
+ return { start, end: index, indent: refsIndent, entries };
74
+ }
75
+ const sep = trimmed.indexOf(":");
76
+ if (sep <= 0) continue;
77
+ const name = trimmed.slice(0, sep).trim();
78
+ const value = trimmed
79
+ .slice(sep + 1)
80
+ .trim()
81
+ .replace(/^["']|["']$/g, "");
82
+ entries.push({ line: index, name, value });
83
+ }
84
+ if (start < 0) return undefined;
85
+ return { start, end: lines.length, indent: refsIndent, entries };
86
+ }
87
+
88
+ /**
89
+ * 把一组键值写入 `refs:` 段。
90
+ *
91
+ * 行为约定:
92
+ *
93
+ * - 已存在的键:原地替换值,保留该行位置;
94
+ * - 不存在的键:追加到 refs 段末尾(段内缩进对齐已有条目);
95
+ * - 值为空字符串:**删除该键**(用户清空即撤销,不留空壳行);
96
+ * - 没有 refs 段:在文件末尾新建一个;
97
+ * - `records:` 段与注释:一律不动。
98
+ *
99
+ * @param raw - 文件全文。
100
+ * @param updates - 键名 → 值。值传 "" 表示删除。
101
+ * @returns 新文本。
102
+ */
103
+ export function applyRefUpdates(raw, updates) {
104
+ const source = String(raw ?? "");
105
+ const lines = source.split(/\r?\n/);
106
+ const block = parseRefsBlock(source);
107
+
108
+ // 没有 refs 段:追加一个。先保证末尾有换行,避免与最后一行粘连。
109
+ if (block === undefined) {
110
+ const body = lines.filter((line) => line.length > 0 || lines.indexOf(line) > 0);
111
+ // 去掉尾部空行后补回一个换行
112
+ while (body.length > 0 && body[body.length - 1].trim().length === 0) body.pop();
113
+ const additions = [];
114
+ additions.push("refs:");
115
+ for (const [name, value] of Object.entries(updates ?? {})) {
116
+ if (typeof value !== "string" || value.length === 0) continue;
117
+ additions.push(` ${name}: ${renderValue(value)}`);
118
+ }
119
+ return [...body, ...additions, ""].join("\n");
120
+ }
121
+
122
+ // 条目缩进:沿用段内第一个子条目,没有就比 refs 多两格。
123
+ const childIndent =
124
+ block.entries.length > 0
125
+ ? " ".repeat(block.entries[0].line >= 0 ? countIndent(lines[block.entries[0].line]) : block.indent + 2)
126
+ : " ".repeat(block.indent + 2);
127
+
128
+ /** 待删除的行号(值为空时)。 */
129
+ const removals = new Set();
130
+ /** 新增的条目行。 */
131
+ const additions = [];
132
+
133
+ for (const [name, value] of Object.entries(updates ?? {})) {
134
+ const existing = block.entries.find((entry) => entry.name === name);
135
+ if (typeof value !== "string" || value.length === 0) {
136
+ if (existing !== undefined) removals.add(existing.line);
137
+ continue;
138
+ }
139
+ const rendered = `${childIndent}${name}: ${renderValue(value)}`;
140
+ if (existing !== undefined) {
141
+ lines[existing.line] = rendered;
142
+ } else {
143
+ additions.push(rendered);
144
+ }
145
+ }
146
+
147
+ // 追加位置:refs 段最后一个条目之后;段内没有条目则紧跟 `refs:` 行。
148
+ let insertAt;
149
+ if (block.entries.length > 0) {
150
+ insertAt = block.entries[block.entries.length - 1].line + 1;
151
+ } else {
152
+ insertAt = block.start + 1;
153
+ }
154
+
155
+ const result = [];
156
+ for (let index = 0; index < lines.length; index += 1) {
157
+ if (removals.has(index)) continue;
158
+ result.push(lines[index]);
159
+ if (index === insertAt - 1) result.push(...additions);
160
+ }
161
+ // insertAt 落在文件末尾之外(refs 是最后一行且没有条目)
162
+ if (insertAt >= lines.length) result.push(...additions);
163
+ return result.join("\n");
164
+ }
165
+
166
+ /** 行首空格数。 */
167
+ function countIndent(line) {
168
+ return line.length - line.trimStart().length;
169
+ }
170
+
171
+ /**
172
+ * 渲染 YAML 标量。
173
+ *
174
+ * key 都是 `sk-…` 这类无特殊字符的串,但仍要处理少数含 `:#` 或引号的值,
175
+ * 否则写进去之后读不回来(值是 key,读错就等于 key 丢了)。
176
+ *
177
+ * @param value - 原始值。
178
+ * @returns 可安全写入的文本。
179
+ */
180
+ function renderValue(value) {
181
+ const text = String(value);
182
+ if (text.length === 0) return "";
183
+ const needsQuote = /[:#\[\]{},]/.test(text) || text !== text.trim();
184
+ if (!needsQuote) return text;
185
+ if (!text.includes("'")) return `'${text}'`;
186
+ return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
187
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 决策结果缓存。
3
+ *
4
+ * 为什么需要两种键:
5
+ *
6
+ * 旧实现只用「完整 state 的 sha256」当键。完整 state 里含 `args_preview`
7
+ * (本次工具调用的实际参数),所以每次调用参数一变键就变——实测 309 次
8
+ * 调用命中 0 次,等于缓存完全没工作,每次都真发请求。
9
+ *
10
+ * 现在分两层:
11
+ *
12
+ * exact 与旧实现相同,键来自完整 state。命中即复用,绝对安全。
13
+ * soft 键来自「归一化签名」:工具名 + 命令族 + 风险标记集合。
14
+ * 只有**低风险放行**的裁决才写进这一层,且必须风险标记完全一致
15
+ * 才复用——这样 `node -e '...'` 这类重复调试能命中,而
16
+ * `rm -rf /` 与 `rm -rf /tmp/x` 因为风险标记一致、但都属于高风险
17
+ * 被排除在缓存之外,永不误用。
18
+ */
19
+
20
+ import { createHash } from "node:crypto";
21
+
22
+ /**
23
+ * 危险动作标记。命中越多,越不可能进 soft 层。
24
+ * 这些词是「会改变外部世界」的动作,而不是普通读写。
25
+ */
26
+ const RISK_PATTERNS = [
27
+ ["rm", /\brm\b/],
28
+ ["rmdir", /\brmdir\b/],
29
+ ["mv", /\bmv\b/],
30
+ ["dd", /\bdd\b/],
31
+ ["mkfs", /\bmkfs/],
32
+ ["chmod", /\bchmod\b/],
33
+ ["chown", /\bchown\b/],
34
+ ["sudo", /\bsudo\b/],
35
+ ["kill", /\b(kill|pkill|killall)\b/],
36
+ ["reboot", /\b(reboot|shutdown|halt)\b/],
37
+ ["git-push", /\bgit\s+push\b/],
38
+ ["git-reset", /\bgit\s+(reset|clean)\b/],
39
+ ["publish", /\b(npm|pnpm|yarn)\s+publish\b/],
40
+ ["curl", /\b(curl|wget)\b/],
41
+ ["ssh", /\b(ssh|scp|rsync)\b/],
42
+ ["redirect", /(^|[^>])>{1,2}[^>]/],
43
+ ["pipe", /\|/],
44
+ ["chain", /(&&|;)/],
45
+ ["subshell", /\$\(|`/],
46
+ ["eval", /\b(eval|exec)\b/],
47
+ ["truncate", /\b(truncate|unlink)\b/],
48
+ ["drop", /\b(drop|delete|truncate)\s+(table|database)\b/i],
49
+ ["launchctl", /\b(launchctl|crontab|systemctl)\b/],
50
+ ["defaults", /\bdefaults\s+write\b/],
51
+ ["security", /\bsecurity\s+(delete|add)/],
52
+ ];
53
+
54
+ /**
55
+ * 从一段命令文本里抽取风险标记。
56
+ * @param text - 命令或参数预览。
57
+ * @returns 排序后的标记名数组。
58
+ */
59
+ export function riskFlags(text) {
60
+ const source = typeof text === "string" ? text : "";
61
+ if (source.length === 0) return [];
62
+ const hits = [];
63
+ for (const [name, pattern] of RISK_PATTERNS) {
64
+ if (pattern.test(source)) hits.push(name);
65
+ }
66
+ return hits;
67
+ }
68
+
69
+ /**
70
+ * 归一化命令族:取第一个「看起来像命令」的词。
71
+ *
72
+ * 目的是让同一类重复操作落到同一个键上,例如反复跑的
73
+ * `node -e ...` / `python3 - <<PY` / `ls -la`。
74
+ *
75
+ * @param text - 命令或参数预览。
76
+ * @returns 命令族名(小写),取不到时返回空串。
77
+ */
78
+ export function commandFamily(text) {
79
+ const source = (typeof text === "string" ? text : "").trim();
80
+ if (source.length === 0) return "";
81
+ // 去掉前导环境变量赋值,如 `FOO=1 bar`
82
+ const withoutEnv = source.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, "");
83
+ const first = withoutEnv.split(/[\s;|&]+/)[0] ?? "";
84
+ // 只保留命令名的基名,去掉路径
85
+ const base = first.split("/").pop() ?? "";
86
+ return base.toLowerCase().slice(0, 32);
87
+ }
88
+
89
+ /**
90
+ * 双层决策缓存。
91
+ *
92
+ * @param limit - 最多保留多少条(<=0 表示禁用缓存)。
93
+ * @param ttlMs - 条目存活时间;策略或配置改了之后旧裁决不该继续被复用。
94
+ */
95
+ export class DecisionCache {
96
+ /**
97
+ * @param getLimit - 返回当前容量的取值函数;<=0 表示禁用缓存。
98
+ * @param getTtlMs - 返回当前存活时间的取值函数。
99
+ * 两者都接收函数而非定值:配置页改了缓存参数应当立刻生效,
100
+ * 不该要求重启(与预算、端点、密钥的处理方式一致)。
101
+ */
102
+ constructor(getLimit, getTtlMs) {
103
+ this.getLimit = getLimit;
104
+ this.getTtlMs = getTtlMs;
105
+ this.map = new Map();
106
+ this.stats = { exactHits: 0, softHits: 0, stores: 0, softStores: 0 };
107
+ }
108
+
109
+ /** 当前容量。 */
110
+ get limit() {
111
+ const value = Number(typeof this.getLimit === "function" ? this.getLimit() : this.getLimit);
112
+ return Number.isFinite(value) ? value : 0;
113
+ }
114
+
115
+ /** 当前存活时间。 */
116
+ get ttlMs() {
117
+ const value = Number(typeof this.getTtlMs === "function" ? this.getTtlMs() : this.getTtlMs);
118
+ return Number.isFinite(value) && value > 0 ? value : 600000;
119
+ }
120
+
121
+ /** 完整 state 的精确键。 */
122
+ static exactKey(parts) {
123
+ return "e:" + createHash("sha256").update(JSON.stringify(parts)).digest("hex");
124
+ }
125
+
126
+ /**
127
+ * 归一化软键:工具名 + 命令族 + 风险标记。
128
+ * 三者全同才认为是同一类操作。
129
+ */
130
+ static softKey(toolName, text) {
131
+ const flags = riskFlags(text).join(",");
132
+ const family = commandFamily(text);
133
+ return `s:${toolName}|${family}|${flags}`;
134
+ }
135
+
136
+ get(key) {
137
+ const entry = this.map.get(key);
138
+ if (entry === undefined) return undefined;
139
+ if (Date.now() - entry.at > this.ttlMs) {
140
+ this.map.delete(key);
141
+ return undefined;
142
+ }
143
+ // LRU:命中后移到队尾
144
+ this.map.delete(key);
145
+ this.map.set(key, entry);
146
+ if (key.startsWith("s:")) this.stats.softHits += 1;
147
+ else this.stats.exactHits += 1;
148
+ return entry.value;
149
+ }
150
+
151
+ /**
152
+ * 写入。exact 键总是可写;soft 键只接受「低风险放行」的裁决。
153
+ * @param key - 键。
154
+ * @param value - 裁决。
155
+ * @param soft - 是否为软键。
156
+ */
157
+ set(key, value, soft = false) {
158
+ if (this.limit <= 0) return;
159
+ if (soft) this.stats.softStores += 1;
160
+ this.stats.stores += 1;
161
+ if (this.map.has(key)) this.map.delete(key);
162
+ this.map.set(key, { value, at: Date.now() });
163
+ while (this.map.size > this.limit) {
164
+ const oldest = this.map.keys().next();
165
+ if (oldest.done) break;
166
+ this.map.delete(oldest.value);
167
+ }
168
+ }
169
+
170
+ /** 命中率(含软硬两层),用于配置页展示。 */
171
+ get report() {
172
+ const hits = this.stats.exactHits + this.stats.softHits;
173
+ const total = hits + this.stats.stores;
174
+ return {
175
+ size: this.map.size,
176
+ exactHits: this.stats.exactHits,
177
+ softHits: this.stats.softHits,
178
+ stores: this.stats.stores,
179
+ hitRate: total === 0 ? 0 : Math.round((hits / total) * 1000) / 1000,
180
+ };
181
+ }
182
+ }