@shgroup/dsh-serenity-hooks 1.105.0 → 1.108.0

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.
@@ -1,150 +1,6 @@
1
- import { f as hostSettings } from "./ccc-roots-BPicjCsj.js";
2
- import { c as readWeixinSettings, o as readImSettings } from "./route-CmEoKp9w.js";
1
+ import { c as readWeixinSettings, o as readImSettings } from "./route-reyv8qcB.js";
3
2
  import { isAbsolute, join, relative, resolve } from "node:path";
4
3
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
- //#region src/surface/settings.ts
6
- /**
7
- * 设置面板命名空间 = **profile 条目 id**(v0.1.7 起;与旧 `settings.yaml` 段名逐字相同)。
8
- */
9
- const SERENITY_SETTINGS_NS = "serenity-hooks";
10
- /** 进程级默认(无 Config 可读时的兜底;与 Config 各字段的缺省一致) */
11
- function defaultSimpleSettings() {
12
- return {
13
- gatewayEnabled: false,
14
- rebuildThresholdK: 400,
15
- croEnabled: true,
16
- diagramEnabled: false,
17
- humanChannel: defaultHumanChannelSettings()
18
- };
19
- }
20
- /**
21
- * volatile 字段的**结构化解包**(v1.47.2;本文件读取语义的第一道)。
22
- *
23
- * ## 为什么必须有它(两个事实的合成,缺一即错)
24
- * ① **0.1.7 的面板层字段必须标 `.volatile()`** —— 否则本插件命名空间整个不进宿主的
25
- * `settings.describe()`(`volatileForm()` 只认 `meta.volatile`)⇒ **设置面板那一节消失**。
26
- * ② **标了之后,运行期拿到的是引用包装,不是裸值** —— schemastery 的 `Schema.resolve()` 对
27
- * `meta.volatile` **无条件**包 `createVolatile(value)`,且该分支排在"缺省值回退"那一段
28
- * **之前** ⇒ **"用户没设过"的字段也是恒真对象**(`.get()` 返回 `undefined`),**不是 `undefined`**。
29
- *
30
- * ⇒ 若这里不解包,下面十行的 `??` 链会**短路在这层包装上** ⇒ 网关 / 轨迹约束 会被
31
- * **无意打开**(比"面板不显示"更坏:静默改变对外暴露面)。
32
- *
33
- * ## 为什么用结构判定
34
- * 可观察形状就是"有 `get()`"(cosmokit 的 `isVolatile` 也这么判);本模块不引它的运行时依赖。
35
- *
36
- * @param value - volatile 引用 | 裸值 | undefined
37
- * @returns 解包后的裸值(引用 ⇒ `.get()`;裸值原样;缺省 ⇒ `undefined`)
38
- */
39
- function unwrapVolatile(value) {
40
- if (value === void 0 || value === null) return void 0;
41
- if (typeof value.get === "function") return value.get();
42
- return value;
43
- }
44
- /** human-channel 的内建缺省(与 `Config` 的 schema 缺省**同源**:这里是读取面,那里是解析面) */
45
- function defaultHumanChannelSettings() {
46
- return {
47
- accounts: [],
48
- subscriptions: [],
49
- relayCcc: null,
50
- sendingAllow: ["*"]
51
- };
52
- }
53
- /** 取"有内容的字符串"(首尾空白剥掉;空白串 ⇒ `undefined`)—— 面板空文本框会写 `''`,那是"没填"不是"填了个空名" */
54
- function nonEmptyText(value) {
55
- if (typeof value !== "string") return void 0;
56
- const trimmed = value.trim();
57
- return trimmed === "" ? void 0 : trimmed;
58
- }
59
- /**
60
- * `humanChannel` 片段 → 运行时归一形态。**优先级:显式值 > 内建缺省**(与 `simpleSettingsFromConfig` 同族)。
61
- *
62
- * 🔴 三处"缺省 vs 显式"的语义差别,别写反:
63
- * ① `sending.allow: []` ⇒ **`[]`**("谁都不许"),**不是**回退到 `['*']` —— 故用 `??`
64
- * (只对 `undefined`/`null` 回退)。⚠️ **别拿 `??`→`||` 当变异探针**:空数组在 JS 里是
65
- * **真值** ⇒ 两种写法在这个输入上**恰好同效**,那是**非变异**(会得出"测试不承重"的假结论)。
66
- * ② `relay.ccc` 空白 ⇒ `null`("没填"与"没这一项"同义),**不是**把空白当 CCC 名;
67
- * ③ 账号 `enabled` 缺省 **true**(老账号表里 `enabled` 不写就是开)。
68
- *
69
- * 🔴 **旧 CCC 侧 `weixin.*` 不在这里、也不该在这里**:机器级面**只**认 `humanChannel`
70
- * (硬切无别名,同 D61 先例)⇒ 传进来的旧键**一律不进结构**(回归钉见
71
- * `tests/human-channel-config.test.ts`;CCC 侧旧读取面的退役在 P0-2~P0-4)。
72
- * @param fragment - `Config.humanChannel`(可能整个不存在 —— 那是**合法**初始态)
73
- * @returns 归一形态(永不 `undefined`)
74
- */
75
- function humanChannelSettingsFromConfig(fragment) {
76
- const d = defaultHumanChannelSettings();
77
- if (!fragment) return d;
78
- return {
79
- accounts: (fragment.accounts ?? []).map((a) => ({
80
- id: nonEmptyText(a?.id),
81
- channel: nonEmptyText(a?.channel),
82
- enabled: a?.enabled !== false,
83
- token: a?.token,
84
- userId: a?.userId
85
- })),
86
- subscriptions: (fragment.subscriptions ?? []).map((s) => ({
87
- account: nonEmptyText(s?.account),
88
- user: nonEmptyText(s?.user),
89
- ccc: nonEmptyText(s?.ccc),
90
- role: nonEmptyText(s?.role)
91
- })),
92
- relayCcc: nonEmptyText(fragment.relay?.ccc) ?? d.relayCcc,
93
- sendingAllow: fragment.sending?.allow ?? d.sendingAllow
94
- };
95
- }
96
- /**
97
- * Config(宿主 Loader 校验后的解析值)→ 扁平简单配置。**本文件唯一的读取语义**。
98
- *
99
- * 优先级:**面板层(扁平键)> 部署层(嵌套段)> 内建缺省**。
100
- * @param config - 插件 Config(`fiber.config` 或 apply 收到的 config)
101
- * @returns 扁平开关集合
102
- */
103
- function simpleSettingsFromConfig(config) {
104
- const d = defaultSimpleSettings();
105
- return {
106
- gatewayEnabled: unwrapVolatile(config.gatewayEnabled) ?? config.gateway?.enabled ?? d.gatewayEnabled,
107
- rebuildThresholdK: unwrapVolatile(config.rebuildThresholdK) ?? config.rebuild?.thresholdK ?? d.rebuildThresholdK,
108
- croEnabled: unwrapVolatile(config.croEnabled) ?? d.croEnabled,
109
- diagramEnabled: unwrapVolatile(config.diagramEnabled) ?? config.diagram?.enabled ?? d.diagramEnabled,
110
- humanChannel: humanChannelSettingsFromConfig(config.humanChannel)
111
- };
112
- }
113
- /** 运行时源(`registerSettingsSection` 装配:读本插件 fiber 的**当前** Config) */
114
- let simpleSource = null;
115
- /**
116
- * 读取当前简单配置(各功能 gateway/rebuild/轨迹约束/… 的启动与运行时判断都经此)。
117
- *
118
- * 源 = 本插件 fiber 的 `config`(宿主在配置变更时走 `fiber.update()` ⇒ `config` 始终最新,
119
- * **不必**依赖 apply 重跑,也不必缓存)。
120
- * @returns 扁平开关集合
121
- */
122
- function readSimpleSettings() {
123
- return simpleSource ? simpleSource() : defaultSimpleSettings();
124
- }
125
- /**
126
- * 装配简单配置读取面(v1.21 分层;**v0.1.7 起不再注册任何 section**)。
127
- *
128
- * 两件事:
129
- * 1. 把"本插件自己的 Config"接成简单配置源 —— 读 `ctx.fiber.config`(宿主 `fiber.update()`
130
- * 保证最新),拿不到 fiber 时退回 apply 收到的 `config`。
131
- * 2. 关掉宿主按 Config schema **自动生成**的通用页(`settings.configure({ auto: false })`)——
132
- * 门面是我们的自定义页 `client/SettingsSection.tsx`(同一个命名空间,同一份数据)。
133
- *
134
- * ⚠️ 两个失败都不阻断插件装载(面板是附属能力,绝不能成为启动单点)。
135
- * @param ctx - 插件上下文
136
- * @param config - apply 收到的 Config(fiber.config 不可用时的兜底)
137
- */
138
- function registerSettingsSection(ctx, config) {
139
- const fiber = ctx.fiber;
140
- const readConfig = () => fiber?.config ?? config;
141
- simpleSource = () => simpleSettingsFromConfig(readConfig());
142
- const settings = hostSettings(ctx);
143
- try {
144
- settings?.configure?.({ auto: false }, fiber);
145
- } catch {}
146
- }
147
- //#endregion
148
4
  //#region src/im/log-scan.ts
149
5
  /**
150
6
  * im/log-scan.ts — 读聊天记录落盘,汇总出「与谁有过往来」(`contacts` 的数据源)
@@ -599,4 +455,4 @@ async function runImBridge(root, req) {
599
455
  return fail("ACTION_UNKNOWN", `unknown action "${action}"`);
600
456
  }
601
457
  //#endregion
602
- export { SERENITY_SETTINGS_NS as a, runImBridge as i, isImWiredTrajectory as n, readSimpleSettings as o, registerImChannel as r, registerSettingsSection as s, imChannelIds as t };
458
+ export { runImBridge as i, isImWiredTrajectory as n, registerImChannel as r, imChannelIds as t };
package/lib/client.js CHANGED
@@ -4754,10 +4754,135 @@ Instruction-following style:
4754
4754
  title: "会话清理",
4755
4755
  desc: "删除 N 天前无活动的 DSH 会话(手动,不可恢复)",
4756
4756
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionCleanupBlock, {})
4757
+ }),
4758
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Collapse, {
4759
+ title: "轨迹订阅",
4760
+ desc: "观察者盯一组成员,任一轮结束即通知(只读;配置在 serenity.json)",
4761
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SubscriptionsBlock, {})
4757
4762
  })
4758
4763
  ]
4759
4764
  });
4760
4765
  }
4766
+ /**
4767
+ * 「轨迹订阅」块(v1.108.0,owner 2026-11-01 P3)。
4768
+ *
4769
+ * 🔴 **只读**,且刻意分两栏显示「意图」与「实际」:
4770
+ * · **意图栏** = 配置里写了谁盯谁;**实际栏** = 真的发生过投递的键。
4771
+ * · 两者**必须分开看**:一个配了却不出现在实际栏的订阅 = **还没触发过**(不是"在工作");
4772
+ * 出现在实际栏且 `failed>0` = **触发了但送不到**(那才是要修的)。
4773
+ * 把这两件事合成一句话,人就分不出来了(这正是 diag ⑤ 段同一个理由)。
4774
+ *
4775
+ * ⚠️ 计数是**进程内**的(重启清零)—— UI 上必须写明,否则"0"会被读成"从来没配过"。
4776
+ */
4777
+ function SubscriptionsBlock() {
4778
+ const [data, setData] = (0, react.useState)(null);
4779
+ const [busy, setBusy] = (0, react.useState)(false);
4780
+ const [error, setError] = (0, react.useState)(null);
4781
+ const load = async () => {
4782
+ setBusy(true);
4783
+ setError(null);
4784
+ try {
4785
+ const res = await fetch("/serenity/subscriptions", { headers: {
4786
+ accept: "application/json",
4787
+ "x-serenity-ui": "1"
4788
+ } });
4789
+ if (!res.ok) {
4790
+ const body = await res.json().catch(() => null);
4791
+ setError(body?.error ?? `HTTP ${res.status}`);
4792
+ return;
4793
+ }
4794
+ setData(await res.json());
4795
+ } catch (err) {
4796
+ setError(err instanceof Error ? err.message : String(err));
4797
+ } finally {
4798
+ setBusy(false);
4799
+ }
4800
+ };
4801
+ (0, react.useEffect)(() => {
4802
+ load();
4803
+ }, []);
4804
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4805
+ className: "ss-field",
4806
+ children: [
4807
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4808
+ className: "ss-row",
4809
+ style: {
4810
+ display: "flex",
4811
+ alignItems: "center",
4812
+ gap: 8
4813
+ },
4814
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4815
+ type: "button",
4816
+ className: "ss-btn",
4817
+ disabled: busy,
4818
+ onClick: () => void load(),
4819
+ children: busy ? "读取中…" : "刷新读数"
4820
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4821
+ style: {
4822
+ opacity: .7,
4823
+ fontSize: 12
4824
+ },
4825
+ children: "投递计数只在宿主进程内有效(重启清零);配置全表在 .opencode/serenity.json"
4826
+ })]
4827
+ }),
4828
+ error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4829
+ className: "ss-error",
4830
+ children: ["⚠ ", error]
4831
+ }),
4832
+ data !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
4833
+ data.problems.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4834
+ className: "ss-error",
4835
+ children: [
4836
+ "⚠ 配置坏项 ",
4837
+ data.problems.length,
4838
+ " 条(配了但不生效):",
4839
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
4840
+ style: { margin: "4px 0 0 16px" },
4841
+ children: data.problems.map((p, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: p }, i))
4842
+ })
4843
+ ]
4844
+ }),
4845
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
4846
+ style: { margin: "12px 0 4px" },
4847
+ children: "配置(意图:谁盯谁)"
4848
+ }),
4849
+ data.subscriptions.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4850
+ style: { opacity: .7 },
4851
+ children: "(未配置任何订阅 —— 在 .opencode/serenity.json 里写 trajectorySubscriptions)"
4852
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
4853
+ style: { margin: "0 0 0 16px" },
4854
+ children: data.subscriptions.map((s, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
4855
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: s.watcher }),
4856
+ " 盯 ",
4857
+ s.members.join("、"),
4858
+ s.note !== null ? `(${s.note})` : ""
4859
+ ] }, i))
4860
+ }),
4861
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
4862
+ style: { margin: "12px 0 4px" },
4863
+ children: "投递(实际:发生了没有)"
4864
+ }),
4865
+ data.deliveries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4866
+ style: { opacity: .7 },
4867
+ children: "(本进程内还没有任何投递发生 —— 配了但没出现 = 还没触发过,不等于在工作)"
4868
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
4869
+ style: { margin: "0 0 0 16px" },
4870
+ children: data.deliveries.map((d, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
4871
+ d.key,
4872
+ ":成功 ",
4873
+ d.ok,
4874
+ " / 失败 ",
4875
+ d.failed,
4876
+ d.failed > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4877
+ className: "ss-error",
4878
+ children: [" ⚠ ", d.lastError ?? "(无说明)"]
4879
+ })
4880
+ ] }, i))
4881
+ })
4882
+ ] })
4883
+ ]
4884
+ });
4885
+ }
4761
4886
  /** 「会话清理」块(v1.29 需求③,S142 用户拍板:lastActive + 手动按钮 + 直接物理删):
4762
4887
  * 天数输入 → GET 预览(dryRun 列表)→ 确认 → POST 执行。live 会话由服务端保护跳过。 */
4763
4888
  function SessionCleanupBlock() {