dsh-skill-tag 0.1.7 → 0.1.10
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 +10 -9
- package/docs//345/257/271/350/257/235/347/256/241/347/220/206/346/240/207/347/255/276.md +49 -18
- package/lib/client.js +32 -5
- package/lib/index.js +106 -33
- package/package.json +1 -1
- package/src/api.js +24 -2
- package/src/service.js +141 -24
- package/src/storage/repository.js +6 -0
- package/src/storage/schema.js +10 -0
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
|
| **打开技能所在文件夹** | 任何列出技能的地方都有 📁 按钮,一点即在文件管理器里定位 |
|
|
@@ -30,23 +30,24 @@ office * plugin # 只要 office 包带来的、且是插件相关
|
|
|
30
30
|
## 界面预览
|
|
31
31
|
|
|
32
32
|
> 全部是真实实例截图(105 个技能 / 48 个标签 / 一个开了筛选的工作区),没有 mock。
|
|
33
|
-
> 图片源文件在 `images/`(不进 npm 包,README 里用
|
|
33
|
+
> 图片源文件在 `images/`(不进 npm 包,README 里用 jsDelivr 绝对地址,GitHub 与 npm 都能显示;
|
|
34
|
+
> 之所以不走 `raw.githubusercontent.com`,是因为部分网络到该域名的 IPv6 不通,图片会间歇性加载失败)。
|
|
34
35
|
|
|
35
36
|
### 图 1 · 入口:会话行「…」→「管理标签」
|
|
36
37
|
|
|
37
|
-
<img src="https://
|
|
38
|
+
<img src="https://cdn.jsdelivr.net/gh/aawef/dsh-skill-manager-tags@main/images/session-menu.png" width="240" alt="会话行菜单里的管理标签入口">
|
|
38
39
|
|
|
39
40
|
对话级的配置就挂在会话行的「…」菜单最下面,不用去翻设置页。
|
|
40
41
|
|
|
41
42
|
### 图 2 · 入口:命令面板里的 `/skill-tag-manage`
|
|
42
43
|
|
|
43
|
-
<img src="https://
|
|
44
|
+
<img src="https://cdn.jsdelivr.net/gh/aawef/dsh-skill-manager-tags@main/images/command-palette.png" width="820" alt="命令面板中的管理对话标签命令">
|
|
44
45
|
|
|
45
46
|
在输入框敲 `/` 也能直接开「管理对话标签」——同一个界面,省一次点菜单。
|
|
46
47
|
|
|
47
48
|
### 图 3 · 打标签:设置 →「Skill 标签」
|
|
48
49
|
|
|
49
|
-
<img src="https://
|
|
50
|
+
<img src="https://cdn.jsdelivr.net/gh/aawef/dsh-skill-manager-tags@main/images/tag-settings.png" width="620" alt="Skill 标签管理页:新建标签、按来源分组的标签列表">
|
|
50
51
|
|
|
51
52
|
三个页签分工明确:**tag 联系 skill**(一个标签包含哪些技能)、**skill 联系 tag**
|
|
52
53
|
(一个技能拥有哪些标签)、**编辑标签组**(写表达式存模板)。
|
|
@@ -54,7 +55,7 @@ office * plugin # 只要 office 包带来的、且是插件相关
|
|
|
54
55
|
|
|
55
56
|
### 图 4 · 一个技能看它身上有哪些标签
|
|
56
57
|
|
|
57
|
-
<img src="https://
|
|
58
|
+
<img src="https://cdn.jsdelivr.net/gh/aawef/dsh-skill-manager-tags@main/images/skill-to-tags.png" width="640" alt="skill 联系 tag:左列技能、右列标签勾选">
|
|
58
59
|
|
|
59
60
|
左列 `技能 (105)` 每行标着它有几个标签;右边勾选**即时保存**,不用点确认。
|
|
60
61
|
右列会把该技能**已有的标签排在列表最前面**(并保持它们原来的相对顺序),所以不用滚动
|
|
@@ -63,7 +64,7 @@ office * plugin # 只要 office 包带来的、且是插件相关
|
|
|
63
64
|
|
|
64
65
|
### 图 5 · 工作区「管理标签」:左写条件,右就是下发名单
|
|
65
66
|
|
|
66
|
-
<img src="https://
|
|
67
|
+
<img src="https://cdn.jsdelivr.net/gh/aawef/dsh-skill-manager-tags@main/images/workspace-filter.png" width="900" alt="工作区管理标签双列界面">
|
|
67
68
|
|
|
68
69
|
左列是标签条件,下面实时显示**解析结果**和状态「有条件筛选(严格子集)」;
|
|
69
70
|
右列 `技能 (54)` 就是该工作区下发给模型的全部技能,每行可以「查看 tag」「禁用」,
|
|
@@ -72,7 +73,7 @@ office * plugin # 只要 office 包带来的、且是插件相关
|
|
|
72
73
|
|
|
73
74
|
### 图 6 · 对话「管理标签(对话)」:可以有自己的一份
|
|
74
75
|
|
|
75
|
-
<img src="https://
|
|
76
|
+
<img src="https://cdn.jsdelivr.net/gh/aawef/dsh-skill-manager-tags@main/images/conversation-filter.png" width="900" alt="对话级管理标签界面与继承来源">
|
|
76
77
|
|
|
77
78
|
同一个工作区下的某个对话想单独收窄,就在这里改:它会记下**继承来源**与时间
|
|
78
79
|
(`改动不影响工作区`),左下角一键「继承工作区配置」覆盖回来。
|
|
@@ -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
|
|
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
|
-
|
|
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;
|
|
249
|
-
const own = conv?.own ?? null;
|
|
250
|
-
const
|
|
251
|
-
|
|
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` 字段)首次打开 | `
|
|
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
|
|
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` 抛错时降级沿用旧集合,不冒回宿主 | 同上 |
|
package/lib/client.js
CHANGED
|
@@ -33,7 +33,22 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
return r.json();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// ── pure view-model helpers
|
|
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
|
+
|
|
37
52
|
// zoneOf: §7 four-zone classification of a conversation tag entry.
|
|
38
53
|
function zoneOf(entry) {
|
|
39
54
|
if (entry.blocked) return "blocked";
|
|
@@ -1222,6 +1237,10 @@ window.__ModuleLoader__.load({
|
|
|
1222
1237
|
|
|
1223
1238
|
const inheritedFrom = ws?.sourceWorkspaceId;
|
|
1224
1239
|
const inheritedAt = ws?.inheritedAt;
|
|
1240
|
+
// configured:该对话是否有自己的配置快照(own)。false = 从未配置过。
|
|
1241
|
+
// expressionIssue:服务端只读体检结果,用于提示「引用了已删除的标签」。
|
|
1242
|
+
const configured = ws?.configured;
|
|
1243
|
+
const expressionIssue = ws?.expressionIssue ?? null;
|
|
1225
1244
|
|
|
1226
1245
|
const body = () => {
|
|
1227
1246
|
if (loading) return react.createElement("div", { style: { color: "#888", padding: "20px 0" } }, "加载中…");
|
|
@@ -1237,11 +1256,19 @@ window.__ModuleLoader__.load({
|
|
|
1237
1256
|
"解析结果: " + parsed),
|
|
1238
1257
|
react.createElement("div", { key: "st", style: { color: "#888", fontSize: "11px", marginTop: "2px" } },
|
|
1239
1258
|
`状态: ${view.hasFilter ? "有条件筛选(严格子集)" : "无筛选(全部技能)"}`),
|
|
1240
|
-
//
|
|
1259
|
+
// 继承状态:让用户知道「这份条件是从哪来的、什么时候来的」
|
|
1260
|
+
// configured=false(own 为 null)= 从未配置过 = 空条件 = 掌握全部技能。
|
|
1261
|
+
// 这不是「继承来源 null」,而是「你还没配过」,所以单独一条文案。
|
|
1241
1262
|
scope.isConv && react.createElement("div", { key: "inh", style: { color: "#888", fontSize: "11px", marginTop: "2px" } },
|
|
1242
|
-
|
|
1243
|
-
?
|
|
1244
|
-
:
|
|
1263
|
+
configured === false
|
|
1264
|
+
? "尚未配置过(未继承工作区;当前为空条件 = 掌握全部技能)"
|
|
1265
|
+
: (inheritedFrom
|
|
1266
|
+
? `继承来源: ${inheritedFrom}${inheritedAt ? " · " + fmtLocalTime(inheritedAt) : ""}(改动不影响工作区)`
|
|
1267
|
+
: "该对话有独立配置(未继承工作区)")),
|
|
1268
|
+
// 只读提示:已冻结的表达式引用了不存在的标签(旧版 bug 遗留)。
|
|
1269
|
+
// 不自动修复 —— 数据是用户意志,告诉他原因即可。
|
|
1270
|
+
scope.isConv && expressionIssue && !expressionIssue.ok && react.createElement("div", { key: "isq", style: { color: "#e67e22", fontSize: "11px", marginTop: "2px" } },
|
|
1271
|
+
`⚠ 表达式引用了已不存在的标签(${expressionIssue.message})。点「tag 设置」可重新选择;保存前需修正。`),
|
|
1245
1272
|
react.createElement("div", { key: "sp", style: { flex: 1 } }),
|
|
1246
1273
|
react.createElement("div", { key: "f", style: { display: "flex", gap: "8px", flexWrap: "wrap", paddingTop: "8px", borderTop: "1px solid var(--theme-border,#222)" } }, [
|
|
1247
1274
|
react.createElement("button", {
|
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
|
-
*
|
|
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 {
|
|
306
|
-
* @returns {{config:object, manualSkills:string[], disabledSkills:string[]}
|
|
318
|
+
* @param {object|null} [_ws] 已废弃,仅为兼容既有调用签名;**不再参与计算**
|
|
319
|
+
* @returns {{config:object, manualSkills:string[], disabledSkills:string[]}}
|
|
307
320
|
*/
|
|
308
|
-
export function effectiveSkillsConfig(own,
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
// 3. **cwd → workspace.path 反查**(新加)
|
|
674
|
-
// 4. 对话记录里的 inherited.workspaceId(旧数据兜底)
|
|
754
|
+
// 生效口径(2026-10-31 统一):**只看 own,永不回退到工作区实时配置**。
|
|
755
|
+
//
|
|
756
|
+
// own 存在 → 该对话自己的冻结快照;
|
|
757
|
+
// own 为 null → 从未配置过 = 空条件 = 掌握全部技能。
|
|
675
758
|
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
//
|
|
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
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,
|
|
@@ -451,8 +471,10 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
|
|
|
451
471
|
if (path === "/conversations/config" && (method === "GET" || method === "POST")) {
|
|
452
472
|
const cid = q("conversationId");
|
|
453
473
|
const wid = workspaceIdForConversation(cid);
|
|
454
|
-
//
|
|
455
|
-
|
|
474
|
+
// 打开「管理标签(对话)」是**纯读**:只保证对话行存在,绝不播种 own。
|
|
475
|
+
// (旧实现在这里用当前工作区播种,导致"看一眼就被冻结",且冻结值
|
|
476
|
+
// 取决于点开的先后顺序 —— 是「改工作区标签波及部分对话」的根因之一。)
|
|
477
|
+
await services.conversations.ensureOwn(cid);
|
|
456
478
|
return json(res, 200, await convPayload(cid, wid));
|
|
457
479
|
}
|
|
458
480
|
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(
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* 快照(= 用户口径里的「默认继承」)。播种只发生一次:之后工作区再改,
|
|
583
|
-
* 本对话不受影响;要重新同步只能点「继承工作区配置」按钮。
|
|
667
|
+
* 取(必要时**建行**)对话记录。**不写 own**:own 只由出生播种与
|
|
668
|
+
* 「继承工作区配置」写。历史对话第一次被打开时 own 仍是 null(= 全部技能),
|
|
669
|
+
* 直到用户显式继承或编辑。
|
|
584
670
|
*/
|
|
585
|
-
async ensureOwn(conversationId
|
|
586
|
-
// 播种只在「新建这一行」时有意义:先看能不能拿到工作区记录,
|
|
587
|
-
// 拿不到就别硬绑(避免 create 抛错),退回空快照。
|
|
588
|
-
const seed = seedWorkspaceId ? workspaces.get(seedWorkspaceId) ?? null : null;
|
|
671
|
+
async ensureOwn(conversationId) {
|
|
589
672
|
let conv = conversations.get(conversationId);
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
package/src/storage/schema.js
CHANGED
|
@@ -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 ─────────────────────────────────────────────────────────────
|