dsh-skill-tag 0.1.8 → 0.1.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 CHANGED
@@ -20,8 +20,8 @@ office * plugin # 只要 office 包带来的、且是插件相关
20
20
  | **全局标签** | 建/改名/删除标签,标签之间无层级 |
21
21
  | **标签组模板** | 把表达式存成模板(`前端组`、`只留本机技能`…),可复用、可求值 |
22
22
  | **布尔表达式** | `+`=OR、`*`=AND、`*` 优先、支持括号;改标签名会自动改写所有引用它的表达式 |
23
- | **工作区级筛选** | 每个工作区一套条件,只把命中的技能下发给该工作区的对话 |
24
- | **对话级筛选** | 每个对话可以有**自己的**快照,独立于所属工作区;可一键「继承工作区配置」 |
23
+ | **工作区级筛选** | 每个工作区一套条件,作为该工作区**新建对话**的初始模板;改工作区不影响已存在的对话 |
24
+ | **对话级筛选** | 每个对话有**自己的**快照:新建时继承一次,之后冻结;未配置过的对话 = 空条件 = 全部技能;可一键「继承工作区配置」重新拉取 |
25
25
  | **禁用 / 手动添加** | 在标签命中之外额外放行某个技能,或强行屏蔽某个(即使它命中) |
26
26
  | **技能 ↔ 标签双向编辑** | 「tag 联系 skill」和「skill 联系 tag」两个方向都能改 |
27
27
  | **打开技能所在文件夹** | 任何列出技能的地方都有 📁 按钮,一点即在文件管理器里定位 |
@@ -20,10 +20,35 @@
20
20
  | 对话自己的禁用/手动添加 | **存在对话自己身上**(每对话独立,不影响工作区、不影响其他对话) |
21
21
  | 继承的覆盖范围 | **标签条件 + 手动添加 + 禁用,整体覆盖**(不是合并) |
22
22
  | 可见性规则 | 与工作区版相同:空条件 = 全部技能;有条件 = 标签命中 ∪ 手动添加 − 被禁用 |
23
+ | 打开管理标签 | **纯读,不写入任何配置**(2026-10-31 修正,见 §1.1) |
23
24
 
24
25
  > **解耦原则**:对话一旦有了自己的配置快照(`own`),它就是这个对话的权威口径,
25
26
  > 哪怕这份快照是"空的"——空代表用户主动清空过条件,此时再跟着工作区漂移就是错的。
26
- > 见 `lib/index.js` 的 `effectiveSkillsConfig(own, ws)`。
27
+ > 见 `lib/index.js` 的 `effectiveSkillsConfig(own)`。
28
+
29
+ ### 1.1 三种对话的统一口径(2026-10-31 用户确认)
30
+
31
+ 运行时只存在三种对话,判定与行为如下:
32
+
33
+ | # | 类型 | 判据 | `own` | 可见技能 | 工作区再改时 |
34
+ | --- | --- | --- | --- | --- | --- |
35
+ | ① | 插件**安装前**就存在 | `session.header.createdAt < installed_at` | 保持 `null` | 空条件 = **全部技能** | 不受影响 |
36
+ | ② | 刚创建的新对话 | 首次被观察时 `createdAt >= installed_at` | 出生时继承一次 | 继承工作区三件套 | 不再跟随 |
37
+ | ③ | 安装后创建、已存在一阵 | 同 ②,但已播过种 | 已冻结 | 冻结值不变 | 不受影响 |
38
+
39
+ **两条不变量**(改代码时不可破坏):
40
+
41
+ 1. **`own` 只在两个时刻写入**:出生播种、用户点「继承工作区配置」。
42
+ 用户手动编辑(tag 设置 / 手动添加 / 禁用)也算显式写入,但只动已有快照。
43
+ **打开「管理标签(对话)」不是写入时机** —— 旧实现会在这里用当前工作区播种,
44
+ 导致"看一眼就被冻结",且冻结值取决于点开的先后顺序。
45
+ 2. **`own === null` 的语义固定为「从未配置过 = 空条件 = 掌握全部技能」**,
46
+ 运行时**绝不**回退到工作区实时配置。工作区退化为「新建对话的初始模板 +
47
+ 继承按钮的数据源」,不再实时下发。
48
+
49
+ `installed_at` 存在 `sync_meta` 表(键 `installed_at`),本版首次运行时写入、永不覆盖。
50
+ 判定用 `createdAt` 而不是 `agent/created` 事件 —— 宿主对**恢复**已有会话同样会发该事件
51
+ (`dsh-agent` 的 `announce(agent, "startup")` 是唯一路径),事件本身区分不了新建与打开。
27
52
 
28
53
  ---
29
54
 
@@ -52,10 +77,15 @@ conversationRecord = z.object({
52
77
  ```
53
78
 
54
79
  `own` 是**新增且独立**的一份快照,不复用 `config`(后者是旧的四区派生结果),
55
- 以免与既有「对话标签」逻辑互相污染。`own` 缺失的历史行由 `ensureOwn()` 补:
80
+ 以免与既有「对话标签」逻辑互相污染。
81
+
82
+ **`own` 缺失时不再补任何东西**(2026-10-31 起):
83
+
84
+ - `ensureOwn()` 只保证对话行存在,**不写 own**;
85
+ - 打开「管理标签(对话)」仍是纯读;
86
+ - 只有 `seedOwnOnBirth()`(出生)与 `inheritFromWorkspace()`(用户点继承)会写入。
56
87
 
57
- - 有绑定工作区 → 用工作区当前配置**播种**(= 默认继承,只发生一次)
58
- - 没绑定工作区 → 空快照(`emptyOwnConfig()`)
88
+ 历史行 `own === null` 就保持 `null`,运行时按「空条件 = 全部技能」处理。
59
89
 
60
90
  ### 2.2 领域函数(`src/domain/conv.js`)
61
91
 
@@ -245,14 +275,10 @@ ctx.inject(["commandUi"], (scope) => scope.effect(() => scope.commandUi.register
245
275
  `lib/index.js` 的 `refreshBySession(sessionId, cwd)` → `recomputeDenials(...)`:
246
276
 
247
277
  ```js
248
- const convId = sessionId; // 对话主键 = sessionId
249
- const own = conv?.own ?? null;
250
- const wsId = own?.sourceWorkspaceId
251
- ?? workspaceIdOfSession(registry, sessionId)
252
- ?? workspaceIdOfPath(registry, cwd) // ← 第一轮兜底,见 5.1
253
- ?? conv?.inherited?.workspaceId;
254
- const effective = effectiveSkillsConfig(own, ws); // own 优先,即使空快照
255
- const candidates = candidatesFor({ // ← 不绕 conversations.candidates()
278
+ const convId = sessionId; // 对话主键 = sessionId
279
+ const own = conv?.own ?? null; // 只认 own,**不解析工作区**
280
+ const effective = effectiveSkillsConfig(own); // own 为 null → 空条件(全部技能)
281
+ const candidates = candidatesFor({ // ← 不绕 conversations.candidates()
256
282
  skillTagLinks: links, config: effective.config, active: activeTagsOf(effective.config),
257
283
  });
258
284
  const denied = workspaceDeniedSkills({ ...effective, universe, candidates });
@@ -360,12 +386,12 @@ ctx.on("domain/changed", (change) => {
360
386
  | # | 情况 | 期望行为 |
361
387
  | --- | --- | --- |
362
388
  | 1 | 新建对话且绑定了工作区 | 自动继承一次工作区的条件/手动/禁用 |
363
- | 2 | 新建对话但没绑工作区 | 空快照(无条件 = 全部技能) |
364
- | 3 | 工作区后来改了配置 | 已存在的对话**不跟随**(只有点继承才拉取) |
389
+ | 2 | 新建对话但没绑工作区 | `own` 保持 `null`(= 无条件 = 全部技能) |
390
+ | 3 | 工作区后来改了配置 | 已存在的对话**不跟随**(只有点继承才拉取);未配置过的也不跟随 |
365
391
  | 4 | 点「继承工作区配置」 | 整体覆盖条件+手动+禁用(不是合并),工作区不受影响 |
366
392
  | 5 | 对话没有任何工作区可继承 | HTTP 400「没有可继承的工作区」+ 界面提示 |
367
- | 6 | 历史对话(无 `own` 字段)首次打开 | `ensureOwn(cid, wid)` 播种一次并落库 |
368
- | 7 | 历史对话且没有工作区 | 补空快照并落库 |
393
+ | 6 | 历史对话(无 `own` 字段)首次打开 | **纯读,不播种**;`own` 仍是 `null` |
394
+ | 7 | 历史对话且没有工作区 | 同上,不写数据 |
369
395
  | 8 | 表达式为空 | 合法(无条件),不报 `EMPTY`,可直接保存 |
370
396
  | 9 | 表达式只有空格 | 同 8(`trim()` 后为空) |
371
397
  | 10 | 表达式非法(`A+`) | 保存按钮禁用 + 错误「定位 第N字符」;HTTP 400 |
@@ -413,16 +439,21 @@ ctx.on("domain/changed", (change) => {
413
439
  | B14 | 工作区后续改动不流入已存在的对话 | 同上 |
414
440
  | B15 | 继承整体覆盖三件套、且不回写工作区 | 同上 |
415
441
  | B16 | 两对话配置互相隔离 | 同上 |
416
- | B17 | 落盘跨实例恢复、历史行补快照 | 同上 |
442
+ | B17 | 落盘跨实例恢复 / 历史行打开后 own 仍为 null | 同上 |
417
443
  | B18 | HTTP 全链路(读→继承→改条件→加技能→禁用→恢复) | 同上最后一项 |
418
444
  | B19 | 无工作区时继承返回 400 | 同上 |
419
- | B20 | `effectiveSkillsConfig` 的四种情形(own 优先/空快照/退回 ws/都无) | `tests/runtime.test.mjs` |
445
+ | B20 | `effectiveSkillsConfig`:own 优先 / null → 空条件(**不退回工作区**) | `tests/runtime.test.mjs` |
420
446
  | B21 | `workspaceIdOfSession` / `workspaceIdOfPath` 正常与异常 | 同上 |
421
447
  | B22 | `getWorkspaceRegistry` 只走 `ctx.get`,不碰 `ctx.workspaceRegistry` 属性 | `tests/runtime.test.mjs` |
422
448
  | B23 | 服务缺失 / 属性访问抛异常时 `getWorkspaceRegistry` 返回 `null`,不抛 | 同上 |
423
449
  | B24 | `apply()` 在无 `workspaceRegistry` 的最小 cordis 面不抛 | 同上 |
424
450
  | B25 | **`domain/changed` 监听器内部抛错不得逃逸**(否则 host 直接崩) | 同上 |
425
451
  | B26 | `createFilterProvider` 上游抛错 → `list` 回 `[]`、`get` 回 `undefined` | 同上 |
452
+ | B27 | 出生播种幂等:已有 own 绝不覆盖;工作区不存在时不写 | `tests/conversation.test.mjs` |
453
+ | B28 | 改标签名 → 所有对话快照的表达式同步改写 | 同上 |
454
+ | B29 | 全局删标签 → 对话快照不留悬空 tagId | 同上 |
455
+ | B30 | `installed_at` 首次写入后永不覆盖、跨实例恢复 | 同上 |
456
+ | B31 | 界面:从未配置显示「尚未配置过」;表达式失效显示只读提示 | `tests/__conv-render.mjs` |
426
457
  | B27 | `createUpstreamAdapter` 的扫描/host 各自挂掉时仍返回另一边 | 同上 |
427
458
  | B28 | **新对话首次 `list()` 就必须已过滤**(`ensureFresh` 在读路径上被调用) | 同上(变异验证:去掉则红) |
428
459
  | B29 | `ensureFresh` 抛错时降级沿用旧集合,不冒回宿主 | 同上 |
@@ -208,3 +208,58 @@ mattpocock 的 25 个、archify 的 `archify`。
208
208
  不是模拟模型视野。
209
209
  2. `scope`:设置页没有 agent,只能拿 global 层。当前是「设置页列 global + 自扫描,
210
210
  对话页列该对话 agent 的真实集合」这种分区口径。
211
+ ## 8. 刷新技能列表 / 失效关联(2026-10-31)
212
+
213
+ ### 8.1 症状
214
+
215
+ 把 `mattpocock-skills-dsh` 插件 disable 之后,技能确实从模型视野里消失了,
216
+ 但本插件的界面**仍然认为它们在**,数字也不降。
217
+
218
+ ### 8.2 为什么没自动刷新
219
+
220
+ 两层原因,都不是"缓存没清":
221
+
222
+ 1. **服务端本来就是实时的**。`GET /skills` 每次都重新问宿主 `ctx.skills.snapshot()`
223
+ + 补一次自扫描,没有任何缓存层。连问两次结果完全一致。
224
+ 2. **前端把"幽灵技能"也算进了计数**。`skillLinks` 表里存的是**历史关联记录**,
225
+ 技能消失后这些记录不会自己消失(这是对的:插件只是被临时关闭,不该丢掉
226
+ 用户手工打的标签)。旧实现在 `SkillTagPage` 里把它们并进 `names`:
227
+
228
+ ```js
229
+ // 旧实现:先扫描结果,再补"只存在连接记录"的幽灵
230
+ for (const l of links) if (!seen.has(l.skillId)) names.push({ name: l.skillId, ghost: true });
231
+ // …然后 `技能 (${names.length})` ← 幽灵也被数进去了
232
+ ```
233
+
234
+ 本机实测:真实 82 个 + 幽灵 25 个(全部来自被 disable 的 mattpocock 插件)=
235
+ 界面显示 **107**。所以"刷新按钮"必须同时解决**计数口径**,否则点了数字还是不变。
236
+
237
+ ### 8.3 改动
238
+
239
+ | 位置 | 改动 |
240
+ | --- | --- |
241
+ | `src/api.js` `GET /skills` | 额外返回 `stale: [{skillId, tagIds}]` —— 有标签关联但这次发现不到的技能。**只报告,不删**。 |
242
+ | `src/api.js` `POST /skills/purge-stale` | 只删这些失效关联。发现结果为 **0 时直接 409 拒绝**(`NO_SKILLS`),避免宿主未就绪时把全部关联当成失效。支持 `skillIds` 白名单只删勾选的。 |
243
+ | `lib/client.js` `Section` | 新增 state `stale`;新增 `refreshSkills()`(重新拉 `/state` + `/skills`)与 `purgeStale()`(二次确认后调 purge)。 |
244
+ | `lib/client.js` 主页面 | 导航栏加「刷新技能列表」;有 stale 时顶部橙色提示条 + 「清理失效关联」。 |
245
+ | `lib/client.js` `SkillTagPage` | **计数只算真实技能**:`技能 (N)` + `· 失效 M`;失效项在左列下方单列(带删除线、不显示 📁);选中失效技能时给解释条。 |
246
+
247
+ ### 8.4 为什么不自动删
248
+
249
+ 关联是用户手工打的标签。插件被临时关闭 ≠ 用户放弃这些标签 —— 重新启用后
250
+ 应该恢复。所以:**默认只标记,删除必须显式点击**,且点击时列出前 8 个名字
251
+ 并说明"重新启用插件后需要重新打标签"。
252
+
253
+ ### 8.5 验收
254
+
255
+ ```
256
+ 1. 关掉贡献技能的插件 → 点「刷新技能列表」
257
+ → 「技能 (N)」降到真实值;橙色条显示「有 25 个技能仍有标签关联,但当前发现不到」
258
+ 2. 重新打开插件 → 再点刷新
259
+ → 数字回升,橙色条消失,之前的标签关联**原样恢复**(因为一条都没删)
260
+ 3. 点「清理失效关联」→ 二次确认 → 关联被删,橙色条消失
261
+ → 再打开插件时这些技能不再带标签(预期行为,有文案提示)
262
+ ```
263
+
264
+ 测试:`tests/__page-render.mjs`(6 条渲染断言)+ `tests/conversation.test.mjs`
265
+ (4 条 API 断言,含"GET 不删数据"与"发现为 0 时拒绝执行"两条保护性断言)。
package/lib/client.js CHANGED
@@ -33,7 +33,60 @@ window.__ModuleLoader__.load({
33
33
  return r.json();
34
34
  }
35
35
 
36
- // ── pure view-model helpers (unit-tested via tests/ui.test.mjs) ─
36
+ // ── pure view-model helpers ────────────────────────────────────
37
+ //
38
+ // fmtLocalTime:把服务端存的 ISO-8601(**始终是 UTC,带 Z**)渲染成本地
39
+ // 时间。曾经这里直接 `String(iso).replace("T", " ").slice(0, 19)`——
40
+ // slice(0,19) 把结尾的 Z 连同时区信息一起切掉了,于是 UTC 被当成本地
41
+ // 时间显示,东八区整整偏 8 小时("凌晨 2 点"其实是上午 10 点)。
42
+ // 一律走 Date 解析 + 本地字段拼接,不做字符串截断。
43
+ function fmtLocalTime(iso) {
44
+ if (iso === null || iso === undefined || iso === "") return "";
45
+ const d = new Date(iso);
46
+ if (Number.isNaN(d.getTime())) return String(iso); // 非法值原样显示,不编造时间
47
+ const p = (n) => String(n).padStart(2, "0");
48
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
49
+ + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
50
+ }
51
+
52
+ // ── 失效技能(stale)的判定与分区 ──────────────────────────────
53
+ //
54
+ // 「失效」= 有标签关联,但**这次发现不到**(通常是贡献它的插件被关闭)。
55
+ // 服务端在 GET /skills 里把这类记录放在 stale 数组里报告,不删数据。
56
+ //
57
+ // 三处界面共用这一套判定,保证口径一致:
58
+ // · skill 联系 tag(技能清单):失效项置底 + 标红
59
+ // · 主页面标签列表(TagList):含失效技能的**标签名**标红
60
+ // · tag 联系 skill(左列标签):同上;右列技能列表失效项置底 + 标红
61
+
62
+ /** 失效技能名集合(来自 /skills 的 stale 报告)。 */
63
+ function staleSetOf(state) {
64
+ return new Set((state?.stale ?? []).map((s) => s?.skillId).filter(Boolean));
65
+ }
66
+
67
+ /** 这个标签是否含至少一个失效技能(决定标签名要不要标红)。 */
68
+ function tagHasStale(links, tagId, staleSet) {
69
+ if (!staleSet || staleSet.size === 0) return false;
70
+ return (links ?? []).some((l) => (l.tagIds ?? []).includes(tagId) && staleSet.has(l.skillId));
71
+ }
72
+
73
+ /**
74
+ * 技能名分区:存活在前、失效在后,**各分区内部保持原顺序**。
75
+ * 稳定分区(不是排序),所以用户看到的相对次序不会因为标红而跳动。
76
+ * @param {Array<{skillId:string}|string>} items
77
+ * @param {Set<string>} staleSet
78
+ * @param {(x:any)=>string} nameOf
79
+ */
80
+ function partitionStaleSkills(items, staleSet, nameOf = (x) => (typeof x === "string" ? x : x?.skillId)) {
81
+ const alive = [];
82
+ const stale = [];
83
+ for (const it of items ?? []) (staleSet.has(nameOf(it)) ? stale : alive).push(it);
84
+ return { alive, stale };
85
+ }
86
+
87
+ /** 失效技能的统一配色(与「全局删除」的红一致,但更暗一点,避免抢眼)。 */
88
+ const STALE_COLOR = "#e74c3c";
89
+
37
90
  // zoneOf: §7 four-zone classification of a conversation tag entry.
38
91
  function zoneOf(entry) {
39
92
  if (entry.blocked) return "blocked";
@@ -163,6 +216,9 @@ window.__ModuleLoader__.load({
163
216
  function Section() {
164
217
  const [state, setState] = react.useState({
165
218
  loading: true, error: null, data: null, skills: [],
219
+ // stale = 有标签关联、但当前发现不到的技能(贡献它的插件被关了)。
220
+ // 只标记,不自动删除 —— 用户显式点「清理失效关联」才动数据。
221
+ stale: [],
166
222
  newTagName: "", busy: false, notice: null,
167
223
  // sub-page routing: "main" | "links" | "skilltags" | "templates"
168
224
  view: "main",
@@ -188,6 +244,7 @@ window.__ModuleLoader__.load({
188
244
  error: d?.ok ? null : JSON.stringify(d),
189
245
  data: d?.ok ? d : null,
190
246
  skills: sk?.ok ? sk.skills : [],
247
+ stale: sk?.ok ? (sk.stale ?? []) : [],
191
248
  }));
192
249
  } catch (e) {
193
250
  setState((s) => ({ ...s, loading: false, error: String(e), data: null }));
@@ -209,6 +266,42 @@ window.__ModuleLoader__.load({
209
266
  }
210
267
  }, [reload]);
211
268
 
269
+ /**
270
+ * 刷新技能列表:重新问宿主一次当前真正可见的技能。
271
+ * 用于「贡献技能的插件被关掉/打开」之后同步界面 —— 这类技能不落在任何
272
+ * 文件系统根里,只能靠宿主注册表发现,插件启动后不会自动感知变化。
273
+ */
274
+ const refreshSkills = react.useCallback(async () => {
275
+ setState((s) => ({ ...s, busy: true, notice: null }));
276
+ try {
277
+ await reload();
278
+ setState((s) => ({ ...s, busy: false, notice: `技能列表已刷新:${(s.skills ?? []).length} 个可见技能` }));
279
+ } catch (e) {
280
+ setState((s) => ({ ...s, busy: false, notice: "刷新失败: " + (e?.message ?? e) }));
281
+ }
282
+ }, [reload]);
283
+
284
+ /**
285
+ * 清理失效关联(显式、可取消):只删「有标签、但当前发现不到」的记录。
286
+ * 服务端在发现结果为空时会拒绝执行(避免宿主未就绪时误删全部)。
287
+ */
288
+ const purgeStale = react.useCallback(async () => {
289
+ const list = state.stale ?? [];
290
+ if (list.length === 0) return;
291
+ const names = list.map((s) => s.skillId);
292
+ const preview = names.slice(0, 8).join("\u3001") + (names.length > 8 ? ` 等 ${names.length} 个` : "");
293
+ if (!window.confirm(`清理 ${names.length} 条失效关联?\n\n${preview}\n\n这些技能当前发现不到(通常是贡献它的插件被关闭)。清理后它们的标签关联会删除;若之后重新启用那个插件,需要重新打标签。`)) return;
294
+ setState((s) => ({ ...s, busy: true, notice: null }));
295
+ try {
296
+ const d = await apiPost("/skills/purge-stale", { skillIds: names });
297
+ if (!d?.ok) throw new Error(d?.error ?? JSON.stringify(d));
298
+ await reload();
299
+ setState((s) => ({ ...s, busy: false, notice: `已清理 ${d.count ?? 0} 条失效关联` }));
300
+ } catch (e) {
301
+ setState((s) => ({ ...s, busy: false, notice: "清理失败: " + (e?.message ?? e) }));
302
+ }
303
+ }, [state.stale, reload]);
304
+
212
305
  const { loading, error, data, busy, notice } = state;
213
306
  if (loading) return react.createElement("div", { style: pad }, "加载中…");
214
307
  if (error) return react.createElement("div", { style: pad }, "加载失败: " + error);
@@ -244,7 +337,32 @@ window.__ModuleLoader__.load({
244
337
  react.createElement("button", {
245
338
  style: { ...btn, padding: "6px 16px", fontSize: "13px" },
246
339
  onClick: () => setState((s) => ({ ...s, view: "templates", tplId: null, tplIsNew: false, tplName: "", tplExpression: "" })),
247
- }, "编辑标签组")),
340
+ }, "编辑标签组"),
341
+ // 刷新技能列表:贡献技能的插件被关闭/打开后界面不会自动感知,
342
+ // 这里显式重新问宿主一次(这类技能不落在任何文件系统根里)。
343
+ react.createElement("button", {
344
+ key: "refresh",
345
+ style: { ...btn, padding: "6px 16px", fontSize: "13px" },
346
+ disabled: busy,
347
+ title: "重新向宿主查询当前真正可用的技能(插件被关闭后用它同步列表)",
348
+ "data-skill-tag-refresh": "1",
349
+ onClick: () => void refreshSkills(),
350
+ }, "刷新技能列表")),
351
+ // 失效关联提示条:有标签、但当前发现不到的技能。
352
+ // 只提示不自动删;给一个显式清理入口(点击会二次确认)。
353
+ !loading && (state.stale ?? []).length > 0 && react.createElement("div", {
354
+ key: "stale",
355
+ style: { background: "rgba(230,126,34,0.12)", border: "1px solid rgba(230,126,34,0.4)", borderRadius: "8px", padding: "8px 10px", marginBottom: "12px", fontSize: "12px", color: "#e67e22", display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap" },
356
+ }, [
357
+ react.createElement("span", { key: "t", style: { flex: 1 } },
358
+ `有 ${(state.stale ?? []).length} 个技能仍有标签关联,但当前发现不到(通常是贡献它的插件被关闭)。`),
359
+ react.createElement("button", {
360
+ key: "p",
361
+ style: { ...btn, padding: "2px 10px", fontSize: "12px", color: "#e67e22" },
362
+ disabled: busy,
363
+ "data-skill-tag-purge": "1",
364
+ onClick: () => void purgeStale(),
365
+ }, "清理失效关联")]),
248
366
  react.createElement(TagCreateForm, { key: "create", state, setState, act, busy }),
249
367
  react.createElement(TagList, { key: "list", data, act, busy, state, setState }),
250
368
  ]);
@@ -280,6 +398,8 @@ window.__ModuleLoader__.load({
280
398
  const links = data.skillLinks ?? [];
281
399
  const workspaces = data.workspaces ?? [];
282
400
  const catalog = state.skills ?? [];
401
+ // 失效技能集合:决定"哪些标签名要标红"以及"展开后哪些名字置底标红"。
402
+ const staleSet = staleSetOf(state);
283
403
  // 每个标签默认折叠,只显示数量;点「▸」才展开下面的技能名。
284
404
  // 本机 96 个技能 / 34 个标签,全展开会让这一页变成几百行的墙。
285
405
  const open = state.openTags ?? {};
@@ -292,6 +412,9 @@ window.__ModuleLoader__.load({
292
412
  tags.length === 0 && react.createElement("div", { key: "empty", style: { color: "#888" } }, "暂无标签"),
293
413
  ...tags.map((t) => {
294
414
  const skills = links.filter((l) => l.tagIds.includes(t.tagId)).map((l) => l.skillId);
415
+ // 标签内技能分区:存活在前、失效在后(分区稳定,内部保序)
416
+ const split = partitionStaleSkills(skills, staleSet, (x) => x);
417
+ const hasStale = split.stale.length > 0;
295
418
  const usedBy = workspaces.filter((w) => (w.config?.orTags ?? []).includes(t.tagId) || (w.config?.andGroups ?? []).some((g) => g.tagIds.includes(t.tagId))).map((w) => w.workspaceId);
296
419
  const isOpen = !!open[t.tagId];
297
420
  return react.createElement("div", { key: t.tagId, style: { padding: "4px 0", borderBottom: "1px solid var(--theme-border,#222)" } }, [
@@ -305,12 +428,21 @@ window.__ModuleLoader__.load({
305
428
  "data-skill-tag-toggle": t.tagId,
306
429
  onClick: () => toggleOpen(t.tagId),
307
430
  }, isOpen ? "▾" : "▸"),
308
- react.createElement("span", { key: "n", style: { fontWeight: 500 } }, t.name),
431
+ // 标签名:含失效技能时标红(图二/图三的要求 —— 只标红标签本身,
432
+ // 让用户一眼看出"这个标签关联到了已消失的技能")。
433
+ react.createElement("span", {
434
+ key: "n",
435
+ style: { fontWeight: 500, ...(hasStale ? { color: STALE_COLOR } : {}) },
436
+ title: hasStale ? `该标签含 ${split.stale.length} 个失效技能(当前发现不到)` : undefined,
437
+ "data-skill-tag-stale-tag": hasStale ? t.tagId : undefined,
438
+ }, t.name),
309
439
  react.createElement("span", { key: "id", style: { color: "#888", fontSize: "11px" } }, t.tagId),
310
440
  react.createElement("span", {
311
441
  key: "cnt", style: { color: "#888", fontSize: "11px" },
312
442
  "data-skill-tag-count": t.tagId,
313
- }, skills.length === 0 ? "无 Skill 关联" : `${skills.length} 个 Skill`),
443
+ }, skills.length === 0
444
+ ? "无 Skill 关联"
445
+ : `${skills.length} 个 Skill` + (hasStale ? `(${split.stale.length} 失效)` : "")),
314
446
  react.createElement("button", {
315
447
  key: "del", style: { ...btn, color: "#e74c3c" }, disabled: busy,
316
448
  onClick: () => {
@@ -321,10 +453,18 @@ window.__ModuleLoader__.load({
321
453
  ]),
322
454
  // 展开后才列出技能(每行仍带「打开文件夹」,这里也是"列出 skill 的界面")
323
455
  isOpen && skills.length > 0 && react.createElement("div", { key: "sk", style: { display: "flex", gap: "6px", alignItems: "center", flexWrap: "wrap", fontSize: "11px", color: "#888", padding: "4px 0 2px 22px" } }, [
324
- ...skills.map((sid, i) => react.createElement("span", { key: sid + i, style: { display: "inline-flex", gap: "2px", alignItems: "center" } }, [
456
+ // 存活技能在前
457
+ ...split.alive.map((sid, i) => react.createElement("span", { key: sid + i, style: { display: "inline-flex", gap: "2px", alignItems: "center" } }, [
325
458
  sid,
326
459
  react.createElement(RevealButton, { key: "rev", skillName: sid, path: skillPathOf(catalog, sid), label: "📁", style: { padding: "0 4px", fontSize: "10px" } }),
327
460
  ])),
461
+ // 失效技能置底:标红、删除线、不给 📁(本机没有该文件,点了必然失败)
462
+ ...split.stale.map((sid, i) => react.createElement("span", {
463
+ key: "st" + i,
464
+ style: { display: "inline-flex", gap: "2px", alignItems: "center", color: STALE_COLOR, textDecoration: "line-through" },
465
+ title: "失效技能:当前发现不到(通常是贡献它的插件被关闭)。标签关联仍保留。",
466
+ "data-skill-tag-stale-skill": sid,
467
+ }, [sid])),
328
468
  ]),
329
469
  ]);
330
470
  }),
@@ -399,7 +539,10 @@ window.__ModuleLoader__.load({
399
539
  const sel = tags.find((t) => t.tagId === selId) ?? null;
400
540
  const tagMatch = (t) => !state.tagSearch.trim() || t.name.toLowerCase().includes(state.tagSearch.trim().toLowerCase());
401
541
  const skillMatch = (s) => !state.skillSearch.trim() || s.skillId.toLowerCase().includes(state.skillSearch.trim().toLowerCase());
542
+ // 失效技能集合:标签名标红 + 右列技能列表分区(存活在前、失效置底标红)
543
+ const staleSet = staleSetOf(state);
402
544
  const linkedSkills = skillsLinkedTo(links, selId).filter(skillMatch);
545
+ const linkedSplit = partitionStaleSkills(linkedSkills, staleSet);
403
546
  const createTag = async () => {
404
547
  const name = state.newTagName.trim();
405
548
  if (!name) return;
@@ -436,12 +579,18 @@ window.__ModuleLoader__.load({
436
579
  tags.filter(tagMatch).length === 0 && react.createElement("div", { key: "e", style: { color: "#888", fontSize: "12px", padding: "6px 2px" } }, "无匹配标签"),
437
580
  ...tags.filter(tagMatch).map((t) => {
438
581
  const n = links.filter((l) => l.tagIds.includes(t.tagId)).length;
582
+ // 含失效技能的标签:只把**标签名**标红(图三的要求),其余样式不变
583
+ const hasStale = tagHasStale(links, t.tagId, staleSet);
439
584
  return react.createElement("div", {
440
585
  key: t.tagId, style: t.tagId === selId ? selRowStyle : rowStyle,
441
586
  onClick: () => setState((s) => ({ ...s, linkTagId: t.tagId })),
442
- title: "点击选中",
587
+ title: hasStale ? "该标签含失效技能(当前发现不到),点开可见" : "点击选中",
443
588
  }, [
444
- react.createElement("span", { key: "n", style: { fontWeight: t.tagId === selId ? 600 : 400, flex: 1, cursor: "pointer" } }, t.name),
589
+ react.createElement("span", {
590
+ key: "n",
591
+ style: { fontWeight: t.tagId === selId ? 600 : 400, flex: 1, cursor: "pointer", ...(hasStale ? { color: STALE_COLOR } : {}) },
592
+ "data-skill-tag-stale-tag": hasStale ? t.tagId : undefined,
593
+ }, t.name),
445
594
  react.createElement("span", { key: "c", style: { color: "#888", fontSize: "11px" } }, n ? `${n} skill` : ""),
446
595
  react.createElement("button", {
447
596
  key: "del", style: { ...btn, color: "#e74c3c", padding: "1px 8px" }, disabled: busy,
@@ -479,7 +628,8 @@ window.__ModuleLoader__.load({
479
628
  }),
480
629
  react.createElement("div", { key: "rows", style: { maxHeight: "340px", overflowY: "auto" } }, [
481
630
  linkedSkills.length === 0 && react.createElement("div", { key: "e", style: { color: "#888", fontSize: "12px", padding: "6px 2px" } }, "该标签暂未关联任何 Skill"),
482
- ...linkedSkills.map((s) => {
631
+ // 存活技能在前
632
+ ...linkedSplit.alive.map((s) => {
483
633
  const ghost = !skills.some((x) => x.name === s.skillId);
484
634
  return react.createElement("div", { key: s.skillId, style: rowStyle, title: ghost ? "未在本机扫描到(关联仍生效)" : "" }, [
485
635
  react.createElement("span", { key: "n", style: { flex: 1 } }, s.skillId + (ghost ? " *" : "")),
@@ -490,6 +640,20 @@ window.__ModuleLoader__.load({
490
640
  }, "移除"),
491
641
  ]);
492
642
  }),
643
+ // 失效技能置底:名字标红 + 删除线;不给 📁(本机没有该文件)
644
+ ...linkedSplit.stale.map((s) => react.createElement("div", {
645
+ key: "st" + s.skillId,
646
+ style: { ...rowStyle, opacity: 0.85 },
647
+ title: "失效技能:当前发现不到(通常是贡献它的插件被关闭)。标签关联仍保留。",
648
+ "data-skill-tag-stale-skill": s.skillId,
649
+ }, [
650
+ react.createElement("span", { key: "n", style: { flex: 1, color: STALE_COLOR, textDecoration: "line-through" } }, s.skillId),
651
+ // 仍允许移除(用户可能就是想清掉它)
652
+ react.createElement("button", {
653
+ key: "rm", style: { ...btn, color: "#e67e22", padding: "1px 8px" }, disabled: busy,
654
+ onClick: () => void removeSkill(s.skillId),
655
+ }, "移除"),
656
+ ])),
493
657
  ]),
494
658
  react.createElement("div", { key: "add", style: { display: "flex", gap: "8px", marginTop: "10px", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
495
659
  react.createElement("button", {
@@ -518,11 +682,18 @@ window.__ModuleLoader__.load({
518
682
  const scanned = state.skills ?? [];
519
683
  const selName = state.linkSkillName;
520
684
 
521
- // 技能清单:先扫描结果(按名字),再补上"只存在连接记录"的幽灵技能
685
+ // 技能清单:**只列当前真正存在的技能**(scanned = 这次刷新发现到的)。
686
+ //
687
+ // 2026-10-31 改:此前会把"只存在连接记录"的幽灵技能也算进 `技能 (N)`,
688
+ // 于是关掉贡献技能的插件后,数字仍然不变(本机 82 真 + 25 幽灵 = 107)。
689
+ // 现在幽灵单独归类为 stale,计数只算真技能 —— 用户说的"更新列表和数目"。
522
690
  const names = [];
523
691
  const seen = new Set();
524
692
  for (const s of scanned) { if (s?.name && !seen.has(s.name)) { names.push({ name: s.name, ghost: false }); seen.add(s.name); } }
525
- for (const l of links) { if (!seen.has(l.skillId)) { names.push({ name: l.skillId, ghost: true }); seen.add(l.skillId); } }
693
+ const stale = (state.stale ?? []).filter((s) => s?.skillId && !seen.has(s.skillId));
694
+ const staleIds = new Set(stale.map((s) => s.skillId));
695
+ // 已选中但当前失效的技能:仍然显示,否则右侧会突然空白且没有解释。
696
+ const selIsStale = Boolean(selName && staleIds.has(selName));
526
697
  const match = (n) => !state.skillPickSearch.trim() || n.toLowerCase().includes(state.skillPickSearch.trim().toLowerCase());
527
698
  const shown = names.filter((n) => match(n.name));
528
699
 
@@ -552,12 +723,28 @@ window.__ModuleLoader__.load({
552
723
  react.createElement("button", { key: "back", style: btn, disabled: busy, onClick: () => setState((s) => ({ ...s, view: "main", linkSkillName: null })) }, "← 返回"),
553
724
  react.createElement("h3", { key: "t", style: { margin: 0 } }, "skill 联系 tag"),
554
725
  selName && react.createElement("span", { key: "cur", style: { color: "#888", fontSize: "12px" } }, `当前技能: ${selName}`),
726
+ // 刷新技能列表:插件关停/启用后手动同步(服务端每次都是实时查询,
727
+ // 这里只是把前端那份缓存重新拉一遍)。
728
+ react.createElement("button", {
729
+ key: "refresh",
730
+ style: { ...btn, marginLeft: "auto" },
731
+ disabled: busy,
732
+ title: "重新向宿主查询当前真正可用的技能",
733
+ "data-skill-tag-refresh": "1",
734
+ onClick: () => void refreshSkills(),
735
+ }, "刷新技能列表"),
555
736
  ]),
556
737
  notice && react.createElement("div", { key: "n", style: noteStyle }, notice),
738
+ // 选中了一个当前已经失效的技能:右侧标签仍可看/可改,但要讲清原因。
739
+ selIsStale && react.createElement("div", {
740
+ key: "staleSel",
741
+ style: { background: "rgba(230,126,34,0.12)", border: "1px solid rgba(230,126,34,0.4)", borderRadius: "8px", padding: "6px 10px", marginBottom: "10px", fontSize: "12px", color: "#e67e22" },
742
+ }, `「${selName}」当前发现不到(通常是贡献它的插件被关闭)。标签关联仍保留,重新启用插件后自动恢复可见。`),
557
743
  react.createElement("div", { key: "cols", style: { display: "flex", gap: "12px", alignItems: "flex-start" } }, [
558
744
  // ── 左列:技能 ──────────────────────────────────────────
559
745
  react.createElement("div", { key: "left", style: colStyle }, [
560
- react.createElement("div", { key: "lt", style: { fontWeight: 600, marginBottom: "6px" } }, `技能 (${names.length})`),
746
+ react.createElement("div", { key: "lt", style: { fontWeight: 600, marginBottom: "6px" } },
747
+ `技能 (${names.length})` + (stale.length ? ` · 失效 ${stale.length}` : "")),
561
748
  react.createElement("input", {
562
749
  key: "search", style: { ...input, width: "100%", marginBottom: "6px" }, placeholder: "搜索技能…",
563
750
  value: state.skillPickSearch, onChange: (e) => setState((s) => ({ ...s, skillPickSearch: e.target.value })),
@@ -578,6 +765,31 @@ window.__ModuleLoader__.load({
578
765
  ]);
579
766
  }),
580
767
  ]),
768
+ // 失效技能单列:只在有标签关联、但当前发现不到时出现。
769
+ // 位置固定在最底部(图一的要求),名字标红 + 删除线;
770
+ // 计数不算进上面的「技能 (N)」;默认不删,给一个显式清理入口。
771
+ stale.length > 0 && react.createElement("div", { key: "staleBox", style: { marginTop: "10px", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
772
+ react.createElement("div", { key: "h", style: { display: "flex", alignItems: "center", gap: "8px", fontSize: "12px", color: "#e67e22", marginBottom: "4px" } }, [
773
+ react.createElement("span", { key: "t", style: { flex: 1 } }, `失效 ${stale.length} 个(有标签关联,但当前发现不到)`),
774
+ react.createElement("button", {
775
+ key: "p",
776
+ style: { ...btn, padding: "1px 8px", fontSize: "11px", color: "#e67e22" },
777
+ disabled: busy,
778
+ "data-skill-tag-purge": "1",
779
+ onClick: () => void purgeStale(),
780
+ }, "清理失效关联")]),
781
+ ...stale.slice(0, 30).map((s) => react.createElement("div", {
782
+ key: s.skillId,
783
+ style: { ...rowStyle, cursor: "pointer" },
784
+ title: "失效技能:当前发现不到(通常是贡献它的插件被关闭)。点击查看它保留的标签关联。",
785
+ "data-skill-tag-stale-skill": s.skillId,
786
+ onClick: () => setState((st) => ({ ...st, linkSkillName: s.skillId })),
787
+ }, [
788
+ react.createElement("span", { key: "n", style: { flex: 1, color: STALE_COLOR, textDecoration: "line-through" } }, s.skillId),
789
+ react.createElement("span", { key: "c", style: { fontSize: "11px" } }, `${(s.tagIds ?? []).length} tag`),
790
+ ])),
791
+ stale.length > 30 && react.createElement("div", { key: "more", style: { color: "#888", fontSize: "11px", padding: "2px 4px" } }, `…还有 ${stale.length - 30} 个`),
792
+ ]),
581
793
  ]),
582
794
  // ── 右列:该技能的标签 ──────────────────────────────────
583
795
  react.createElement("div", { key: "right", style: colStyle }, [
@@ -1222,6 +1434,10 @@ window.__ModuleLoader__.load({
1222
1434
 
1223
1435
  const inheritedFrom = ws?.sourceWorkspaceId;
1224
1436
  const inheritedAt = ws?.inheritedAt;
1437
+ // configured:该对话是否有自己的配置快照(own)。false = 从未配置过。
1438
+ // expressionIssue:服务端只读体检结果,用于提示「引用了已删除的标签」。
1439
+ const configured = ws?.configured;
1440
+ const expressionIssue = ws?.expressionIssue ?? null;
1225
1441
 
1226
1442
  const body = () => {
1227
1443
  if (loading) return react.createElement("div", { style: { color: "#888", padding: "20px 0" } }, "加载中…");
@@ -1237,11 +1453,19 @@ window.__ModuleLoader__.load({
1237
1453
  "解析结果: " + parsed),
1238
1454
  react.createElement("div", { key: "st", style: { color: "#888", fontSize: "11px", marginTop: "2px" } },
1239
1455
  `状态: ${view.hasFilter ? "有条件筛选(严格子集)" : "无筛选(全部技能)"}`),
1240
- // 继承状态:让用户知道"这份条件是从哪来的、什么时候来的"
1456
+ // 继承状态:让用户知道「这份条件是从哪来的、什么时候来的」
1457
+ // configured=false(own 为 null)= 从未配置过 = 空条件 = 掌握全部技能。
1458
+ // 这不是「继承来源 null」,而是「你还没配过」,所以单独一条文案。
1241
1459
  scope.isConv && react.createElement("div", { key: "inh", style: { color: "#888", fontSize: "11px", marginTop: "2px" } },
1242
- inheritedFrom
1243
- ? `继承来源: ${inheritedFrom}${inheritedAt ? " · " + String(inheritedAt).replace("T", " ").slice(0, 19) : ""}(改动不影响工作区)`
1244
- : "未继承任何工作区(当前为该对话独立配置)"),
1460
+ configured === false
1461
+ ? "尚未配置过(未继承工作区;当前为空条件 = 掌握全部技能)"
1462
+ : (inheritedFrom
1463
+ ? `继承来源: ${inheritedFrom}${inheritedAt ? " · " + fmtLocalTime(inheritedAt) : ""}(改动不影响工作区)`
1464
+ : "该对话有独立配置(未继承工作区)")),
1465
+ // 只读提示:已冻结的表达式引用了不存在的标签(旧版 bug 遗留)。
1466
+ // 不自动修复 —— 数据是用户意志,告诉他原因即可。
1467
+ scope.isConv && expressionIssue && !expressionIssue.ok && react.createElement("div", { key: "isq", style: { color: "#e67e22", fontSize: "11px", marginTop: "2px" } },
1468
+ `⚠ 表达式引用了已不存在的标签(${expressionIssue.message})。点「tag 设置」可重新选择;保存前需修正。`),
1245
1469
  react.createElement("div", { key: "sp", style: { flex: 1 } }),
1246
1470
  react.createElement("div", { key: "f", style: { display: "flex", gap: "8px", flexWrap: "wrap", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
1247
1471
  react.createElement("button", {
@@ -1681,6 +1905,8 @@ window.__ModuleLoader__.load({
1681
1905
  workspaceIdOfRowKey, isWorkspaceMenu, manage, manageSet,
1682
1906
  manageScope, registerComposerEntry,
1683
1907
  skillPathOf, RevealButton, ownedTagsFirst,
1908
+ // 失效技能判定(三处界面共用同一套口径,单独测)
1909
+ staleSetOf, tagHasStale, partitionStaleSkills, STALE_COLOR,
1684
1910
  };
1685
1911
  return module.exports;
1686
1912
  },
package/lib/index.js CHANGED
@@ -294,24 +294,40 @@ export function computeDeniedSkillIds(wsConfig, allLinkedSkillIds, computeCandid
294
294
  /**
295
295
  * 运行时生效口径:这个会话到底按哪份配置筛技能?
296
296
  *
297
- * own 存在 → 用对话自己的(「对话 → 管理标签」的独立配置)
298
- * own 为空 → 退回所属工作区的(历史对话 / 从未配置过)
299
- * 两者都没有 → null(不筛)
297
+ * own 存在 → 用它(「对话 → 管理标签」的独立配置,已冻结)
298
+ * own 为空 → **空配置 = 掌握全部技能**,绝不回退到工作区实时配置
300
299
  *
301
- * own 一旦存在就是权威,哪怕它是"空快照":那代表用户已经把这份配置与工作区
302
- * 解耦了(新建时继承过、或手动清空过),此时再跟着工作区漂移就是错的。
300
+ * ## 为什么不再回退工作区(2026-10-31 起的统一口径)
301
+ *
302
+ * 旧实现是 `pick(own) ?? pick(ws)`:own 为 null 的对话会实时跟着工作区走。
303
+ * 而 own 只有「打开过管理标签」或「点过继承」才会产生,于是同一个操作
304
+ * "改工作区标签"在不同对话上表现完全不同:
305
+ *
306
+ * - 有 own(历史点开过)→ 冻结,不变;
307
+ * - 无 own(从没点开过)→ 实时跟随,跟着改 ← 用户报告的 bug。
308
+ *
309
+ * 用户口径(三种对话):
310
+ * ① 插件安装前就存在 → 未配置过 = 空 = 全部技能,改工作区不影响
311
+ * ② 刚创建的新对话 → 出生时继承一次工作区(own 被写入)
312
+ * ③ 安装后创建、已存在一阵 → ②的 own 已冻结,工作区再改不影响
313
+ *
314
+ * 三者共同点:**own 一旦存在即权威;own 为 null 即"没有筛选"**。
315
+ * 工作区配置退化为「新建对话的初始模板 + 继承按钮的数据源」,不再实时下发。
303
316
  *
304
317
  * @param {{config?:object, manualSkills?:string[], disabledSkills?:string[]}|null} own
305
- * @param {{config?:object, manualSkills?:string[], disabledSkills?:string[]}|null} ws
306
- * @returns {{config:object, manualSkills:string[], disabledSkills:string[]}|null}
318
+ * @param {object|null} [_ws] 已废弃,仅为兼容既有调用签名;**不再参与计算**
319
+ * @returns {{config:object, manualSkills:string[], disabledSkills:string[]}}
307
320
  */
308
- export function effectiveSkillsConfig(own, ws) {
309
- const pick = (src) => src && {
310
- config: src.config ?? { orTags: [], andGroups: [] },
311
- manualSkills: src.manualSkills ?? [],
312
- disabledSkills: src.disabledSkills ?? [],
321
+ export function effectiveSkillsConfig(own, _ws = null) {
322
+ if (!own) {
323
+ // 未配置过 → 空条件 → 全部技能(workspaceSkillView 对空 config 返回 universe)
324
+ return { config: { orTags: [], andGroups: [] }, manualSkills: [], disabledSkills: [] };
325
+ }
326
+ return {
327
+ config: own.config ?? { orTags: [], andGroups: [] },
328
+ manualSkills: own.manualSkills ?? [],
329
+ disabledSkills: own.disabledSkills ?? [],
313
330
  };
314
- return pick(own) ?? pick(ws) ?? null;
315
331
  }
316
332
 
317
333
  /**
@@ -583,6 +599,14 @@ export async function apply(ctx, config) {
583
599
  const services = createServices(repo, { log: (m) => ctx.logger?.info?.(m) });
584
600
  await services.ready();
585
601
 
602
+ // 里程碑:本版**首次运行**的时刻。三种对话口径的判定基准(见 maybeSeedOwn)。
603
+ // 幂等且永不覆盖:升级/重启不会把老对话误判成"新出生"。
604
+ try {
605
+ await services.meta.ensureInstalledAt();
606
+ } catch (error) {
607
+ ctx.logger?.warn?.(`[skill-tag] installed_at not recorded: ${error?.message ?? error}`);
608
+ }
609
+
586
610
  // 3. Session→denied-skill-names live map. Denials derive from the boolean
587
611
  // config + per-conversation four-zone state (§11). Recomputed on demand.
588
612
  const sessionDenials = new Map(); // sessionId -> Set<string>
@@ -647,12 +671,73 @@ export async function apply(ctx, config) {
647
671
  if (refreshing.has(sessionId)) return;
648
672
  refreshing.add(sessionId);
649
673
  try {
674
+ // 先判「是不是新出生的对话」并播种(只写一次);再算拒绝集。
675
+ // 播种会写库 → 触发 domain/changed → 下一轮 recomputeDenials 用上 own。
676
+ await maybeSeedOwn(sessionId, cwd, scope);
650
677
  await recomputeDenials(sessionId, cwd, scope);
651
678
  } finally {
652
679
  refreshing.delete(sessionId);
653
680
  }
654
681
  }
655
682
 
683
+
684
+ /**
685
+ * 会话的创建时刻(毫秒)。宿主保证 session.header 恒存在且 createdAt 是
686
+ * 非负安全整数(dsh-session/lib/types 校验),所以读不到只可能是极早期
687
+ * 的空壳 agent —— 那种情况一律当作「看不了」,不播种(不写数据最安全)。
688
+ * @returns {number|null}
689
+ */
690
+ function sessionCreatedAt(agent) {
691
+ try {
692
+ const v = agent?.session?.header?.createdAt;
693
+ if (typeof v === "number" && Number.isFinite(v)) return v;
694
+ if (typeof v === "string") {
695
+ const t = Date.parse(v);
696
+ if (Number.isFinite(t)) return t;
697
+ }
698
+ } catch { /* header 取不到就当未知 */ }
699
+ return null;
700
+ }
701
+
702
+ /**
703
+ * 「出生播种」:**只在新对话的第一次被观察到时**继承一次工作区配置。
704
+ *
705
+ * 三种对话口径(2026-10-31 用户确认)都靠这一个函数落地:
706
+ * ① 插件安装前就存在的对话 → createdAt < installed_at → 直接返回,
707
+ * own 永远保持 null → 空条件 → 掌握全部技能;
708
+ * ② 刚创建的新对话 → createdAt >= installed_at 且 own 为 null → 播种一次;
709
+ * ③ 安装后创建、已存在一阵的对话 → 出生时已播过种,own 存在 → 永不重播。
710
+ *
711
+ * 为什么用 createdAt 而不是 agent/created 事件:宿主对**恢复**已有会话同样
712
+ * 发 agent/created(dsh-agent 的 announce(agent, startup) 是唯一路径),
713
+ * 事件本身区分不了「新建」和「打开旧对话」。用 header 里的出生时刻才可靠。
714
+ *
715
+ * 幂等:seedOwnOnBirth 内部再看一次 own,已有配置绝不覆盖。
716
+ */
717
+ async function maybeSeedOwn(sessionId, cwd, scope) {
718
+ if (!sessionId || !enabled) return;
719
+ try {
720
+ const installed = services.meta.installedAt();
721
+ if (!installed) return; // 里程碑还没写(极早期),下一轮再说
722
+ const bornAt = sessionCreatedAt(scope);
723
+ if (bornAt === null) return;
724
+ const installedMs = Date.parse(installed);
725
+ if (!Number.isFinite(installedMs)) return;
726
+ if (bornAt < installedMs) return; // ① 安装前就存在 → 永不播种
727
+ if (services.conversations.isConfigured(sessionId)) return; // ③ 已冻结
728
+ // 工作区解析:解析不到就先不写(留空 = 全部技能,语义与「没播」一致,
729
+ // 而且不会把对话钉死)。宿主是在会话跑起来**之后**才 attachSession 的,
730
+ // 出生瞬间常常还查不到,所以这里宁可等下一轮。
731
+ const registry = getWorkspaceRegistry(ctx);
732
+ const wsId = workspaceIdOfSession(registry, sessionId) ?? workspaceIdOfPath(registry, cwd) ?? null;
733
+ if (!wsId) return;
734
+ const seeded = await services.conversations.seedOwnOnBirth(sessionId, wsId);
735
+ if (seeded) ctx.logger?.info?.(`[skill-tag] own seeded on birth session=${sessionId} workspace=${wsId}`);
736
+ } catch (error) {
737
+ try { ctx.logger?.warn?.(`[skill-tag] maybeSeedOwn failed: ${error?.message ?? error}`); } catch { /* ignore */ }
738
+ }
739
+ }
740
+
656
741
  async function recomputeDenials(sessionId, cwd, scope = null) {
657
742
  const denied = deniedFor(sessionId);
658
743
  const before = [...denied].join(",");
@@ -666,27 +751,15 @@ export async function apply(ctx, config) {
666
751
  conv = services.conversations.get(convId);
667
752
  }
668
753
  const own = conv?.own ?? null;
669
- const registry = getWorkspaceRegistry(ctx);
670
- // 解析顺序(2026-10-20 补第 3 档):
671
- // 1. 对话自己记的继承来源——最权威,用户点过「继承」/新建时播种的
672
- // 2. 宿主 registry 的 sessionIds 反查
673
- // 3. **cwd → workspace.path 反查**(新加)
674
- // 4. 对话记录里的 inherited.workspaceId(旧数据兜底)
754
+ // 生效口径(2026-10-31 统一):**只看 own,永不回退到工作区实时配置**。
755
+ //
756
+ // own 存在 → 该对话自己的冻结快照;
757
+ // own 为 null → 从未配置过 = 空条件 = 掌握全部技能。
675
758
  //
676
- // 第 3 档是修「第一条消息不受控」的关键:宿主是在会话跑起来之后才
677
- // `attachSession` 把 id 写进 workspace.sessionIds 的,而 agent 第一次
678
- // `list()` 时那个 id 还不在里面 → 第 2 档返回 null → effective 为 null
679
- // → 全部技能放行。cwd 在第一轮就已经可靠(provider options.cwd),
680
- // 所以用路径兜底能把第一轮也纳管。
681
- const wsId = own?.sourceWorkspaceId
682
- ?? workspaceIdOfSession(registry, sessionId)
683
- ?? workspaceIdOfPath(registry, cwd)
684
- ?? conv?.inherited?.workspaceId
685
- ?? null;
686
- const ws = wsId ? services.workspaces.get(wsId) : null;
687
- // 生效口径:对话自己的快照优先(own 存在即代表该对话有独立配置,
688
- // 即使是"空快照"也是用户选择过/新建时继承过的结果);否则退回工作区。
689
- const effective = effectiveSkillsConfig(own, ws);
759
+ // 工作区只在两个地方还参与:出生播种(maybeSeedOwn)与「继承」按钮。
760
+ // 所以这里不再解析 ws,也不再需要 wsId。
761
+ const effective = effectiveSkillsConfig(own);
762
+ // 空配置 = 不筛 = denied 为空;仍然走一遍(禁用列表非空时还要生效)。
690
763
  if (effective) {
691
764
  let universe = [];
692
765
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-tag",
3
- "version": "0.1.8",
3
+ "version": "0.1.12",
4
4
  "description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/api.js CHANGED
@@ -158,6 +158,20 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
158
158
  };
159
159
  }
160
160
 
161
+ /**
162
+ * 该对话已保存的表达式是否仍然有效(引用的标签是否还存在)。
163
+ * 只用于**提示**,绝不修改数据:被冻结的历史快照可能引用了已删除/改名的
164
+ * 标签,用户有权保留原样,但保存时必须能看到原因。
165
+ * @returns {{ok:boolean, message:string|null}}
166
+ */
167
+ function ownExpressionIssue(c) {
168
+ const expr = String(c?.expression ?? "");
169
+ if (expr.trim() === "") return { ok: true, message: null };
170
+ const v = services.templates.validate(expr, { allowEmpty: true });
171
+ if (v.ok) return { ok: true, message: null };
172
+ return { ok: false, message: v.errors?.[0]?.message ?? "表达式非法" };
173
+ }
174
+
161
175
  /**
162
176
  * 对话版双列界面的统一载荷(与 /workspaces/skills 同形,前端复用同一个面板)。
163
177
  */
@@ -172,12 +186,18 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
172
186
  ok: true,
173
187
  conversationId,
174
188
  workspaceId,
189
+ // configured=false 表示该对话**从未配置过**(own 为 null):
190
+ // 语义是「空 = 掌握全部技能」,且界面上不该显示成"继承来源为 null"。
191
+ configured: services.conversations.isConfigured(conversationId),
175
192
  expression: c?.expression ?? "",
176
193
  config: c?.config ?? { orTags: [], andGroups: [] },
177
194
  manualSkills: c?.manualSkills ?? [],
178
195
  disabledSkills: c?.disabledSkills ?? [],
179
196
  inheritedAt: c?.inheritedAt ?? null,
180
197
  sourceWorkspaceId: c?.sourceWorkspaceId ?? null,
198
+ // 只读提示:已冻结的表达式引用了不存在的标签(旧版 bug 遗留)。
199
+ // 不自动修复 —— 数据是用户意志,界面上告诉他原因即可。
200
+ expressionIssue: ownExpressionIssue(c),
181
201
  candidates: ctxInfo.candidates,
182
202
  universe: ctxInfo.universe,
183
203
  catalog: ctxInfo.catalog,
@@ -209,7 +229,46 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
209
229
  workspaceId: q("workspaceId") ?? null,
210
230
  })) ?? []
211
231
  : [];
212
- return json(res, 200, { ok: true, skills });
232
+ // stale = 有标签关联、但这次发现不到的技能(通常是贡献它的插件被关了)。
233
+ // 只**报告**,不删:见 /skills/purge-stale 的注释。
234
+ const live = new Set(skills.map((s) => (typeof s === "string" ? s : s?.name)));
235
+ const stale = [...services._caches.skillLinks.values()]
236
+ .filter((l) => !live.has(l.skillId))
237
+ .map((l) => ({ skillId: l.skillId, tagIds: [...(l.tagIds ?? [])] }));
238
+ return json(res, 200, { ok: true, skills, stale });
239
+ }
240
+ // ── 「刷新技能列表」+「清理失效关联」 ─────────────────────────────
241
+ //
242
+ // 场景:某些技能由**别的插件**贡献(provider 注册进 ctx.skills,根本不落在
243
+ // 任何文件系统根里)。那个插件被 disable 之后,技能立刻消失,但插件这边
244
+ // 的 skillLinks 记录还留着 —— 界面上就表现为"技能还在、数字偏大"。
245
+ //
246
+ // 这里的口径:
247
+ // GET /skills → 实时问宿主,顺带算出 stale(失效关联)
248
+ // POST /skills/purge-stale → 只删这些失效关联记录(用户在界面上显式点)
249
+ //
250
+ // 为什么不在 GET 里顺手删:关联是用户手工打的标签,插件的临时缺席不等于
251
+ // 用户放弃它。默认只标记,删除必须显式触发。
252
+ if (path === "/skills/purge-stale" && method === "POST") {
253
+ const live = new Set(
254
+ ((listSkills ? await listSkills(q("cwd") ?? undefined, {
255
+ conversationId: q("conversationId") ?? null,
256
+ workspaceId: q("workspaceId") ?? null,
257
+ }) : []) ?? []).map((s) => (typeof s === "string" ? s : s?.name)),
258
+ );
259
+ // 发现结果为空 = 宿主没就绪,此时"全是失效"是误判,绝不动数据。
260
+ if (live.size === 0) {
261
+ return json(res, 409, { ok: false, code: "NO_SKILLS", error: "当前发现到 0 个技能,拒绝清理(可能是宿主未就绪)" });
262
+ }
263
+ const wanted = Array.isArray(body?.skillIds) ? new Set(body.skillIds.map(String)) : null;
264
+ const removed = [];
265
+ for (const link of [...services._caches.skillLinks.values()]) {
266
+ if (live.has(link.skillId)) continue; // 还活着 → 不动
267
+ if (wanted && !wanted.has(link.skillId)) continue; // 用户只勾了部分
268
+ removed.push(link.skillId);
269
+ }
270
+ for (const skillId of removed) await services.tags.unlinkSkill(skillId);
271
+ return json(res, 200, { ok: true, removed, count: removed.length });
213
272
  }
214
273
  // ── 「打开技能所在文件夹」 ─────────────────────────────────────
215
274
  // 宿主没有暴露 shell/reveal 能力,由 src/reveal.js 自己拉起文件管理器。
@@ -451,8 +510,10 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
451
510
  if (path === "/conversations/config" && (method === "GET" || method === "POST")) {
452
511
  const cid = q("conversationId");
453
512
  const wid = workspaceIdForConversation(cid);
454
- // 历史对话第一次打开:用所属工作区播种一份快照(「默认继承」只发生一次)
455
- await services.conversations.ensureOwn(cid, wid);
513
+ // 打开「管理标签(对话)」是**纯读**:只保证对话行存在,绝不播种 own。
514
+ // (旧实现在这里用当前工作区播种,导致"看一眼就被冻结",且冻结值
515
+ // 取决于点开的先后顺序 —— 是「改工作区标签波及部分对话」的根因之一。)
516
+ await services.conversations.ensureOwn(cid);
456
517
  return json(res, 200, await convPayload(cid, wid));
457
518
  }
458
519
  if (path === "/conversations/inherit" && method === "POST") {
package/src/service.js CHANGED
@@ -48,6 +48,7 @@ export function createServices(repo, opts = {}) {
48
48
  const templates = new Map();
49
49
  const workspaces = new Map();
50
50
  const conversations = new Map(); // conversationId -> live ConversationState
51
+ const syncMeta = new Map(); // key -> {key, rev, updatedAt}
51
52
 
52
53
  async function loadAll() {
53
54
  for (const [k, v] of await repo.listTags()) tags.set(k, v);
@@ -55,6 +56,7 @@ export function createServices(repo, opts = {}) {
55
56
  for (const [k, v] of await repo.listTemplates()) templates.set(k, v);
56
57
  for (const [k, v] of await repo.listWorkspaces()) workspaces.set(k, v);
57
58
  for (const [k, v] of await repo.listConversations()) conversations.set(k, deserializeConversation(v));
59
+ for (const [k, v] of await repo.listSyncMeta()) syncMeta.set(k, v);
58
60
  // 旧数据回填:历史模板只有 config、没有 expression —— 反推出表达式并回写,
59
61
  // 保证"再次编辑必须回显"对存量数据同样成立。
60
62
  try {
@@ -72,6 +74,50 @@ export function createServices(repo, opts = {}) {
72
74
  }
73
75
  }
74
76
 
77
+ // ── 对话快照(own)的联动维护 ────────────────────────────────────────
78
+ //
79
+ // own 是"冻结的配置",但**标签实体本身**是全局的:改名/删除标签必须同步
80
+ // 写回 own,否则冻结住的表达式会指向一个不存在的标签名,用户下次打开
81
+ // 「tag 设置」就会看到"标签不存在",且保存被拒。
82
+ //
83
+ // 注意这**不是**"自动继承工作区":改的是标签的名字/存废,不是条件本身。
84
+ // 条件(哪些标签、手动列表、禁用列表)一律保持调用方原样。
85
+
86
+ /**
87
+ * 对每个带 own 的对话快照应用一个纯变换,有实际变化才落库。
88
+ * @param {(own: object) => string|null} transform 返回新表达式或 null 表示不改
89
+ */
90
+ async function renameTagInOwnConfigs(transform) {
91
+ for (const [cid, conv] of conversations) {
92
+ if (!conv.own) continue;
93
+ const next = transform(conv.own);
94
+ if (typeof next !== "string" || next === conv.own.expression) continue;
95
+ conv.own.expression = next;
96
+ await repo.putConversation(serializeConversation(conv));
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 全局删除标签后,把该标签从每个对话快照的 config/expression 里摘掉。
102
+ * 与 workspaces 的处理保持一致(domainRemoveTagEntirely + removeTagFromExpression),
103
+ * 差别只是数据挂在 own 上。
104
+ */
105
+ async function dropTagFromOwnConfigs(tagId, removedName) {
106
+ for (const [cid, conv] of conversations) {
107
+ if (!conv.own) continue;
108
+ let changed = false;
109
+ if (conv.own.config) {
110
+ domainRemoveTagEntirely(conv.own.config, tagId);
111
+ changed = true;
112
+ }
113
+ if (removedName && conv.own.expression) {
114
+ const next = removeTagFromExpression(conv.own.expression, removedName);
115
+ if (next !== conv.own.expression) { conv.own.expression = next; changed = true; }
116
+ }
117
+ if (changed) await repo.putConversation(serializeConversation(conv));
118
+ }
119
+ }
120
+
75
121
  // ── TagStore (spec §4) ───────────────────────────────────────────────────
76
122
  const tagStore = {
77
123
  async create(tagId, name) {
@@ -113,6 +159,10 @@ export function createServices(repo, opts = {}) {
113
159
  await repo.putWorkspace(ws);
114
160
  }
115
161
  }
162
+ // 对话自己的快照(own)也按名称引用 → 必须一并改写。
163
+ // 2026-10-31 补:此前只写 templates/workspaces,冻结的 own 会留下
164
+ // 指向旧名的表达式,下次打开「tag 设置」直接报"标签不存在"。
165
+ await renameTagInOwnConfigs((own) => renameTagInExpression(own.expression ?? "", oldName, newName));
116
166
  }
117
167
  },
118
168
  list() { return [...tags.values()]; },
@@ -167,6 +217,11 @@ export function createServices(repo, opts = {}) {
167
217
  }
168
218
  await repo.putWorkspace(ws);
169
219
  }
220
+ // 对话自己的快照(own):同样摘除该标签。
221
+ // 2026-10-31 补:此前漏了这一段,导致 own.config 里残留已经删除的
222
+ // tagId(本机实测 3 条),而 hasFilter 仍为 true、candidates 变空
223
+ // → 那条对话会把**全部技能**都屏蔽掉,且界面上看不出原因。
224
+ await dropTagFromOwnConfigs(tagId, removedName);
170
225
  // conversations: tag state + OR/AND + inherited groups + block/exempt records
171
226
  for (const [cid, conv] of conversations) {
172
227
  const removed = domainRemoveTagEntirely(conv.config, tagId);
@@ -177,7 +232,7 @@ export function createServices(repo, opts = {}) {
177
232
  }
178
233
  pruneGroups(conv.inherited.groups);
179
234
  conv.inherited.groups = conv.inherited.groups.filter((g) => new Set(g.tagIds).size >= 2);
180
- await repo.putConversation(serializeConversation({ ...conv, conversationId: cid }));
235
+ await repo.putConversation(serializeConversation(conv));
181
236
  }
182
237
  tags.delete(tagId);
183
238
  await repo.deleteTag(tagId);
@@ -553,20 +608,53 @@ export function createServices(repo, opts = {}) {
553
608
  return new Map([...tags.values()].map((t) => [t.tagId, t.name]));
554
609
  }
555
610
 
611
+ // ── MetaService:插件级里程碑(当前只有 installed_at)────────────────
612
+ //
613
+ // installed_at = 本版**首次运行**的时刻,一旦写下永不改变。
614
+ // 三种对话的判定依赖它(见 lib/index.js seedOwnOnBirth)。
615
+ const INSTALLED_AT_KEY = "installed_at";
616
+ const metaService = {
617
+ /** 读已记录的安装时刻(ISO 字符串),没有则 null。 */
618
+ installedAt() {
619
+ return syncMeta.get(INSTALLED_AT_KEY)?.value ?? null;
620
+ },
621
+ /** 首次运行时写入;已有则原样返回(幂等,永不覆盖)。 */
622
+ async ensureInstalledAt(now = new Date().toISOString()) {
623
+ const existing = syncMeta.get(INSTALLED_AT_KEY);
624
+ if (existing?.value) return existing.value;
625
+ const rec = { key: INSTALLED_AT_KEY, rev: 1, updatedAt: now, value: now };
626
+ syncMeta.set(INSTALLED_AT_KEY, rec);
627
+ await repo.putSyncMeta(rec);
628
+ appLog?.(`[skill-tag] installed_at recorded: ${now}`);
629
+ return now;
630
+ },
631
+ };
632
+
556
633
 
557
634
  // ── ConversationService (spec §7/§8) ─────────────────────────────────────
635
+ //
636
+ // 「own 只写一次」不变量(2026-10-31 统一口径):
637
+ //
638
+ // 一条对话的 own 快照只允许在**两个**时刻产生:
639
+ // ① 出生(新建对话 / 首次观察到 createdAt ≥ installedAt 的会话)→ 继承一次
640
+ // ② 用户点「继承工作区配置」→ 覆盖
641
+ //
642
+ // 除此之外永不自动写入。尤其:**打开「管理标签(对话)」不是写入时机**
643
+ // (旧实现会在打开瞬间用当前工作区播种,导致"看一眼就被冻结",且冻结值
644
+ // 取决于看的先后顺序 —— 这就是 0.1.8 之前「改工作区标签有些对话会被改」
645
+ // 的根因之一)。
646
+ //
647
+ // own === null 的语义固定为「从未配置过 = 空 = 掌握全部技能」,运行时
648
+ // 绝不再回退到工作区实时配置(见 lib/index.js effectiveSkillsConfig)。
558
649
  const conversationService = {
559
- /** §8.1 create-with-workspace init */
650
+ /** 新建对话(出生):绑工作区则继承一次。 */
560
651
  async create(conversationId, workspaceId = null) {
561
652
  const conv = createConversationState();
562
653
  conv.conversationId = conversationId;
563
- conv.own = emptyOwnConfig();
564
654
  if (workspaceId) {
565
655
  const ws = workspaces.get(workspaceId);
566
656
  if (!ws) throw new Error(`workspace '${workspaceId}' not found`);
567
657
  initFromWorkspace(conv, ws.config, workspaceId);
568
- // 新创建对话 = 自动继承工作区配置(用户口径:新建时才自动继承,
569
- // 之后工作区再改不会波及本对话,要重新同步必须点「继承工作区配置」)
570
658
  conv.own = ownConfigFromWorkspace(ws);
571
659
  }
572
660
  conversations.set(conversationId, conv);
@@ -576,26 +664,43 @@ export function createServices(repo, opts = {}) {
576
664
  get(conversationId) { return conversations.get(conversationId); },
577
665
 
578
666
  /**
579
- * 取(必要时创建)对话的配置快照 —— 历史行没有 own 字段。
580
- *
581
- * seedWorkspaceId:历史对话第一次被打开时,用它绑定的工作区**播种**一份
582
- * 快照(= 用户口径里的「默认继承」)。播种只发生一次:之后工作区再改,
583
- * 本对话不受影响;要重新同步只能点「继承工作区配置」按钮。
667
+ * 取(必要时**建行**)对话记录。**不写 own**:own 只由出生播种与
668
+ * 「继承工作区配置」写。历史对话第一次被打开时 own 仍是 null(= 全部技能),
669
+ * 直到用户显式继承或编辑。
584
670
  */
585
- async ensureOwn(conversationId, seedWorkspaceId = null) {
586
- // 播种只在「新建这一行」时有意义:先看能不能拿到工作区记录,
587
- // 拿不到就别硬绑(避免 create 抛错),退回空快照。
588
- const seed = seedWorkspaceId ? workspaces.get(seedWorkspaceId) ?? null : null;
671
+ async ensureOwn(conversationId) {
589
672
  let conv = conversations.get(conversationId);
590
- // 与 /conversations/ensure 语义一致:不存在就建一个对话
591
- if (!conv) conv = await this.create(conversationId, seed ? seedWorkspaceId : null);
592
- if (!conv.own) {
593
- conv.own = seed ? ownConfigFromWorkspace(seed) : emptyOwnConfig();
673
+ if (!conv) {
674
+ conv = createConversationState();
675
+ conv.conversationId = conversationId;
676
+ conversations.set(conversationId, conv);
594
677
  await repo.putConversation(serializeConversation(conv));
595
678
  }
596
679
  return conv;
597
680
  },
598
681
 
682
+ /**
683
+ * 出生播种(幂等):只在 own 为 null 且能解析到工作区时继承一次。
684
+ * 调用方负责「是不是新出生」的判定(见 lib/index.js seedOwnOnBirth)。
685
+ * @returns {Promise<boolean>} 是否真的播种了
686
+ */
687
+ async seedOwnOnBirth(conversationId, workspaceId) {
688
+ if (!conversationId || !workspaceId) return false;
689
+ const ws = workspaces.get(workspaceId);
690
+ if (!ws) return false; // 工作区还没登记 → 留空,下次再说(留空 = 全部技能,与"没播种"一致)
691
+ const conv = await this.ensureOwn(conversationId);
692
+ if (conv.own) return false; // 已有配置,绝不覆盖
693
+ conv.own = ownConfigFromWorkspace(ws);
694
+ if (!conv.inherited.workspaceId) conv.inherited.workspaceId = ws.workspaceId;
695
+ await repo.putConversation(serializeConversation(conv));
696
+ return true;
697
+ },
698
+
699
+ /** 该对话是否已经有自己的配置快照(own 存在即"配置过")。 */
700
+ isConfigured(conversationId) {
701
+ return Boolean(conversations.get(conversationId)?.own);
702
+ },
703
+
599
704
  /** 对话自己的配置快照(只读视图;保证字段完整) */
600
705
  own(conversationId) {
601
706
  const conv = conversations.get(conversationId);
@@ -632,9 +737,20 @@ export function createServices(repo, opts = {}) {
632
737
  return conv;
633
738
  },
634
739
 
740
+ /**
741
+ * 写路径专用:取对话并保证 own 是一个可编辑对象。
742
+ * 只有**用户在对话页里动手编辑**才会走到这里 —— 这是除「出生」与
743
+ * 「继承」之外的第三种合法写入时机(用户显式操作,不是自动同步)。
744
+ */
745
+ async ownForEdit(conversationId) {
746
+ const conv = await this.ensureOwn(conversationId);
747
+ if (!conv.own) conv.own = emptyOwnConfig();
748
+ return conv;
749
+ },
750
+
635
751
  /** 保存对话的标签条件表达式(空串 = 无条件 = 掌握全部技能)。 */
636
752
  async setOwnExpression(conversationId, expression) {
637
- const conv = await this.ensureOwn(conversationId);
753
+ const conv = await this.ownForEdit(conversationId);
638
754
  const v = validateExpression(expression, { tags: [...tags.values()], allowEmpty: true });
639
755
  if (!v.ok) {
640
756
  const err = new Error(v.errors[0]?.message ?? "表达式非法");
@@ -650,7 +766,7 @@ export function createServices(repo, opts = {}) {
650
766
 
651
767
  // ── 对话页右列:手动添加 / 禁用 / 恢复 ──────────────────────────
652
768
  async addOwnManualSkill(conversationId, skillName) {
653
- const conv = await this.ensureOwn(conversationId);
769
+ const conv = await this.ownForEdit(conversationId);
654
770
  const name = String(skillName ?? "").trim();
655
771
  if (!name) throw new Error("技能名不能为空");
656
772
  if (conv.own.disabledSkills.includes(name)) throw new Error(`技能 '${name}' 已被禁用,请先恢复`);
@@ -659,13 +775,13 @@ export function createServices(repo, opts = {}) {
659
775
  return conv;
660
776
  },
661
777
  async removeOwnManualSkill(conversationId, skillName) {
662
- const conv = await this.ensureOwn(conversationId);
778
+ const conv = await this.ownForEdit(conversationId);
663
779
  conv.own.manualSkills = conv.own.manualSkills.filter((s) => s !== skillName);
664
780
  await repo.putConversation(serializeConversation(conv));
665
781
  return conv;
666
782
  },
667
783
  async disableOwnSkill(conversationId, skillName) {
668
- const conv = await this.ensureOwn(conversationId);
784
+ const conv = await this.ownForEdit(conversationId);
669
785
  const name = String(skillName ?? "").trim();
670
786
  if (!name) throw new Error("技能名不能为空");
671
787
  conv.own.manualSkills = conv.own.manualSkills.filter((s) => s !== name);
@@ -674,7 +790,7 @@ export function createServices(repo, opts = {}) {
674
790
  return conv;
675
791
  },
676
792
  async enableOwnSkill(conversationId, skillName) {
677
- const conv = await this.ensureOwn(conversationId);
793
+ const conv = await this.ownForEdit(conversationId);
678
794
  conv.own.disabledSkills = conv.own.disabledSkills.filter((s) => s !== skillName);
679
795
  await repo.putConversation(serializeConversation(conv));
680
796
  return conv;
@@ -794,11 +910,12 @@ export function createServices(repo, opts = {}) {
794
910
  templates: templateStore,
795
911
  workspaces: workspaceService,
796
912
  conversations: conversationService,
913
+ meta: metaService,
797
914
  ready: loadAll,
798
915
  /** repository back-reference (api layer: template create) */
799
916
  _repo: repo,
800
917
  /** exposed for tests/inspection */
801
- _caches: { tags, skillLinks, templates, workspaces, conversations },
918
+ _caches: { tags, skillLinks, templates, workspaces, conversations, syncMeta },
802
919
  };
803
920
  }
804
921
 
@@ -21,6 +21,8 @@
21
21
  * @property {() => Promise<Map<string, any>>} listConversations
22
22
  * @property {(rec: any) => Promise<void>} putConversation
23
23
  * @property {(conversationId: string) => Promise<void>} deleteConversation
24
+ * @property {() => Promise<Map<string, any>>} listSyncMeta
25
+ * @property {(rec: any) => Promise<void>} putSyncMeta
24
26
  */
25
27
 
26
28
  // ── In-memory implementation (unit tests) ───────────────────────────────────
@@ -52,6 +54,8 @@ export function createMemoryRepository() {
52
54
  async listConversations() { return new Map(t("conversations")); },
53
55
  async putConversation(rec) { t("conversations").set(rec.conversationId, structuredClone(rec)); },
54
56
  async deleteConversation(id) { t("conversations").delete(id); },
57
+ async listSyncMeta() { return new Map(t("sync_meta")); },
58
+ async putSyncMeta(rec) { t("sync_meta").set(rec.key, structuredClone(rec)); },
55
59
  };
56
60
  }
57
61
 
@@ -92,6 +96,8 @@ export function createDshRepository(domain) {
92
96
  async listConversations() { return rows("conversations"); },
93
97
  async putConversation(rec) { await tables.conversations.put(rec.conversationId, rec); },
94
98
  async deleteConversation(id) { await tables.conversations.delete(id); },
99
+ async listSyncMeta() { return rows("sync_meta"); },
100
+ async putSyncMeta(rec) { await tables["sync_meta"].put(rec.key, rec); },
95
101
  };
96
102
  }
97
103
 
@@ -124,10 +124,20 @@ export const conversationRecord = z.object({
124
124
  .nullable(),
125
125
  });
126
126
 
127
+ // 同步元数据(键值小抽屉)。目前只放一个键:
128
+ // "installed_at" —— 本插件**本版首次运行**的时刻(ISO 字符串)。
129
+ //
130
+ // 用途(2026-10-31 三种对话口径):判定一条对话属于「插件安装前就存在」还是
131
+ // 「安装后新建」。判据是 session.header.createdAt(宿主保证恒存在)与
132
+ // installed_at 的比较,而不是 agent/created 事件 —— 因为宿主对**恢复**已有
133
+ // 会话同样会发 agent/created(dsh-agent 的 announce(agent, "startup")),
134
+ // 事件本身无法区分"新建"与"打开旧对话"。
127
135
  export const syncMetaRecord = z.object({
128
136
  key: z.string(),
129
137
  rev: z.number().int().nonnegative(),
130
138
  updatedAt: z.string(),
139
+ // 可选值载荷(旧行没有这个字段)
140
+ value: z.string().optional(),
131
141
  });
132
142
 
133
143
  // ── domain spec ─────────────────────────────────────────────────────────────