dsh-skill-tag 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -31,7 +31,6 @@ const PROVIDER_NAME = "skill-tag";
31
31
  /** 影子候选的 source(宿主要求是字符串,且只用于展示/排序以外的标识)。 */
32
32
  const PROVIDER_SOURCE = "skill-tag";
33
33
 
34
- const ON = { modelInvocable: true, userInvocable: true };
35
34
  const DENY = { modelInvocable: false, userInvocable: false };
36
35
 
37
36
  /**
@@ -559,6 +558,22 @@ export function getWorkspaceRegistry(ctx) {
559
558
  return null;
560
559
  }
561
560
 
561
+ /**
562
+ * 安全取宿主 connection 服务(Host/Origin 与浏览器鉴权)。
563
+ * 与 getWorkspaceRegistry 同理:走 ctx.get 探测,不写进 inject,缺席时返回 null。
564
+ * @param {object} ctx cordis context
565
+ * @returns {object|null}
566
+ */
567
+ function getConnection(ctx) {
568
+ try {
569
+ if (typeof ctx?.get === "function") {
570
+ const svc = ctx.get("connection");
571
+ if (svc) return svc;
572
+ }
573
+ } catch { /* 未装载 */ }
574
+ return null;
575
+ }
576
+
562
577
  /**
563
578
  * 会话 → 工作区 id 的解析(宿主 workspaceRegistry 是唯一权威来源)。
564
579
  *
@@ -602,8 +617,6 @@ export function workspaceIdOfPath(registry, cwd) {
602
617
 
603
618
  // ── Wiring ───────────────────────────────────────────────────────────────────
604
619
 
605
- const controls = new Set();
606
-
607
620
  export const name = "dsh-skill-tag";
608
621
 
609
622
  export const inject = ["skills", "agents", "storageDomain", "webServer"];
@@ -618,17 +631,44 @@ export async function apply(ctx, config) {
618
631
  const enabled = config?.enabled !== false;
619
632
 
620
633
  // 1. Persistence: open our storage domain; close on dispose (§12).
634
+ //
635
+ // 关闭必须排在在飞的刷新之后。Cordis 的 fiber 卸载是**兄弟 disposer 并发**
636
+ // (vendor/cordis fiber.ts:720 `Promise.all(this._disposables...)`),所以如果
637
+ // domain.close() 和仍在跑的 refreshSession 同时执行,后者会撞上
638
+ // DomainError("closed")。做法:一个共享的 inflight 登记表 + 先停接收新工作、
639
+ // 再等已接受的结算、最后才 close。
640
+ const inflight = new Set();
641
+ let shuttingDown = false;
642
+
643
+ /** 登记一个在飞的异步任务,dispose 时会等它结算。 */
644
+ function track(promise) {
645
+ const p = Promise.resolve(promise).catch(() => {}).finally(() => inflight.delete(p));
646
+ inflight.add(p);
647
+ return p;
648
+ }
649
+
650
+ //
651
+ // storageDomain 在 inject 里声明为强依赖,但这里仍保留缺席兜底:单测与
652
+ // 极简 cordis 面(runtime.test.mjs)会直接构造不带该服务的 ctx 调 apply,
653
+ // 兜底保证"缺可选服务不崩溃"这条既有契约不破。真宿主里 inject 会先挡住
654
+ // 激活,这条分支不会走到,但它守的是另一条约束。
621
655
  let repo;
622
656
  if (ctx.storageDomain) {
623
657
  const domain = await ctx.storageDomain.open(skillTagDomain);
624
658
  // cordis effect semantics (cordis lib/index.js:1142): the thunk runs NOW and
625
659
  // its RETURNED function becomes the dispose hook — so return the closer,
626
660
  // never call it here. `() => domain.close()` would close the domain at setup.
627
- ctx.effect(() => () => domain.close());
661
+ //
662
+ // 顺序:先置 shuttingDown(拒绝新的刷新)→ 等在飞的结算 → 再关 domain。
663
+ // 这样消费者(读路径、HTTP handler)始终跑在还活着的 domain 上。
664
+ ctx.effect(() => () => {
665
+ shuttingDown = true;
666
+ return Promise.allSettled([...inflight]).then(() => domain.close());
667
+ });
628
668
  repo = createDshRepository(domain);
629
669
  ctx.logger?.info?.("[skill-tag] storage domain 'skill_tag' opened");
630
670
  } else {
631
- // host planes without storageDomain (unit tests, non-persistent runs)
671
+ // 测试面 / 无存储宿主:退回内存仓库,行为可跑但不持久化。
632
672
  const { createMemoryRepository } = await import("../src/storage/repository.js");
633
673
  repo = createMemoryRepository();
634
674
  ctx.logger?.warn?.("[skill-tag] storageDomain service missing — running in-memory");
@@ -649,6 +689,14 @@ export async function apply(ctx, config) {
649
689
  // 3. Session→denied-skill-names live map. Denials derive from the boolean
650
690
  // config + per-conversation four-zone state (§11). Recomputed on demand.
651
691
  const sessionDenials = new Map(); // sessionId -> Set<string>
692
+
693
+ // 每个 agent 注册 provider 时拿到的 control,用于配置变更后 invalidate。
694
+ //
695
+ // 这张表**不能**放模块顶层:模块级 Set 的身份来自模块加载而非 fiber,
696
+ // 插件重载后新旧实例共用同一份,且 agent 销毁时旧 control 留在里面,
697
+ // 之后每次 recomputeDenials 都要对已释放的对象白调一次 invalidate()。
698
+ // 放这里 + 按 agent 删除,生命周期才跟 apply 一致。
699
+ const controls = new Set();
652
700
  function deniedFor(sessionId) {
653
701
  let set = sessionDenials.get(sessionId);
654
702
  if (!set) { set = new Set(); sessionDenials.set(sessionId, set); }
@@ -707,6 +755,7 @@ export async function apply(ctx, config) {
707
755
  const refreshing = new Set();
708
756
  async function refreshBySession(sessionId, cwd, scope = null) {
709
757
  if (!sessionId) return;
758
+ if (shuttingDown) return; // 卸载中:不要再动即将关闭的 domain
710
759
  if (refreshing.has(sessionId)) return;
711
760
  refreshing.add(sessionId);
712
761
  try {
@@ -852,11 +901,14 @@ export async function apply(ctx, config) {
852
901
  const denied = deniedFor(sessionId);
853
902
  // agent 侧的 cwd 兜底(provider options.cwd 优先,见 createFilterProvider)
854
903
  const agentCwd = agent.session?.header?.cwd ?? agent.session?.cwd;
855
- ctx.effect(() =>
856
- agent.ctx.effect(() =>
904
+ // 每个 agent 持有自己那份 control,便于销毁时精确摘除。
905
+ let ownControl = null;
906
+ ctx.effect(() => {
907
+ const outerDispose = agent.ctx.effect(() =>
857
908
  skills.registerProvider((control) => {
858
909
  try {
859
910
  controls.add(control);
911
+ ownControl = control;
860
912
  } catch { /* 控制面已释放 */ }
861
913
  return createFilterProvider(sessionId, upstream, denied, async (options) => {
862
914
  // 读路径上按需刷新:新对话的第一次 list() 也必须已过滤,
@@ -867,8 +919,14 @@ export async function apply(ctx, config) {
867
919
  await refreshBySession(sessionId, options?.cwd ?? agentCwd, agent);
868
920
  }, (message) => ctx.logger?.warn?.(message));
869
921
  }),
870
- ),
871
- );
922
+ );
923
+ // 清理顺序:先摘 control(不再对它 invalidate),再撤 provider。
924
+ return () => {
925
+ if (ownControl !== null) controls.delete(ownControl);
926
+ ownControl = null;
927
+ return outerDispose();
928
+ };
929
+ });
872
930
  ctx.logger?.info?.(`[skill-tag] attached per-agent provider, session=${sessionId}`);
873
931
  } catch (error) {
874
932
  ctx.logger?.error?.("[skill-tag] agent provider attach failed", error);
@@ -901,6 +959,13 @@ export async function apply(ctx, config) {
901
959
 
902
960
  ctx.effect(() => () => { controls.clear(); sessionDenials.clear(); });
903
961
 
962
+ // 会话销毁即回收该会话的拒绝集。缺这条时 Map 只增不减:每开一个对话就多
963
+ // 一条 Set,关掉也不删,长跑进程按会话数无界增长。
964
+ ctx.on("session/disposed", (session) => {
965
+ const sessionId = sessionIdOfAgent({ session }) ?? session?.id;
966
+ if (sessionId) sessionDenials.delete(sessionId);
967
+ });
968
+
904
969
  // 4b. 把「给技能打标签」这份手册注册成一个真实的 skill。
905
970
  // 别人装上本插件后,他们的 agent 在 /skills 里就能看到 `tagging-skills`,
906
971
  // 从而照着它给自己的技能打标签 —— 这是 m05073 要求的「可移植」落点:
@@ -947,9 +1012,13 @@ export async function apply(ctx, config) {
947
1012
  ctx.on("domain/changed", (change) => {
948
1013
  try {
949
1014
  if (change?.domain !== "skill_tag") return;
1015
+ // 卸载中不再接新刷新:这些刷新会去读写正在关闭的 domain。
1016
+ if (shuttingDown) return;
950
1017
  for (const agent of ctx.get("agents")?.list?.() ?? []) {
951
- // refreshSession 自己已 async + try/catch,这里再包一层防同步抛出
952
- try { void refreshSession(agent); } catch { /* ignore */ }
1018
+ // refreshSession 自己已 async + try/catch,这里再包一层防同步抛出。
1019
+ // 用 track() 登记:原先的 `void refreshSession(agent)` 没有 owner,
1020
+ // 卸载时既不等它结算也不取消它。
1021
+ try { track(refreshSession(agent)); } catch { /* ignore */ }
953
1022
  }
954
1023
  } catch (error) {
955
1024
  try {
@@ -959,8 +1028,9 @@ export async function apply(ctx, config) {
959
1028
  });
960
1029
 
961
1030
  // 6. Settings-page API (host half of the UI; client half in lib/client.js).
962
- // Guarded: test planes may lack webServer.
963
- if (ctx.webServer && typeof ctx.webServer.register === "function") {
1031
+ // 与 storageDomain 同理:webServer 虽是 inject 里的强依赖,测试面会构造
1032
+ // 不带它的 ctx,所以保留缺席守卫,保证 apply 在最小面上不抛。
1033
+ if (typeof ctx.webServer?.register === "function") {
964
1034
  // listSkills(cwd): 技能发现。设置页没有 cwd → 只扫用户级根目录;
965
1035
  // 「管理标签」浮层知道自己在哪个工作区/对话,于是传 cwd 把项目级技能也扫进来。
966
1036
  mountApi(
@@ -999,9 +1069,13 @@ export async function apply(ctx, config) {
999
1069
  const ids = entity?.sessionIds;
1000
1070
  return Array.isArray(ids) ? [...ids] : [];
1001
1071
  },
1072
+ // Host/Origin + 浏览器鉴权。webServer 只做路由匹配、不查来源,
1073
+ // 不传它就等于任意外部页面都能跨源调用这些写端点。
1074
+ getConnection(ctx),
1002
1075
  );
1003
1076
  } else {
1004
- ctx.logger?.warn?.("[skill-tag] webServer missing — settings API not mounted");
1077
+ // 强依赖缺席只可能是测试面(真宿主里 inject 会挡住激活)。
1078
+ ctx.logger?.warn?.("[skill-tag] webServer.register unavailable — settings API not mounted");
1005
1079
  }
1006
1080
 
1007
1081
  ctx.logger?.info?.("[skill-tag] apply() complete", {
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Skill tags",
4
+ "description": "Tags DSH skills and uses one boolean expression to pick, per workspace and per conversation, which subset of skills is handed to the model."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "技能标签",
4
+ "description": "给 DSH 的技能打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-skill-tag",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "给 DSH 的技能(skill)打标签,用一条布尔表达式在「工作区 / 对话」两级选出要下发给模型的技能子集",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,13 +23,15 @@
23
23
  "exports": {
24
24
  ".": "./lib/index.js",
25
25
  "./client": "./lib/client.js",
26
- "./package.json": "./package.json"
26
+ "./package.json": "./package.json",
27
+ "./locale/*.json": "./locale/*.json"
27
28
  },
28
29
  "files": [
29
30
  "lib",
30
31
  "src",
31
32
  "skills",
32
33
  "tools",
34
+ "locale/*.json",
33
35
  "cordis.patch.yml",
34
36
  "README.md",
35
37
  "LICENSE",
package/src/api.js CHANGED
@@ -57,26 +57,55 @@ function wsShape(ws) {
57
57
  * 工作区 id → 该工作区下的会话 id 列表(宿主 workspaceRegistry entity.sessionIds)。
58
58
  * 「一键下发」要知道发给谁;为 null 时退回对话自己记录的 sourceWorkspaceId。
59
59
  */
60
- export function createApiHandler(services, log, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null) {
60
+ export function createApiHandler(services, log, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null, connection = null) {
61
61
  const json = (res, code, obj) => {
62
62
  res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
63
63
  res.end(JSON.stringify(obj));
64
64
  };
65
+ const MAX_BODY = 1_000_000;
65
66
  const readBody = (req) =>
66
67
  new Promise((resolve, reject) => {
67
68
  let data = "";
69
+ let settled = false;
68
70
  req.on("data", (c) => {
71
+ if (settled) return;
69
72
  data += c;
70
- if (data.length > 1_000_000) reject(new Error("body too large"));
73
+ // 超限必须停:原实现 reject 之后仍继续累积字符串,
74
+ // 等于把「拒绝」写成了「慢慢吃满内存」。
75
+ if (data.length > MAX_BODY) {
76
+ settled = true;
77
+ req.destroy?.();
78
+ reject(new Error("body too large"));
79
+ }
80
+ });
81
+ req.on("end", () => {
82
+ if (!settled) { settled = true; resolve(data); }
83
+ });
84
+ req.on("error", (e) => {
85
+ if (!settled) { settled = true; reject(e); }
71
86
  });
72
- req.on("end", () => resolve(data));
73
- req.on("error", reject);
74
87
  });
75
88
 
76
89
  return async function handler(req, res) {
77
90
  const url = new URL(req.url ?? "/", "http://localhost");
78
91
  const path = url.pathname.replace(/^\/skill-tag\/api/, "") || "/";
79
92
  const method = req.method ?? "GET";
93
+
94
+ // 先过宿主 Connection 的 Host/Origin 与浏览器鉴权。webServer 本身不做任何
95
+ // 来源检查,不接这道防线的话,任意网页都能跨源调这些端点:写操作走
96
+ // mode:'no-cors' 的 simple request 不需要预检,读不到响应但写入已生效。
97
+ const rejection = connection?.requestRejection?.({ headers: req.headers });
98
+ if (rejection !== undefined) return json(res, rejection, { ok: false, error: "rejected" });
99
+
100
+ // 写操作必须是 JSON:挡掉 text/plain 的跨源 simple request(它不触发预检),
101
+ // 也让浏览器的跨源请求先走 CORS 预检。
102
+ if (method === "POST" || method === "PUT") {
103
+ const type = String(req.headers?.["content-type"] ?? "").toLowerCase();
104
+ if (!type.startsWith("application/json")) {
105
+ return json(res, 415, { ok: false, error: "content-type must be application/json" });
106
+ }
107
+ }
108
+
80
109
  let body = {};
81
110
  if (method === "POST" || method === "PUT") {
82
111
  try {
@@ -439,7 +468,7 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
439
468
 
440
469
  // ── 「管理标签」双列界面 ───────────────────────────────────────
441
470
  // 左列 = 标签条件(表达式);右列 = 该工作区掌握/禁用的技能。
442
- if (path === "/workspaces/expression" && (method === "POST" || method === "GET")) {
471
+ if (path === "/workspaces/expression" && method === "POST") {
443
472
  // 保存左列表达式(空串 = 清空条件 = 回到"全部技能")
444
473
  const out = await services.workspaces.setExpression(q("workspaceId"), q("expression") ?? "");
445
474
  return json(res, 200, { ok: true, workspace: wsShape(out.record), validation: out.validation });
@@ -571,7 +600,7 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
571
600
  // · 已配置的对话必须显式 overwrite=true 才覆盖(界面二次勾选确认)。
572
601
  //
573
602
  // 这是 0.1.9 那轮修复的直接延续:用户改的东西不能被无差别覆盖。
574
- if ((path === "/workspaces/push-preview" || path === "/workspaces/push") && (method === "GET" || method === "POST")) {
603
+ if ((path === "/workspaces/push-preview" || path === "/workspaces/push") && method === "POST") {
575
604
  const wid = q("workspaceId");
576
605
  if (!wid) return json(res, 400, { ok: false, error: "缺少 workspaceId" });
577
606
  // 该工作区下的对话:**宿主 sessionIds 是唯一权威来源**。
@@ -627,7 +656,7 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
627
656
  // 对话拥有**自己的**配置快照(own):标签条件 + 手动添加 + 禁用。
628
657
  // 工作区配置不会自动流入对话,除非:新建对话(自动继承一次)或
629
658
  // 用户点了「继承工作区配置」按钮。
630
- if (path === "/conversations/config" && (method === "GET" || method === "POST")) {
659
+ if (path === "/conversations/config" && method === "POST") {
631
660
  const cid = q("conversationId");
632
661
  const wid = workspaceIdForConversation(cid);
633
662
  // 打开「管理标签(对话)」是**纯读**:只保证对话行存在,绝不播种 own。
@@ -683,14 +712,19 @@ export function createApiHandler(services, log, listSkills = null, resolveWorksp
683
712
  };
684
713
  }
685
714
 
686
- /** Mount the handler on a cordis ctx (inject webServer). */
687
- export function mountApi(ctx, services, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null) {
715
+ /**
716
+ * Mount the handler on a cordis ctx (inject webServer).
717
+ *
718
+ * connection 用来做 Host/Origin 与浏览器鉴权:webServer 自身只做路由匹配,
719
+ * 不做任何来源检查。它可能缺席(无浏览器组合),那时 handler 会跳过校验。
720
+ */
721
+ export function mountApi(ctx, services, listSkills = null, resolveWorkspaceId = null, resolveWorkspacePath = null, resolveWorkspaceSessions = null, connection = null) {
688
722
  ctx.effect(
689
723
  () =>
690
724
  ctx.webServer.register({
691
725
  kind: "prefix",
692
726
  path: "/skill-tag/api",
693
- handler: createApiHandler(services, ctx.logger, listSkills, resolveWorkspaceId, resolveWorkspacePath, resolveWorkspaceSessions),
727
+ handler: createApiHandler(services, ctx.logger, listSkills, resolveWorkspaceId, resolveWorkspacePath, resolveWorkspaceSessions, connection),
694
728
  }),
695
729
  "skill-tag: api",
696
730
  );