dsh-sun-space 0.3.1 → 0.3.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/lib/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
@@ -20,43 +20,95 @@ var __exportAll = (all, no_symbols) => {
20
20
  *
21
21
  * 解析优先级(vaultPath() 每次调用重读,运行期变更即时生效):
22
22
  * ① 环境变量 SUN_VAULT(启动命令注入,随进程生效)
23
- * ② ~/.dsh/state/sun-space/config.json 的 vault 字段(用户手写,持久化,不入库)
24
- * ③ ~/.dsh/state/sun-space/state.json 的 vault 字段(Router 会话创建时插件自动登记 = 用户选定的工作区目录)
25
- * ④ 会话工作目录(调用方从 exec 提取传入——用户在哪个工作区开会话,vault 就解析到哪)
26
- * ⑤ 默认约定 ~/Workspace/sun-vault
23
+ * ② ~/.dsh/state/sun-space/config.json 的 vault 字段(用户手写/补建指令写入,持久化,不入库)
24
+ * ③ 会话工作目录(调用方从 exec 提取传入,须过 cwdHasSkeleton 门控——目录已含 90-Meta
25
+ * 骨架才算;"空目录"不算:DSH 默认 boot 桶 default-workspace 全新时也是空的,无法与
26
+ * "用户新建目录当知识库"区分,空目录意图在会话创建时由 registerVaultRootFromSession 落到 ④)
27
+ * ④ state.json 的 workspaces 映射(分桶登记:workspaces[<会话/实例 cwd>] = vault。
28
+ * DSH 会话按实例 boot cwd 分桶,一桶一 vault——多实例并存互不污染:
29
+ * homevault 实例登记 homevault,主实例解析不受影响)
30
+ * ⑤ state.json 的 vault 字段(全局兜底;只承载 ①② 显式值的镜像,见 publishVaultState)
31
+ * ⑥ 默认约定 ~/Workspace/sun-vault(仅全新裸环境兜底)
27
32
  *
28
- * ③ 的设计动机:用户的 vault 就是他在 DSH 里选定的那个目录——Router 会话创建时
29
- * 插件自动把该会话 cwd 登记为 vault 根(registerVaultRootFromSession),无需任何
30
- * 手工配置。①② 供自定义/覆盖;⑤ 仅在全新裸环境兜底。
33
+ * 无会话上下文的调用方(source/worker-watchdog):用 vaultPathForHost()——以实例进程
34
+ * cwd(= 本桶 boot 目录)查 ④ 映射,与本实例会话解析一致。
31
35
  *
32
- * 解析结果写回 state.json,作为浏览器端(孙学空间)的发现通道——浏览器经
33
- * workspaceFiles/read 以绝对路径读取该文件(工作区外绝对路径读取被宿主允许)。
36
+ * publishVaultState 纪律(v0.3.2 根因修复):落盘值仅限显式来源(①②⑤),⑥ 兜底值不写——
37
+ * 落了会把默认路径固化成登记值,堵死 ③④ 级(v0.3.1 及之前每次插件启动即写入,新开工作区
38
+ * 时"识别到的知识库"永远是 ~/Workspace/sun-vault 的幽灵)。
39
+ *
40
+ * registerVaultRootFromSession(首个 sun-router 会话创建时调用):仅对 vault 候选目录
41
+ * (空目录或已含 90-Meta)登记 workspaces[cwd]=cwd,不写全局 vault 字段——全局槽一旦
42
+ * 写入首个工作区,会遮蔽其他实例/桶的解析(多 vault 场景交叉污染)。
34
43
  *
35
44
  * 目录约定沿用 DSH 官方先例:~/.dsh/state/<插件名>/(session-superagent 同款)。
36
- * 本文件不携带任何个人路径;个人 vault 位置只存在于①②③(均不入库)。
45
+ * 本文件不携带任何个人路径;个人 vault 位置只存在于①②⑤(均不入库)。
37
46
  */
38
47
  const DEFAULT_VAULT_DIRNAME = "sun-vault";
39
48
  /** 插件自有状态目录(<DSH_HOME 或 ~/>/.dsh/state/sun-space/),沿用官方 state/<插件名> 约定。
40
49
  * 跟随 DSH_HOME:多实例(隔离 HOME 的测试环境)之间状态互不串扰。 */
41
50
  const stateDir = () => join(process.env.DSH_HOME ?? homedir(), ".dsh", "state", "sun-space");
42
- /** 纯函数:五级优先解析(测试与 vaultPath 共用)。 */
43
- function computeVaultPath(envValue, configJson, stateJson, sessionCwd, home = homedir()) {
51
+ function parseState(stateJson) {
52
+ try {
53
+ return JSON.parse(stateJson ?? "");
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** 目录是否已含 vault 骨架(③ 级门控:已初始化的工作区目录就是 vault)。 */
59
+ function cwdHasSkeleton(cwd) {
60
+ if (!cwd || !cwd.startsWith("/")) return false;
61
+ try {
62
+ return existsSync(join(cwd, "90-Meta"));
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+ /** 目录是否为 vault 候选(登记门控):空目录(非隐藏项为 0,git init 过的也算)
68
+ * 或已含 90-Meta 骨架。有内容而无骨架的目录(代码仓库根等)不是候选。 */
69
+ function cwdIsVaultCandidate(cwd) {
70
+ if (!cwd || !cwd.startsWith("/")) return false;
71
+ try {
72
+ if (existsSync(join(cwd, "90-Meta"))) return true;
73
+ return readdirSync(cwd).every((name) => name.startsWith("."));
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+ /** 纯函数:六级优先解析(测试与 vaultPath 共用)。
79
+ * sessionCwd 须由调用方先过 cwdHasSkeleton 门控;bucketCwd 为分桶目录(④ 映射查询用,
80
+ * 缺省同 sessionCwd)。 */
81
+ function computeVaultPath(envValue, configJson, stateJson, sessionCwd, home = homedir(), bucketCwd) {
44
82
  if (envValue && envValue.trim() !== "") return envValue;
45
- for (const raw of [configJson, stateJson]) try {
46
- const parsed = JSON.parse(raw ?? "");
47
- if (typeof parsed?.vault === "string" && parsed.vault.startsWith("/")) return parsed.vault;
83
+ try {
84
+ const cfg = JSON.parse(configJson ?? "");
85
+ if (typeof cfg?.vault === "string" && cfg.vault.startsWith("/")) return cfg.vault;
48
86
  } catch {}
87
+ const state = parseState(stateJson);
49
88
  if (sessionCwd && sessionCwd.startsWith("/")) return sessionCwd;
89
+ const bucket = bucketCwd ?? sessionCwd;
90
+ if (bucket && bucket.startsWith("/")) {
91
+ const mapped = state?.workspaces?.[bucket];
92
+ if (typeof mapped === "string" && mapped.startsWith("/")) return mapped;
93
+ }
94
+ if (typeof state?.vault === "string" && state.vault.startsWith("/")) return state.vault;
50
95
  return join(home, "Workspace", DEFAULT_VAULT_DIRNAME);
51
96
  }
97
+ /** ①②⑤ 显式来源(env/config/全局镜像)的取值;全空返回 undefined——
98
+ * ⑥ 兜底、③ 会话目录、④ 分桶映射都不算"显式"(publishVaultState 据此决定是否落盘)。 */
99
+ function explicitVaultValue(envValue, configJson, stateJson) {
100
+ if (envValue && envValue.trim() !== "") return envValue;
101
+ for (const raw of [configJson, stateJson]) {
102
+ const parsed = parseState(raw);
103
+ if (parsed && typeof parsed.vault === "string" && parsed.vault.startsWith("/")) return parsed.vault;
104
+ }
105
+ }
52
106
  let cached = {
53
107
  at: 0,
54
108
  key: "",
55
109
  value: ""
56
110
  };
57
- /** 运行期解析:每次调用重读状态目录(60s 微缓存防抖),登记/手改后即时生效。
58
- * sessionCwd:调用方从 exec 提取的会话工作目录(可选)——用户在哪个工作区开会话,vault 就解析到哪。 */
59
- function vaultPath(sessionCwd, now = Date.now()) {
111
+ function resolve(sessionCwd, now) {
60
112
  const dir = stateDir();
61
113
  let configJson;
62
114
  let stateJson;
@@ -66,9 +118,11 @@ function vaultPath(sessionCwd, now = Date.now()) {
66
118
  try {
67
119
  stateJson = readFileSync(join(dir, "state.json"), "utf8");
68
120
  } catch {}
69
- const key = `${sessionCwd ?? ""}|${configJson ?? ""}|${stateJson ?? ""}`;
121
+ const cwdSkeleton = cwdHasSkeleton(sessionCwd) ? sessionCwd : void 0;
122
+ const bucket = sessionCwd && sessionCwd.startsWith("/") ? sessionCwd : void 0;
123
+ const key = `${cwdSkeleton ?? ""}|${bucket ?? ""}|${configJson ?? ""}|${stateJson ?? ""}`;
70
124
  if (now - cached.at < 6e4 && cached.key === key) return cached.value;
71
- const value = computeVaultPath(process.env.SUN_VAULT, configJson, stateJson, sessionCwd);
125
+ const value = computeVaultPath(process.env.SUN_VAULT, configJson, stateJson, cwdSkeleton, homedir(), bucket);
72
126
  cached = {
73
127
  at: now,
74
128
  key,
@@ -76,26 +130,41 @@ function vaultPath(sessionCwd, now = Date.now()) {
76
130
  };
77
131
  return value;
78
132
  }
133
+ /** 运行期解析:每次调用重读状态目录(60s 微缓存防抖),登记/手改后即时生效。
134
+ * sessionCwd:调用方从 exec 提取的会话工作目录(可选)——本桶已初始化(含 90-Meta)
135
+ * 或已在 workspaces 映射登记时,vault 就解析到哪。 */
136
+ function vaultPath(sessionCwd, now = Date.now()) {
137
+ return resolve(sessionCwd, now);
138
+ }
139
+ /** 无会话上下文的调用方(source/watchdog 等):以实例进程 cwd(= 本桶 boot 目录)解析,
140
+ * 与本实例会话一致(④ 映射按桶命中)。 */
141
+ function vaultPathForHost(now = Date.now()) {
142
+ return resolve(process.cwd(), now);
143
+ }
79
144
  vaultPath();
80
145
  /**
81
146
  * Router 会话创建时登记 vault 根("当前目录作为 vault"语义的落点)。
82
- * 仅在更高优先级来源(env/config/state)均未设置时才登记首个值——
83
- * 多 Router 并存或重复创建时第一个登记者生效。
84
- * 尽力而为:任何失败静默退场(最坏是回退到约定路径)。
147
+ * 仅对候选目录(空目录或已含 90-Meta 骨架)登记 workspaces[cwd]=cwd——分桶登记,
148
+ * 不写全局 vault 字段(防多实例交叉污染);env 钉死时不登记(尊重显式覆盖)。
149
+ * 同桶幂等;尽力而为:任何失败静默退场(最坏是回退到约定路径)。
85
150
  */
86
151
  function registerVaultRootFromSession(cwd, now = /* @__PURE__ */ new Date()) {
87
- if (!cwd || !cwd.startsWith("/")) return false;
152
+ if (!cwdIsVaultCandidate(cwd)) return false;
88
153
  if (process.env.SUN_VAULT?.trim()) return false;
89
154
  try {
90
155
  const dir = stateDir();
91
156
  mkdirSync(dir, { recursive: true });
92
157
  const p = join(dir, "state.json");
93
- let state = {};
158
+ let raw;
94
159
  try {
95
- state = JSON.parse(readFileSync(p, "utf8"));
160
+ raw = readFileSync(p, "utf8");
96
161
  } catch {}
97
- if (state.vault && state.vault.startsWith("/")) return false;
98
- state.vault = cwd;
162
+ const state = parseState(raw) ?? {};
163
+ if (state.workspaces?.[cwd]) return false;
164
+ state.workspaces = {
165
+ ...state.workspaces ?? {},
166
+ [cwd]: cwd
167
+ };
99
168
  state.updatedAt = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}T${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
100
169
  writeFileSync(p, JSON.stringify(state, null, 2), "utf8");
101
170
  return true;
@@ -103,16 +172,25 @@ function registerVaultRootFromSession(cwd, now = /* @__PURE__ */ new Date()) {
103
172
  return false;
104
173
  }
105
174
  }
106
- /** 把解析结果写回状态目录的 state.json(apply 时调用,尽力而为),供客户端发现。 */
175
+ /** 把显式解析结果写回状态目录的 state.json(apply 时调用,尽力而为),供客户端发现。
176
+ * 纪律:仅显式来源(①env/②config/⑤全局镜像)才落盘;⑥ 兜底值不写——
177
+ * 否则默认路径被固化成登记值,堵死 ③④ 级(v0.3.2 修复的根因)。
178
+ * workspaces 分桶映射由 registerVaultRootFromSession 维护,此处原样保留。 */
107
179
  function publishVaultState(now = /* @__PURE__ */ new Date()) {
108
180
  try {
109
181
  const dir = stateDir();
110
- mkdirSync(dir, { recursive: true });
111
- const p = join(dir, "state.json");
112
- let state = {};
182
+ let configJson;
183
+ let stateJson;
113
184
  try {
114
- state = JSON.parse(readFileSync(p, "utf8"));
185
+ configJson = readFileSync(join(dir, "config.json"), "utf8");
115
186
  } catch {}
187
+ try {
188
+ stateJson = readFileSync(join(dir, "state.json"), "utf8");
189
+ } catch {}
190
+ if (!explicitVaultValue(process.env.SUN_VAULT, configJson, stateJson)) return;
191
+ mkdirSync(dir, { recursive: true });
192
+ const p = join(dir, "state.json");
193
+ const state = parseState(stateJson) ?? {};
116
194
  state.vault = vaultPath(void 0, now.getTime());
117
195
  state.updatedAt = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}T${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
118
196
  writeFileSync(p, JSON.stringify(state, null, 2), "utf8");
@@ -236,8 +314,8 @@ var source_exports = /* @__PURE__ */ __exportAll({
236
314
  const inject$2 = ["timer"];
237
315
  /** 轮询周期(毫秒):纯程序层,可调密。 */
238
316
  const POLL_MS = 18e5;
239
- const triggerDir$1 = () => `${vaultPath()}/90-Meta/triggers`;
240
- const snapshotFile = () => `${vaultPath()}/90-Meta/mock-data/last-snapshot.json`;
317
+ const triggerDir$1 = () => `${vaultPathForHost()}/90-Meta/triggers`;
318
+ const snapshotFile = () => `${vaultPathForHost()}/90-Meta/mock-data/last-snapshot.json`;
241
319
  /** trigger 文件 TTL:超过即视为已消费/过期,轮询成功路径顺带清理。 */
242
320
  const TRIGGER_TTL_MS = 6048e5;
243
321
  const CREDENTIAL_ALERT_COOLDOWN_MS = 36e5;
@@ -389,7 +467,7 @@ var worker_watchdog_exports = /* @__PURE__ */ __exportAll({
389
467
  });
390
468
  const name$1 = "worker-watchdog";
391
469
  const inject$1 = ["timer"];
392
- const triggerDir = () => `${vaultPath()}/90-Meta/triggers`;
470
+ const triggerDir = () => `${vaultPathForHost()}/90-Meta/triggers`;
393
471
  /** 默认停滞阈值 15 分钟:长思考/长命令完成后即重置计时,真卡死撑不到这么久。 */
394
472
  const DEFAULT_STALL_MS = 9e5;
395
473
  /** 巡检间隔 30 秒。 */
@@ -1,7 +1,7 @@
1
1
  import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join, resolve, sep } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
- import { readFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  // src/vault.ts
7
7
  /**
@@ -191,33 +191,78 @@ async function dingSelf(userId, content, type = "app") {
191
191
  *
192
192
  * 解析优先级(vaultPath() 每次调用重读,运行期变更即时生效):
193
193
  * ① 环境变量 SUN_VAULT(启动命令注入,随进程生效)
194
- * ② ~/.dsh/state/sun-space/config.json 的 vault 字段(用户手写,持久化,不入库)
195
- * ③ ~/.dsh/state/sun-space/state.json 的 vault 字段(Router 会话创建时插件自动登记 = 用户选定的工作区目录)
196
- * ④ 会话工作目录(调用方从 exec 提取传入——用户在哪个工作区开会话,vault 就解析到哪)
197
- * ⑤ 默认约定 ~/Workspace/sun-vault
194
+ * ② ~/.dsh/state/sun-space/config.json 的 vault 字段(用户手写/补建指令写入,持久化,不入库)
195
+ * ③ 会话工作目录(调用方从 exec 提取传入,须过 cwdHasSkeleton 门控——目录已含 90-Meta
196
+ * 骨架才算;"空目录"不算:DSH 默认 boot 桶 default-workspace 全新时也是空的,无法与
197
+ * "用户新建目录当知识库"区分,空目录意图在会话创建时由 registerVaultRootFromSession 落到 ④)
198
+ * ④ state.json 的 workspaces 映射(分桶登记:workspaces[<会话/实例 cwd>] = vault。
199
+ * DSH 会话按实例 boot cwd 分桶,一桶一 vault——多实例并存互不污染:
200
+ * homevault 实例登记 homevault,主实例解析不受影响)
201
+ * ⑤ state.json 的 vault 字段(全局兜底;只承载 ①② 显式值的镜像,见 publishVaultState)
202
+ * ⑥ 默认约定 ~/Workspace/sun-vault(仅全新裸环境兜底)
198
203
  *
199
- * ③ 的设计动机:用户的 vault 就是他在 DSH 里选定的那个目录——Router 会话创建时
200
- * 插件自动把该会话 cwd 登记为 vault 根(registerVaultRootFromSession),无需任何
201
- * 手工配置。①② 供自定义/覆盖;⑤ 仅在全新裸环境兜底。
204
+ * 无会话上下文的调用方(source/worker-watchdog):用 vaultPathForHost()——以实例进程
205
+ * cwd(= 本桶 boot 目录)查 ④ 映射,与本实例会话解析一致。
202
206
  *
203
- * 解析结果写回 state.json,作为浏览器端(孙学空间)的发现通道——浏览器经
204
- * workspaceFiles/read 以绝对路径读取该文件(工作区外绝对路径读取被宿主允许)。
207
+ * publishVaultState 纪律(v0.3.2 根因修复):落盘值仅限显式来源(①②⑤),⑥ 兜底值不写——
208
+ * 落了会把默认路径固化成登记值,堵死 ③④ 级(v0.3.1 及之前每次插件启动即写入,新开工作区
209
+ * 时"识别到的知识库"永远是 ~/Workspace/sun-vault 的幽灵)。
210
+ *
211
+ * registerVaultRootFromSession(首个 sun-router 会话创建时调用):仅对 vault 候选目录
212
+ * (空目录或已含 90-Meta)登记 workspaces[cwd]=cwd,不写全局 vault 字段——全局槽一旦
213
+ * 写入首个工作区,会遮蔽其他实例/桶的解析(多 vault 场景交叉污染)。
205
214
  *
206
215
  * 目录约定沿用 DSH 官方先例:~/.dsh/state/<插件名>/(session-superagent 同款)。
207
- * 本文件不携带任何个人路径;个人 vault 位置只存在于①②③(均不入库)。
216
+ * 本文件不携带任何个人路径;个人 vault 位置只存在于①②⑤(均不入库)。
208
217
  */
209
218
  const DEFAULT_VAULT_DIRNAME = "sun-vault";
210
219
  /** 插件自有状态目录(<DSH_HOME 或 ~/>/.dsh/state/sun-space/),沿用官方 state/<插件名> 约定。
211
220
  * 跟随 DSH_HOME:多实例(隔离 HOME 的测试环境)之间状态互不串扰。 */
212
221
  const stateDir = () => join(process.env.DSH_HOME ?? homedir(), ".dsh", "state", "sun-space");
213
- /** 纯函数:五级优先解析(测试与 vaultPath 共用)。 */
214
- function computeVaultPath(envValue, configJson, stateJson, sessionCwd, home = homedir()) {
222
+ function parseState(stateJson) {
223
+ try {
224
+ return JSON.parse(stateJson ?? "");
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+ /** 目录是否已含 vault 骨架(③ 级门控:已初始化的工作区目录就是 vault)。 */
230
+ function cwdHasSkeleton(cwd) {
231
+ if (!cwd || !cwd.startsWith("/")) return false;
232
+ try {
233
+ return existsSync(join(cwd, "90-Meta"));
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+ /** 目录是否为 vault 候选(登记门控):空目录(非隐藏项为 0,git init 过的也算)
239
+ * 或已含 90-Meta 骨架。有内容而无骨架的目录(代码仓库根等)不是候选。 */
240
+ function cwdIsVaultCandidate(cwd) {
241
+ if (!cwd || !cwd.startsWith("/")) return false;
242
+ try {
243
+ if (existsSync(join(cwd, "90-Meta"))) return true;
244
+ return readdirSync(cwd).every((name) => name.startsWith("."));
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+ /** 纯函数:六级优先解析(测试与 vaultPath 共用)。
250
+ * sessionCwd 须由调用方先过 cwdHasSkeleton 门控;bucketCwd 为分桶目录(④ 映射查询用,
251
+ * 缺省同 sessionCwd)。 */
252
+ function computeVaultPath(envValue, configJson, stateJson, sessionCwd, home = homedir(), bucketCwd) {
215
253
  if (envValue && envValue.trim() !== "") return envValue;
216
- for (const raw of [configJson, stateJson]) try {
217
- const parsed = JSON.parse(raw ?? "");
218
- if (typeof parsed?.vault === "string" && parsed.vault.startsWith("/")) return parsed.vault;
254
+ try {
255
+ const cfg = JSON.parse(configJson ?? "");
256
+ if (typeof cfg?.vault === "string" && cfg.vault.startsWith("/")) return cfg.vault;
219
257
  } catch {}
258
+ const state = parseState(stateJson);
220
259
  if (sessionCwd && sessionCwd.startsWith("/")) return sessionCwd;
260
+ const bucket = bucketCwd ?? sessionCwd;
261
+ if (bucket && bucket.startsWith("/")) {
262
+ const mapped = state?.workspaces?.[bucket];
263
+ if (typeof mapped === "string" && mapped.startsWith("/")) return mapped;
264
+ }
265
+ if (typeof state?.vault === "string" && state.vault.startsWith("/")) return state.vault;
221
266
  return join(home, "Workspace", DEFAULT_VAULT_DIRNAME);
222
267
  }
223
268
  let cached = {
@@ -225,9 +270,7 @@ let cached = {
225
270
  key: "",
226
271
  value: ""
227
272
  };
228
- /** 运行期解析:每次调用重读状态目录(60s 微缓存防抖),登记/手改后即时生效。
229
- * sessionCwd:调用方从 exec 提取的会话工作目录(可选)——用户在哪个工作区开会话,vault 就解析到哪。 */
230
- function vaultPath(sessionCwd, now = Date.now()) {
273
+ function resolve$1(sessionCwd, now) {
231
274
  const dir = stateDir();
232
275
  let configJson;
233
276
  let stateJson;
@@ -237,9 +280,11 @@ function vaultPath(sessionCwd, now = Date.now()) {
237
280
  try {
238
281
  stateJson = readFileSync(join(dir, "state.json"), "utf8");
239
282
  } catch {}
240
- const key = `${sessionCwd ?? ""}|${configJson ?? ""}|${stateJson ?? ""}`;
283
+ const cwdSkeleton = cwdHasSkeleton(sessionCwd) ? sessionCwd : void 0;
284
+ const bucket = sessionCwd && sessionCwd.startsWith("/") ? sessionCwd : void 0;
285
+ const key = `${cwdSkeleton ?? ""}|${bucket ?? ""}|${configJson ?? ""}|${stateJson ?? ""}`;
241
286
  if (now - cached.at < 6e4 && cached.key === key) return cached.value;
242
- const value = computeVaultPath(process.env.SUN_VAULT, configJson, stateJson, sessionCwd);
287
+ const value = computeVaultPath(process.env.SUN_VAULT, configJson, stateJson, cwdSkeleton, homedir(), bucket);
243
288
  cached = {
244
289
  at: now,
245
290
  key,
@@ -247,7 +292,61 @@ function vaultPath(sessionCwd, now = Date.now()) {
247
292
  };
248
293
  return value;
249
294
  }
295
+ /** 运行期解析:每次调用重读状态目录(60s 微缓存防抖),登记/手改后即时生效。
296
+ * sessionCwd:调用方从 exec 提取的会话工作目录(可选)——本桶已初始化(含 90-Meta)
297
+ * 或已在 workspaces 映射登记时,vault 就解析到哪。 */
298
+ function vaultPath(sessionCwd, now = Date.now()) {
299
+ return resolve$1(sessionCwd, now);
300
+ }
250
301
  vaultPath();
302
+ /** 状态目录诊断快照(sun_state_read 用):config/state 解析值 + 调用方视角的实际解析结果。
303
+ * "登记了什么 vs 实际生效什么"一眼可比(晨报自检/vault 解析诊断)。只读,不触发写回。 */
304
+ function readStateSnapshot(sessionCwd, now = Date.now()) {
305
+ const dir = stateDir();
306
+ let config = null;
307
+ let state = null;
308
+ try {
309
+ config = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
310
+ } catch {}
311
+ try {
312
+ state = JSON.parse(readFileSync(join(dir, "state.json"), "utf8"));
313
+ } catch {}
314
+ return {
315
+ config,
316
+ state,
317
+ resolvedVault: vaultPath(sessionCwd, now)
318
+ };
319
+ }
320
+ /**
321
+ * Router 会话创建时登记 vault 根("当前目录作为 vault"语义的落点)。
322
+ * 仅对候选目录(空目录或已含 90-Meta 骨架)登记 workspaces[cwd]=cwd——分桶登记,
323
+ * 不写全局 vault 字段(防多实例交叉污染);env 钉死时不登记(尊重显式覆盖)。
324
+ * 同桶幂等;尽力而为:任何失败静默退场(最坏是回退到约定路径)。
325
+ */
326
+ function registerVaultRootFromSession(cwd, now = /* @__PURE__ */ new Date()) {
327
+ if (!cwdIsVaultCandidate(cwd)) return false;
328
+ if (process.env.SUN_VAULT?.trim()) return false;
329
+ try {
330
+ const dir = stateDir();
331
+ mkdirSync(dir, { recursive: true });
332
+ const p = join(dir, "state.json");
333
+ let raw;
334
+ try {
335
+ raw = readFileSync(p, "utf8");
336
+ } catch {}
337
+ const state = parseState(raw) ?? {};
338
+ if (state.workspaces?.[cwd]) return false;
339
+ state.workspaces = {
340
+ ...state.workspaces ?? {},
341
+ [cwd]: cwd
342
+ };
343
+ state.updatedAt = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}T${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
344
+ writeFileSync(p, JSON.stringify(state, null, 2), "utf8");
345
+ return true;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }
251
350
  // src/router-tools.ts
252
351
  const inject = ["tools", "subagents"];
253
352
  /** 从工具执行上下文提取会话工作目录(用户在哪个工作区开会话,vault 就解析到哪)。防御式:形状未知时返回 undefined。 */
@@ -459,7 +558,8 @@ function apply(ctx) {
459
558
  required: ["path", "content"]
460
559
  }, async (args, exec) => {
461
560
  try {
462
- const p = resolveSafe(vaultPath(sessionCwdOf(exec)), args.path);
561
+ const root = vaultPath(sessionCwdOf(exec));
562
+ const p = resolveSafe(root, args.path);
463
563
  await mkdir(join(p, ".."), { recursive: true });
464
564
  if (args.mode === "append") await appendFile(p, args.content, "utf8");
465
565
  else await writeFile(p, args.content, "utf8");
@@ -467,6 +567,7 @@ function apply(ctx) {
467
567
  ok: true,
468
568
  data: {
469
569
  path: args.path,
570
+ vault: root,
470
571
  bytes: Buffer.byteLength(args.content)
471
572
  }
472
573
  };
@@ -576,6 +677,66 @@ function apply(ctx) {
576
677
  };
577
678
  }
578
679
  }));
680
+ ctx.tools.register(def("sun_state_read", "读取插件状态目录(~/.dsh/state/sun-space/)的登记与解析视图:config(用户手写 vault 覆盖)、state(workspaces 分桶登记 + 全局 vault 镜像)、resolvedVault(本会话实际生效的知识库根)。vault 解析诊断 / 晨报自检\"登记 vs 生效\"比对用。", {
681
+ type: "object",
682
+ properties: {}
683
+ }, async (_args, exec) => {
684
+ try {
685
+ return {
686
+ ok: true,
687
+ data: readStateSnapshot(sessionCwdOf(exec))
688
+ };
689
+ } catch (e) {
690
+ return {
691
+ ok: false,
692
+ note: String(e.message).slice(0, 200)
693
+ };
694
+ }
695
+ }));
696
+ ctx.tools.register(def("sun_state_write", "把当前会话工作目录登记为本工作区的 vault 根(state.json 的 workspaces 分桶映射——一桶一 vault,不影响其他工作区/实例;仅空目录或已含 90-Meta 骨架的目录可登记)。零审批。用于:插件升级前创建的旧会话迁移、登记缺失自愈。值由系统从会话上下文派生,只接受 vault:true,不接受路径参数。", {
697
+ type: "object",
698
+ properties: { vault: {
699
+ type: "boolean",
700
+ description: "登记本会话工作目录为 vault 根(唯一合法值 true)"
701
+ } },
702
+ required: ["vault"]
703
+ }, async (args, exec) => {
704
+ if (args.vault !== true) return {
705
+ ok: false,
706
+ note: "仅接受 vault:true——登记值由系统从会话上下文派生,不接受手工传路径"
707
+ };
708
+ const cwd = sessionCwdOf(exec);
709
+ if (!cwd) return {
710
+ ok: false,
711
+ note: "无法从会话上下文提取工作目录(绝对路径)"
712
+ };
713
+ if (process.env.SUN_VAULT?.trim()) return {
714
+ ok: false,
715
+ note: `SUN_VAULT 已显式钉死(${process.env.SUN_VAULT}),不登记——尊重显式覆盖`
716
+ };
717
+ if (!cwdIsVaultCandidate(cwd)) return {
718
+ ok: false,
719
+ note: `${cwd} 不是 vault 候选(须为空目录或已含 90-Meta 骨架)——有内容而无骨架的工作区目录不登记`
720
+ };
721
+ try {
722
+ const changed = registerVaultRootFromSession(cwd);
723
+ const snap = readStateSnapshot(cwd);
724
+ return {
725
+ ok: true,
726
+ data: {
727
+ bucket: cwd,
728
+ registered: snap.state?.workspaces?.[cwd] ?? null,
729
+ changed,
730
+ resolvedVault: snap.resolvedVault
731
+ }
732
+ };
733
+ } catch (e) {
734
+ return {
735
+ ok: false,
736
+ note: String(e.message).slice(0, 200)
737
+ };
738
+ }
739
+ }));
579
740
  ctx.tools.register(def("sun_route", "创建子 Worker 会话并分派任务(task_id + prompt,可选 cwd)。子会话以 task_id 命名、其会话 id 自动回写任务笔记 session 字段(工作台卡片可直达)。cwd 用于跨目录任务(如代码开发,传仓库根的绝对路径)——沙箱可写区自动跟随会话 cwd;产物落库仍走 sun_artifact_write(vault 绝对路径,不受 cwd 影响)。name 冲突时自动改用系统命名。", {
580
741
  type: "object",
581
742
  properties: {
@@ -639,6 +800,6 @@ function apply(ctx) {
639
800
  child_id: activation.childId ?? null
640
801
  };
641
802
  }));
642
- ctx.logger?.info?.("[sun-space] router tools registered: sun_read sun_vault_write sun_vault_meta sun_notify sun_route(subagents)");
803
+ ctx.logger?.info?.("[sun-space] router tools registered: sun_read sun_vault_write sun_vault_meta sun_notify sun_state_read sun_state_write sun_route(subagents)");
643
804
  }
644
805
  export { apply, inject, lossless };
@@ -1,6 +1,6 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join, resolve, sep } from "node:path";
3
- import { readFileSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  // src/vault.ts
6
6
  /**
@@ -34,33 +34,67 @@ function resolveSafe(vaultRoot, rel) {
34
34
  *
35
35
  * 解析优先级(vaultPath() 每次调用重读,运行期变更即时生效):
36
36
  * ① 环境变量 SUN_VAULT(启动命令注入,随进程生效)
37
- * ② ~/.dsh/state/sun-space/config.json 的 vault 字段(用户手写,持久化,不入库)
38
- * ③ ~/.dsh/state/sun-space/state.json 的 vault 字段(Router 会话创建时插件自动登记 = 用户选定的工作区目录)
39
- * ④ 会话工作目录(调用方从 exec 提取传入——用户在哪个工作区开会话,vault 就解析到哪)
40
- * ⑤ 默认约定 ~/Workspace/sun-vault
37
+ * ② ~/.dsh/state/sun-space/config.json 的 vault 字段(用户手写/补建指令写入,持久化,不入库)
38
+ * ③ 会话工作目录(调用方从 exec 提取传入,须过 cwdHasSkeleton 门控——目录已含 90-Meta
39
+ * 骨架才算;"空目录"不算:DSH 默认 boot 桶 default-workspace 全新时也是空的,无法与
40
+ * "用户新建目录当知识库"区分,空目录意图在会话创建时由 registerVaultRootFromSession 落到 ④)
41
+ * ④ state.json 的 workspaces 映射(分桶登记:workspaces[<会话/实例 cwd>] = vault。
42
+ * DSH 会话按实例 boot cwd 分桶,一桶一 vault——多实例并存互不污染:
43
+ * homevault 实例登记 homevault,主实例解析不受影响)
44
+ * ⑤ state.json 的 vault 字段(全局兜底;只承载 ①② 显式值的镜像,见 publishVaultState)
45
+ * ⑥ 默认约定 ~/Workspace/sun-vault(仅全新裸环境兜底)
41
46
  *
42
- * ③ 的设计动机:用户的 vault 就是他在 DSH 里选定的那个目录——Router 会话创建时
43
- * 插件自动把该会话 cwd 登记为 vault 根(registerVaultRootFromSession),无需任何
44
- * 手工配置。①② 供自定义/覆盖;⑤ 仅在全新裸环境兜底。
47
+ * 无会话上下文的调用方(source/worker-watchdog):用 vaultPathForHost()——以实例进程
48
+ * cwd(= 本桶 boot 目录)查 ④ 映射,与本实例会话解析一致。
45
49
  *
46
- * 解析结果写回 state.json,作为浏览器端(孙学空间)的发现通道——浏览器经
47
- * workspaceFiles/read 以绝对路径读取该文件(工作区外绝对路径读取被宿主允许)。
50
+ * publishVaultState 纪律(v0.3.2 根因修复):落盘值仅限显式来源(①②⑤),⑥ 兜底值不写——
51
+ * 落了会把默认路径固化成登记值,堵死 ③④ 级(v0.3.1 及之前每次插件启动即写入,新开工作区
52
+ * 时"识别到的知识库"永远是 ~/Workspace/sun-vault 的幽灵)。
53
+ *
54
+ * registerVaultRootFromSession(首个 sun-router 会话创建时调用):仅对 vault 候选目录
55
+ * (空目录或已含 90-Meta)登记 workspaces[cwd]=cwd,不写全局 vault 字段——全局槽一旦
56
+ * 写入首个工作区,会遮蔽其他实例/桶的解析(多 vault 场景交叉污染)。
48
57
  *
49
58
  * 目录约定沿用 DSH 官方先例:~/.dsh/state/<插件名>/(session-superagent 同款)。
50
- * 本文件不携带任何个人路径;个人 vault 位置只存在于①②③(均不入库)。
59
+ * 本文件不携带任何个人路径;个人 vault 位置只存在于①②⑤(均不入库)。
51
60
  */
52
61
  const DEFAULT_VAULT_DIRNAME = "sun-vault";
53
62
  /** 插件自有状态目录(<DSH_HOME 或 ~/>/.dsh/state/sun-space/),沿用官方 state/<插件名> 约定。
54
63
  * 跟随 DSH_HOME:多实例(隔离 HOME 的测试环境)之间状态互不串扰。 */
55
64
  const stateDir = () => join(process.env.DSH_HOME ?? homedir(), ".dsh", "state", "sun-space");
56
- /** 纯函数:五级优先解析(测试与 vaultPath 共用)。 */
57
- function computeVaultPath(envValue, configJson, stateJson, sessionCwd, home = homedir()) {
65
+ function parseState(stateJson) {
66
+ try {
67
+ return JSON.parse(stateJson ?? "");
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+ /** 目录是否已含 vault 骨架(③ 级门控:已初始化的工作区目录就是 vault)。 */
73
+ function cwdHasSkeleton(cwd) {
74
+ if (!cwd || !cwd.startsWith("/")) return false;
75
+ try {
76
+ return existsSync(join(cwd, "90-Meta"));
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+ /** 纯函数:六级优先解析(测试与 vaultPath 共用)。
82
+ * sessionCwd 须由调用方先过 cwdHasSkeleton 门控;bucketCwd 为分桶目录(④ 映射查询用,
83
+ * 缺省同 sessionCwd)。 */
84
+ function computeVaultPath(envValue, configJson, stateJson, sessionCwd, home = homedir(), bucketCwd) {
58
85
  if (envValue && envValue.trim() !== "") return envValue;
59
- for (const raw of [configJson, stateJson]) try {
60
- const parsed = JSON.parse(raw ?? "");
61
- if (typeof parsed?.vault === "string" && parsed.vault.startsWith("/")) return parsed.vault;
86
+ try {
87
+ const cfg = JSON.parse(configJson ?? "");
88
+ if (typeof cfg?.vault === "string" && cfg.vault.startsWith("/")) return cfg.vault;
62
89
  } catch {}
90
+ const state = parseState(stateJson);
63
91
  if (sessionCwd && sessionCwd.startsWith("/")) return sessionCwd;
92
+ const bucket = bucketCwd ?? sessionCwd;
93
+ if (bucket && bucket.startsWith("/")) {
94
+ const mapped = state?.workspaces?.[bucket];
95
+ if (typeof mapped === "string" && mapped.startsWith("/")) return mapped;
96
+ }
97
+ if (typeof state?.vault === "string" && state.vault.startsWith("/")) return state.vault;
64
98
  return join(home, "Workspace", DEFAULT_VAULT_DIRNAME);
65
99
  }
66
100
  let cached = {
@@ -68,9 +102,7 @@ let cached = {
68
102
  key: "",
69
103
  value: ""
70
104
  };
71
- /** 运行期解析:每次调用重读状态目录(60s 微缓存防抖),登记/手改后即时生效。
72
- * sessionCwd:调用方从 exec 提取的会话工作目录(可选)——用户在哪个工作区开会话,vault 就解析到哪。 */
73
- function vaultPath(sessionCwd, now = Date.now()) {
105
+ function resolve$1(sessionCwd, now) {
74
106
  const dir = stateDir();
75
107
  let configJson;
76
108
  let stateJson;
@@ -80,9 +112,11 @@ function vaultPath(sessionCwd, now = Date.now()) {
80
112
  try {
81
113
  stateJson = readFileSync(join(dir, "state.json"), "utf8");
82
114
  } catch {}
83
- const key = `${sessionCwd ?? ""}|${configJson ?? ""}|${stateJson ?? ""}`;
115
+ const cwdSkeleton = cwdHasSkeleton(sessionCwd) ? sessionCwd : void 0;
116
+ const bucket = sessionCwd && sessionCwd.startsWith("/") ? sessionCwd : void 0;
117
+ const key = `${cwdSkeleton ?? ""}|${bucket ?? ""}|${configJson ?? ""}|${stateJson ?? ""}`;
84
118
  if (now - cached.at < 6e4 && cached.key === key) return cached.value;
85
- const value = computeVaultPath(process.env.SUN_VAULT, configJson, stateJson, sessionCwd);
119
+ const value = computeVaultPath(process.env.SUN_VAULT, configJson, stateJson, cwdSkeleton, homedir(), bucket);
86
120
  cached = {
87
121
  at: now,
88
122
  key,
@@ -90,6 +124,12 @@ function vaultPath(sessionCwd, now = Date.now()) {
90
124
  };
91
125
  return value;
92
126
  }
127
+ /** 运行期解析:每次调用重读状态目录(60s 微缓存防抖),登记/手改后即时生效。
128
+ * sessionCwd:调用方从 exec 提取的会话工作目录(可选)——本桶已初始化(含 90-Meta)
129
+ * 或已在 workspaces 映射登记时,vault 就解析到哪。 */
130
+ function vaultPath(sessionCwd, now = Date.now()) {
131
+ return resolve$1(sessionCwd, now);
132
+ }
93
133
  vaultPath();
94
134
  // src/worker-vault.ts
95
135
  const inject = ["tools"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sun-space",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "Sun Space:DeepSeek Harness 个人超智能体插件——Router/Worker 双预设工具白名单、钉钉待办轮询、Obsidian 知识库落库、会话工作台与定时任务管理。",
6
6
  "exports": {
package/src/client.js CHANGED
@@ -20,9 +20,10 @@ window.__ModuleLoader__.load({
20
20
  const VAULT_DIRNAME = 'sun-vault' // vault 目录名约定;绝对路径动态发现,见 resolveVault
21
21
  // 架构 §4.1 不变量:呈现层不得持有会话 id / vault 绝对路径常量(Router 会话会轮换,机器会换)。
22
22
  // 会话解析链:session/list(sun-router 会话)→ 90-Meta/system/state.json 裁决 → updatedAt 最新兜底。
23
- // vault 解析链:会话 cwd 推 HOME → 约定布局探测 state.json → state.json 的 vault 字段裁决(跨机自愈)。
23
+ // vault 解析链(server 端 vault-path.ts 为唯一事实源):config/分桶映射(workspaces[本工作区])→
24
+ // 约定布局发现;当前工作区无骨架时引导以工作区为根初始化(服务端登记仅放行空目录/已含骨架)。
24
25
  // 写回:① 创建会话后经 session/prompt 引导自登记(含 vault 字段);② 晨报自检发现缺失/不一致自动修正。
25
- const WB_REV = 'v18' // 根节点 data-wb-rev 版本标记:判别浏览器是否吃到旧 client.js(immutable 缓存坑,HANDOFF 踩坑 1)
26
+ const WB_REV = 'v20' // 根节点 data-wb-rev 版本标记:判别浏览器是否吃到旧 client.js(immutable 缓存坑,HANDOFF 踩坑 1)
26
27
 
27
28
  const STATUS_META = {
28
29
  proposed: { label: '待确认派发', color: '#9e9e9e' },
@@ -97,12 +98,18 @@ window.__ModuleLoader__.load({
97
98
 
98
99
  // ===== vault 绝对路径发现(跨机器;呈现层不持常量,架构 §4.1 同款不变量)=====
99
100
  // 链:会话 cwd 推 HOME → 约定布局 <HOME>/Workspace/sun-vault 探测 state.json → state.json.vault 字段裁决。
100
- // state.json.vault 由 Router 自登记/晨报自检维护(server 端 VAULT 是唯一事实源)。
101
- async function resolveVault() {
102
- const v = await gw('session/list', {})
103
- const items = Array.isArray(v?.items) ? v.items : Object.values(v?.items ?? v ?? {})
101
+ // state.json 由 Router 自登记/晨报自检维护(server 端解析链是唯一事实源);
102
+ // workspaces 分桶映射优先(一桶一 vault,多实例互不污染,与 server ④ 级一致)。
103
+ // items 可选:调用方已取过 session/list 时复用(免重复 RPC)。
104
+ async function resolveVault(itemsIn, currentSessionId) {
105
+ let items = itemsIn
106
+ if (!items) {
107
+ const v = await gw('session/list', {})
108
+ items = Array.isArray(v?.items) ? v.items : Object.values(v?.items ?? v ?? {})
109
+ }
104
110
  const scope = items.find(s => s?.projections?.values?.agentPreset === 'sun-router')?.sessionId ?? items[0]?.sessionId
105
111
  if (!scope) return null
112
+ const myCwd = currentSessionId ? String((items.find(s => s.sessionId === currentSessionId) ?? {}).cwd ?? '') : ''
106
113
  // ① 插件状态目录(HOME 从会话 cwd 推导,绝对路径读取):
107
114
  // config.json 优先(运行期事实源——引导初始化/用户手写即时生效),state.json 兜底(启动期快照)
108
115
  const homes = [...new Set(items.map(s => String(s.cwd || '')).filter(c => c.startsWith('/')).map(c => c.split('/').slice(0, 3).join('/')))]
@@ -111,6 +118,8 @@ window.__ModuleLoader__.load({
111
118
  try {
112
119
  const raw = await readVaultFile(scope, `${home}/.dsh/state/sun-space/${file}`, 8)
113
120
  const st = JSON.parse(raw)
121
+ const mapped = myCwd.startsWith('/') ? st?.workspaces?.[myCwd] : undefined // 分桶登记(本工作区)
122
+ if (typeof mapped === 'string' && mapped.startsWith('/')) return mapped
114
123
  if (st?.vault && st.vault.startsWith('/')) return st.vault
115
124
  } catch { /* 该文件不存在 → 下一个 */ }
116
125
  }
@@ -163,15 +172,31 @@ window.__ModuleLoader__.load({
163
172
  // ===== 初始化检查(在任何会话的 tab 上都能跑;① 用当前会话作 workspaceFiles scope)=====
164
173
  async function runInitChecks(currentSessionId) {
165
174
  const c = { vault: false, presets: false, routerSession: null, vaultPath: null, currentCwd: null, notes: [] }
175
+ // 会话列表只取一次:currentCwd 供 ① 跨工作区提示与一键初始化,router 解析复用
176
+ let items = null
177
+ try {
178
+ const sl = await gw('session/list', {})
179
+ items = Array.isArray(sl?.items) ? sl.items : Object.values(sl?.items ?? {})
180
+ c.currentCwd = (items.find(s => s.sessionId === currentSessionId) ?? {}).cwd ?? null
181
+ } catch { c.notes.push('会话列表不可达 → 重试或检查实例') }
166
182
  // ① 知识库:vault 路径动态发现 + runbook 探测
167
- const vault = await resolveVault()
183
+ const vault = await resolveVault(items, currentSessionId)
168
184
  c.vaultPath = vault
169
185
  if (vault && currentSessionId) {
170
186
  try { await readVaultFile(currentSessionId, `${vault}/90-Meta/runbook.md`, 10); c.vault = true } catch {
171
187
  c.notes.push(`知识库未就绪(已定位 ${vault},缺 90-Meta 骨架):① 用 Obsidian 打开该目录 ② 可让任意 Router 会话执行"初始化知识库"指令 ③ 重新检查`)
172
188
  }
189
+ // 跨工作区提示:识别到的 vault 在别处、当前工作区无骨架——一键初始化将以当前工作区为根
190
+ //(服务端门控:仅空目录或已含 90-Meta 的工作区目录会被当 vault 登记/解析,见 vault-path.ts)
191
+ if (c.currentCwd && c.currentCwd !== vault) {
192
+ try { await readVaultFile(currentSessionId, `${c.currentCwd}/90-Meta/runbook.md`, 2) } catch {
193
+ c.notes.push(`知识库识别在 ${vault};当前工作区 ${c.currentCwd} 尚无骨架——【创建 Router 会话】将以当前工作区为知识库根初始化;若要沿用 ${vault},请到它所在的工作区开会话`)
194
+ }
195
+ }
173
196
  } else if (!vault) {
174
- c.notes.push(`未找到知识库目录:按约定应在 <用户目录>/Workspace/${VAULT_DIRNAME}(含 90-Meta/system/state.json);自定义位置需由 Router 把绝对路径写进 state.json 的 vault 字段`)
197
+ c.notes.push(c.currentCwd
198
+ ? `未找到既有知识库——当前工作区 ${c.currentCwd} 将作为知识库根(待初始化):完成下方初始化引导即可`
199
+ : `未找到知识库目录:按约定应在 <用户目录>/Workspace/${VAULT_DIRNAME}(含 90-Meta/system/state.json);自定义位置需由 Router 把绝对路径写进 state.json 的 vault 字段`)
175
200
  } else {
176
201
  c.notes.push('知识库检查需要会话作读取通道 → 先创建会话')
177
202
  }
@@ -188,11 +213,12 @@ window.__ModuleLoader__.load({
188
213
  c.notes.push('预设服务不可达 → 确认 Harness 实例在线、插件已启用')
189
214
  }
190
215
  // ③ Router 会话:动态解析 + 首个 sun-router 会话创建时自动登记其 cwd 为 vault 根
191
- // ("当前目录作为 vault"语义的插件层落点;env/config 更高优先级不受影响)
216
+ // ("当前目录作为 vault"语义的插件层落点;门控在服务端——空目录/已含骨架才登记)
192
217
  try {
193
- const sl = await gw('session/list', {})
194
- const items = Array.isArray(sl?.items) ? sl.items : Object.values(sl?.items ?? {})
195
- c.currentCwd = (items.find(s => s.sessionId === currentSessionId) ?? {}).cwd ?? null
218
+ if (!items) { // 顶部取失败时兜底重取
219
+ const sl = await gw('session/list', {})
220
+ items = Array.isArray(sl?.items) ? sl.items : Object.values(sl?.items ?? {})
221
+ }
196
222
  // vault 根登记由宿主插件在 sun-router 会话创建时自动完成(index.ts agent/created 钩子)
197
223
  c.routerSession = vault ? await resolveRouterSession(vault, items) : null
198
224
  if (!c.routerSession) c.notes.push('Router 会话不存在 → 点下方「创建 Router 会话」(sun-router 预设,随当前工作区),创建后自动登记 state.json')
@@ -277,13 +303,14 @@ window.__ModuleLoader__.load({
277
303
  requestId: 'sun-init-' + Date.now(),
278
304
  sessionId: sid,
279
305
  mode: 'queue',
280
- content: [{ type: 'text', text: `【孙学空间初始化】你是新环境的 Sun Router(你的会话 id:${sid})。你的工作目录即知识库根(vault)。按顺序完成:
281
- 1) 用 sun_vault_write 创建知识库骨架(vault 即你的工作目录,已在会话创建时自动登记):
306
+ content: [{ type: 'text', text: `【孙学空间初始化】你是新环境的 Sun Router(你的会话 id:${sid})。按顺序完成:
307
+ 1) 用 sun_state_write 确认/补全本工作区的 vault 根登记(参数 {"vault": true}——会话创建时已自动登记,此步幂等自愈;若 ok:false 按注记处理)
308
+ 2) 用 sun_vault_write 创建知识库骨架(实际根以工具返回的 vault 字段为准,勿凭工作目录猜测):
282
309
  - 90-Meta/schemas.md:frontmatter 契约最小集(通用字段 type/id/status/created/updated;task 的 status 枚举 proposed|running|need-confirm|done|blocked|dropped;写入纪律:Journal 由 Router 独占、日期一律本地时间 YYYY-MM-DDTHH:mm 不带时区后缀、结论必注出处)
283
310
  - 90-Meta/runbook.md:初始版(# Sun 行为手册 + v0.1 版本行 + §1 角色:Router 只分派不执行 + §2 沟通规范:IM ≤100 字结论先行 + §3 打扰分级:info 攒批/ask 立即/urgent 加 DING)
284
311
  - 90-Meta/memory.md:初始版(# 关于用户,待填充)
285
- 2) 用 sun_vault_write 写 90-Meta/system/state.json:{"routerSession": "${sid}", "vault": "<你的工作目录绝对路径>", "updatedAt": "<本地时间 YYYY-MM-DDTHH:mm>"}(自登记,供孙学空间发现与轮换机制使用)
286
- 3) 完成后列出生成的文件清单与知识库路径。` }],
312
+ 3) 用 sun_vault_write 写 90-Meta/system/state.json:{"routerSession": "${sid}", "vault": "<知识库根绝对路径——填第 2 步 sun_vault_write 返回的 vault 值>", "updatedAt": "<本地时间 YYYY-MM-DDTHH:mm>"}(自登记,供孙学空间发现与轮换机制使用)
313
+ 4) 完成后列出生成的文件清单与知识库路径。` }],
287
314
  clientTimeZone: 'Asia/Shanghai',
288
315
  })
289
316
  try { hostCtx.uiWorkspace.openSession(sid) } catch (e) { D.errors.push('openSession: ' + String(e)) }
@@ -298,13 +325,13 @@ window.__ModuleLoader__.load({
298
325
  requestId: 'sun-skel-' + Date.now(),
299
326
  sessionId: routerSession,
300
327
  mode: 'queue',
301
- content: [{ type: 'text', text: `【知识库骨架补建 + vault 根登记】你的工作目录即知识库根(vault)。按顺序完成:
302
- 1) 把知识库根登记到状态目录 config.json(执行:mkdir -p "\${DSH_HOME:-\$HOME}/.dsh/state/sun-space" && printf '{"vault": "%s"}' "\$(pwd)" > "\${DSH_HOME:-\$HOME}/.dsh/state/sun-space/config.json"——此写在工作区之外,会弹一次审批,等待用户批准后再继续)
303
- 2) 用 sun_vault_write 创建/补全知识库骨架:
304
- - 90-Meta/schemas.md:frontmatter 契约最小集(通用字段 type/id/status/created/updated;写入纪律同 runbook)
328
+ content: [{ type: 'text', text: `【知识库骨架补建 + vault 根登记】按顺序完成:
329
+ 1) 用 sun_state_write 登记本工作区为 vault 根(参数 {"vault": true}——分桶登记,零审批,不影响其他工作区;值由系统从你的会话上下文派生。若 ok:false 按注记处理,禁止用 shell 手写状态目录)
330
+ 2) 用 sun_vault_write 创建/补全知识库骨架(实际根以工具返回的 vault 字段为准,勿凭工作目录猜测):
331
+ - 90-Meta/schemas.md:frontmatter 契约最小集(通用字段 type/id/status/created/updated;task 的 status 枚举 proposed|running|need-confirm|done|blocked|dropped;写入纪律同 runbook)
305
332
  - 90-Meta/runbook.md:若缺失则写初始版(# Sun 行为手册 + v0.1)
306
333
  - 90-Meta/memory.md:若缺失则写初始版(# 关于用户)
307
- - 90-Meta/system/state.json:{"routerSession": "${routerSession}", "vault": "<你的工作目录绝对路径>", "updatedAt": "<本地时间>"}(已有的话合并更新 routerSession/vault 字段)
334
+ - 90-Meta/system/state.json:{"routerSession": "${routerSession}", "vault": "<第 2 步 sun_vault_write 返回的 vault 值>", "updatedAt": "<本地时间 YYYY-MM-DDTHH:mm 无时区后缀>"}(已有的话合并更新 routerSession/vault 字段)
308
335
  完成后报告清单与知识库路径。` }],
309
336
  clientTimeZone: 'Asia/Shanghai',
310
337
  })
@@ -423,7 +450,7 @@ window.__ModuleLoader__.load({
423
450
  function OnboardPanel({ checks, currentSessionId, onRetry }) {
424
451
  const steps = [
425
452
  { n: 1, ok: checks.presets, title: '确认依赖插件', desc: checks.presets ? '智能体协作 / 子智能体插件已启用' : '打开插件面板,启用 sun-space / 智能体协作相关插件后重启实例', action: checks.presets ? null : 'plugins' },
426
- { n: 2, ok: !!checks.routerSession, title: '一键初始化(Router 会话 + 知识库)', desc: checks.routerSession ? `已就绪:${checks.routerSession.slice(0, 28)}…` : `一键完成:以当前工作区目录${checks.currentCwd ? `(${checks.currentCwd})` : ''}为知识库根 → 创建 Router 会话 → 写入 90-Meta 骨架 → 自登记(仅 ~/.dsh 登记需一次批准)`, action: checks.presets && !checks.routerSession ? 'create-router' : null },
453
+ { n: 2, ok: !!checks.routerSession, title: '一键初始化(Router 会话 + 知识库)', desc: checks.routerSession ? `已就绪:${checks.routerSession.slice(0, 28)}…` : `一键完成:以当前工作区目录${checks.currentCwd ? `(${checks.currentCwd})` : ''}为知识库根 → 创建 Router 会话 → 写入 90-Meta 骨架 → 分桶自登记(全程零审批)`, action: checks.presets && !checks.routerSession ? 'create-router' : null },
427
454
  { n: 3, ok: checks.vault, title: '知识库就绪', desc: checks.vault ? `已就绪:${checks.vaultPath}` : (checks.routerSession ? '知识库骨架缺失 → 点下方按钮让 Router 补建' : '完成第②步后自动创建'), action: checks.presets && checks.routerSession && !checks.vault ? 'init-skeleton' : null },
428
455
  ]
429
456
  const allOk = steps.every(x => x.ok)