dsh-decision-engine 0.5.1 → 0.5.2

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 CHANGED
@@ -66,6 +66,29 @@ cd ~/.dsh/profiles/desktop && pnpm install
66
66
  (笔记、备份)一概不碰。
67
67
  - **权限 600**:配置里可能有明文 key,迁移后仍保持只有你可读。
68
68
 
69
+ ### 数据目录解析规则(跨平台)
70
+
71
+ 数据目录是 **`$DSH_HOME/dsh-decision-engine/`**,`~/.dsh` 只是 `DSH_HOME`
72
+ 未设置时的默认值。解析顺序与宿主 `@deepseek-ai/dsh-home-paths` 完全一致:
73
+
74
+ | 优先级 | 来源 |
75
+ |---|---|
76
+ | 1 | 显式配置的路径(本插件暂无此入口) |
77
+ | 2 | 环境变量 `$DSH_HOME` |
78
+ | 3 | 操作系统家目录下的 `~/.dsh` |
79
+
80
+ 细节(都由 `lib/home-paths.js` 统一处理):
81
+
82
+ - **空白值等于未设置**:`DSH_HOME=""` 或纯空白会回退 `~/.dsh`,绝不会把数据
83
+ 目录解析到当前工作目录。
84
+ - **支持波浪号**:`DSH_HOME=~/myhome` 会展开;`~\`(Windows 形式)同样支持。
85
+ - **相对路径会被规范化成绝对路径**,否则落盘位置会随进程工作目录漂移。
86
+
87
+ **Windows 说明**:`~/.dsh` 里的 `.dsh` 只是**目录名**,不是路径分隔符——
88
+ Windows 允许点开头的目录。真正与平台相关的是分隔符与家目录位置,二者分别由
89
+ `node:path` 与 `node:os` 处理,所以 Windows 上实际路径是
90
+ `C:\Users\<你的用户名>\.dsh\dsh-decision-engine\config.yml`。
91
+
69
92
  ## 配置
70
93
 
71
94
  ### 插件配置页
@@ -0,0 +1,86 @@
1
+ /**
2
+ * DSH 家目录解析 —— 与宿主 `@deepseek-ai/dsh-home-paths` 保持同一语义。
3
+ *
4
+ * 为什么自己实现而不直接依赖宿主那个包:
5
+ *
6
+ * `@deepseek-ai/dsh-home-paths` 是宿主的 peer 依赖,装上它本插件就无法在
7
+ * 没有 DSH node_modules 的裸仓库里被加载——而本项目刻意保持零运行时依赖
8
+ * (`lib/providers.js` 甚至能在裸 Node 下单测)。因此这里复刻它的**行为**,
9
+ * 而不是引入它的代码。
10
+ *
11
+ * 与宿主 `resolveDshHome()` 必须一致的三条规则:
12
+ *
13
+ * 1. **优先级**:显式配置 > `$DSH_HOME` > `~/.dsh`
14
+ * 2. **空白即未设置**:空串或纯空白的 `$DSH_HOME` 当作没有,
15
+ * 否则空值会把数据目录解析到当前工作目录(宿主的注释专门强调过这点)
16
+ * 3. **波浪号展开**:`~`、`~/`、`~\` 都按操作系统家目录展开。
17
+ * 其中 `~\` 是 Windows 形式,宿主为此专门加了这一条
18
+ *
19
+ * 另外补上宿主也做、而早期实现漏掉的一步:**规范化成绝对路径**
20
+ * (`path.resolve`)。相对路径(`DSH_HOME=./home`)若不规范化,实际落盘位置
21
+ * 会随进程工作目录漂移,表现为「同一个配置有时读得到有时读不到」。
22
+ *
23
+ * 跨平台说明:`~/.dsh` 里的 `.dsh` 只是**目录名**,不是路径分隔符,Windows
24
+ * 完全允许点开头的目录。真正与平台相关的是分隔符与家目录位置,这两件事都由
25
+ * `node:path` / `node:os` 处理,所以 Windows 上会得到
26
+ * `C:\Users\<name>\.dsh\...`。
27
+ *
28
+ * @module dsh-decision-engine/home-paths
29
+ */
30
+
31
+ import { homedir } from "node:os";
32
+ import { join, resolve } from "node:path";
33
+
34
+ /** 默认的 Harness 家目录名(宿主同名常量)。 */
35
+ export const DSH_HOME_DIR_NAME = ".dsh";
36
+
37
+ /** 覆盖默认家目录的环境变量(宿主同名常量)。 */
38
+ export const DSH_HOME_ENV = "DSH_HOME";
39
+
40
+ /**
41
+ * 展开 `~`、`~/`、`~\` 前缀。
42
+ *
43
+ * 只处理「单独的家目录」与「当前用户」两种形式;`~alice/...` 这类命名用户
44
+ * 形式原样返回——与宿主 `expandHomePath` 的行为一致,避免自作聪明地改坏
45
+ * 用户写的路径。
46
+ *
47
+ * @param path - 可能带波浪号前缀的路径。
48
+ * @returns 展开后的路径;没有可识别前缀时原样返回。
49
+ */
50
+ export function expandHomePath(path) {
51
+ const text = typeof path === "string" ? path : "";
52
+ if (text === "~") return homedir();
53
+ if (text.startsWith("~/") || text.startsWith("~\\")) return join(homedir(), text.slice(2));
54
+ return text;
55
+ }
56
+
57
+ /**
58
+ * 解析 DSH 家目录。
59
+ *
60
+ * @param options.configured - 显式配置的路径,优先级最高(当前插件没有此入口,
61
+ * 保留参数是为了与宿主签名一致,便于将来接入 profile 配置)。
62
+ * @param options.env - 环境变量表,默认 `process.env`;注入便于单测。
63
+ * @returns 规范化后的绝对路径。
64
+ */
65
+ export function resolveDshHome(options = {}) {
66
+ const env = options.env ?? process.env;
67
+ const raw = env?.[DSH_HOME_ENV];
68
+ const fromEnv = typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
69
+ const configured = typeof options.configured === "string" && options.configured.trim().length > 0
70
+ ? options.configured
71
+ : undefined;
72
+ const chosen = configured ?? fromEnv ?? join(homedir(), DSH_HOME_DIR_NAME);
73
+ // resolve() 同时完成「展开相对路径」与「消除 . / .. 」,
74
+ // 保证不同工作目录下得到同一个绝对路径。
75
+ return resolve(expandHomePath(chosen));
76
+ }
77
+
78
+ /**
79
+ * 把若干路径段拼到解析出的家目录上。
80
+ *
81
+ * @param segments - 追加的路径段;为空时返回家目录本身。
82
+ * @returns 绝对路径。
83
+ */
84
+ export function dshHomePath(...segments) {
85
+ return join(resolveDshHome(), ...segments);
86
+ }
package/lib/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * 1. **零可选 API 依赖**。不调用 `settings.installSection`(该 API 在 DSH 中不存在),
7
7
  * 不依赖 `@deepseek-ai/dsh-credentials`、`launchEnvironmentOf`、`dshHomePath`。
8
- * 密钥走 config → 环境变量两级,路径走 `os.homedir()`。宿主 API 缺失不会炸成静默失效。
8
+ * 密钥走 config → 环境变量两级,路径走 `home-paths.js`(与宿主同一套规则)。
9
+ * 宿主 API 缺失不会炸成静默失效。
9
10
  *
10
11
  * 2. **endpoint 每次请求重新解析**。`classify()` 现读 `cfg` 并重新解析 provider,
11
12
  * 改配置立刻对下一次决策生效,无需重启(旧插件在 apply 期把 endpoint 固化进
@@ -23,8 +24,7 @@
23
24
 
24
25
  import { appendFile, mkdir, readFile } from "node:fs/promises";
25
26
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
26
- import { homedir } from "node:os";
27
- import { join } from "node:path";
27
+ import { dirname, join } from "node:path";
28
28
  import z from "@deepseek-ai/schemastery";
29
29
 
30
30
  import { DecisionCache } from "./decision-cache.js";
@@ -37,6 +37,7 @@ import { mountRoutePoint } from "./adapters/route.js";
37
37
  import { mountAgentPoints } from "./adapters/agent.js";
38
38
  import { mountCotTranslatePoint } from "./adapters/translate.js";
39
39
  import { applyConfigUpdates, parseConfigText } from "./config-file.js";
40
+ import { resolveDshHome } from "./home-paths.js";
40
41
  import { applyRefUpdates, listRefNames, pickRefValue } from "./credentials.js";
41
42
  import {
42
43
  DEFAULT_PROVIDER,
@@ -501,10 +502,9 @@ export function resolveKey(cfg, provider) {
501
502
  export function readCredentialFromDsh(ref) {
502
503
  const name = typeof ref === "string" ? ref.trim() : "";
503
504
  if (name.length === 0) return undefined;
504
- const candidates = [join(homedir(), ".dsh", ".credentials.yaml")];
505
- if (typeof process.env.DSH_HOME === "string" && process.env.DSH_HOME.length > 0) {
506
- candidates.unshift(join(process.env.DSH_HOME, ".credentials.yaml"));
507
- }
505
+ // 读侧与写侧必须用**同一套**规则,否则会出现"存了却读不回"。
506
+ // 两边都交给 resolveDshHome(),候选只剩一个路径。
507
+ const candidates = [join(resolveDshHome(), ".credentials.yaml")];
508
508
  for (const path of candidates) {
509
509
  let raw;
510
510
  try {
@@ -527,14 +527,11 @@ export function readCredentialFromDsh(ref) {
527
527
  * @returns 凭据文件绝对路径(不保证已存在)。
528
528
  */
529
529
  export function credentialFilePath() {
530
- // DSH_HOME 覆盖优先,其次 ~/.dsh。
531
- //
532
- // 注意:读取侧(readCredentialFromDsh)与写入侧必须用**同一套**候选,
533
- // 否则会出现"保存成功但读不回来"——写到了 A 文件、读的是 B 文件。
530
+ // 读取侧(readCredentialFromDsh)与写入侧必须用**同一套**规则,否则会
531
+ // 出现"保存成功但读不回来"——写到了 A 文件、读的是 B 文件。
534
532
  // 早先写入侧只认 ~/.dsh,就是在这一步把 key 写进真实家目录的。
535
- const home = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
536
- if (home.length > 0) return join(home, ".credentials.yaml");
537
- return join(homedir(), ".dsh", ".credentials.yaml");
533
+ // 现在两侧都从 resolveDshHome() 派生,规则只有一份。
534
+ return join(resolveDshHome(), ".credentials.yaml");
538
535
  }
539
536
 
540
537
  /**
@@ -617,11 +614,10 @@ const LEGACY_CONFIG_FILE_NAMES = ["startlux.yml"];
617
614
  * 与插件源码分离——源码可以 clone 到任意位置,运行期数据只在 DSH 家目录下。
618
615
  */
619
616
  function defaultTelemetryDir() {
620
- // DSH_HOME 优先:它既是"DSH 家目录"的权威来源,也让测试能完全隔离,
621
- // 不必碰用户真实数据(HOME 在某些运行环境里不生效)。
622
- const home = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
623
- const base = home.length > 0 ? home : join(homedir(), ".dsh");
624
- return join(base, DATA_DIR_NAME);
617
+ // 家目录解析统一走 home-paths.js:优先级、空白值处理、~ 展开与
618
+ // 绝对路径规范化都与宿主 @deepseek-ai/dsh-home-paths 一致。
619
+ // 早期各处手写一份 `DSH_HOME || ~/.dsh`,既重复又漏了规范化。
620
+ return join(resolveDshHome(), DATA_DIR_NAME);
625
621
  }
626
622
 
627
623
  /**
@@ -639,8 +635,7 @@ function defaultTelemetryDir() {
639
635
  * 找不到老配置。顺序即优先级:越新的旧位置越靠前。
640
636
  */
641
637
  function legacyConfigPaths() {
642
- const home = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
643
- const base = home.length > 0 ? home : join(homedir(), ".dsh");
638
+ const base = resolveDshHome();
644
639
  return [
645
640
  // 上一代数据目录(本项目改名前的正式位置)
646
641
  join(base, "dsh-startlux-decision", "startlux.yml"),
@@ -1273,6 +1268,11 @@ export function apply(ctx, config) {
1273
1268
  */
1274
1269
  const persistConfig = async (updates) => {
1275
1270
  const { writeFile } = await import("node:fs/promises");
1271
+ // 先建目录:全新安装时数据目录还不存在,直接 writeFile 会 ENOENT。
1272
+ // 那会造成最糟的一种失败——凭据已经写进去了、配置却写不进去,
1273
+ // 用户只看到一条报错,而 key 其实「存了一半」。
1274
+ // 目录创建必须幂等(recursive),已有目录时是无操作。
1275
+ await mkdir(dirname(ownConfigPath), { recursive: true });
1276
1276
  let lines = [];
1277
1277
  try {
1278
1278
  lines = (await readFile(ownConfigPath, "utf8")).split("\n");
@@ -1345,17 +1345,31 @@ export function apply(ctx, config) {
1345
1345
  const names = Object.keys(credentialWrites);
1346
1346
  if (names.length === 0) return { ok: true, patch: ymlPatch };
1347
1347
 
1348
+ /** 已成功写入的项,带上写入前的值,供上层在后续步骤失败时回滚。 */
1349
+ const applied = [];
1348
1350
  for (const name of names) {
1351
+ // 先记下旧值:万一配置没写成功,要把凭据恢复原状,
1352
+ // 否则会出现「凭据存了、配置没存」的半成功状态。
1353
+ const previous = readCredentialFromDsh(name);
1349
1354
  const result = await writeCredentialToDsh(name, credentialWrites[name]);
1350
1355
  if (!result.ok) {
1356
+ // 本次之前已经写成功的,一并回滚,避免留下半套改动。
1357
+ for (const done of applied) {
1358
+ try {
1359
+ await writeCredentialToDsh(done.refName, done.previous ?? "");
1360
+ } catch {
1361
+ /* 回滚尽力而为,失败也不能再抛 */
1362
+ }
1363
+ }
1351
1364
  return {
1352
1365
  ok: false,
1353
1366
  patch: ymlPatch,
1354
1367
  error: `写入 ~/.dsh/.credentials.yaml 失败(${name}):${result.error}`,
1355
1368
  };
1356
1369
  }
1370
+ applied.push({ refName: name, previous });
1357
1371
  }
1358
- return { ok: true, patch: ymlPatch };
1372
+ return { ok: true, patch: ymlPatch, applied };
1359
1373
  };
1360
1374
 
1361
1375
  /** 一次决策的完整记录,写入 telemetry.jsonl 便于事后核对。 */
@@ -1830,7 +1844,28 @@ export function apply(ctx, config) {
1830
1844
  // key 已被写进指定位置的,不能同时再写进 yml(会变成两处明文)。
1831
1845
  const ymlPatch = { ...keyResult.patch };
1832
1846
 
1833
- await persistConfig(ymlPatch);
1847
+ try {
1848
+ await persistConfig(ymlPatch);
1849
+ } catch (error) {
1850
+ // 配置没写成功,就把已落地的凭据恢复原状:宁可整体失败,
1851
+ // 也不要留下「凭据存了、配置没存」的半套状态——那种状态在
1852
+ // 界面上看起来是保存失败,实际 key 已经悄悄换了。
1853
+ for (const done of keyResult.applied ?? []) {
1854
+ try {
1855
+ await writeCredentialToDsh(done.refName, done.previous ?? "");
1856
+ } catch {
1857
+ /* 回滚尽力而为 */
1858
+ }
1859
+ }
1860
+ return json(
1861
+ {
1862
+ error:
1863
+ `写入配置文件失败:${error instanceof Error ? error.message : String(error)}` +
1864
+ (keyResult.applied?.length > 0 ? "(已回滚本次写入的凭据)" : ""),
1865
+ },
1866
+ 500,
1867
+ );
1868
+ }
1834
1869
  return json({ ok: true, ...statusPayload() });
1835
1870
  } catch (error) {
1836
1871
  return json({ error: error instanceof Error ? error.message : String(error) }, 500);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-engine",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — all nine decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, session title, COT translation) are implemented and individually switchable, and it works against any system-one style API (system one, bamboonode, or a custom base URL).",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",