dsh-expert-group 0.1.1

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.
Files changed (45) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +355 -0
  3. package/THIRD_PARTY_NOTICES.md +35 -0
  4. package/assets/notification-whoosh.wav +0 -0
  5. package/bin/prompt-inspector.mjs +63 -0
  6. package/client-bundle.js +14891 -0
  7. package/client-expert-group-model.js +426 -0
  8. package/client-expert-group-sidebar.js +5401 -0
  9. package/client-freshness.js +364 -0
  10. package/client-model-config.js +2155 -0
  11. package/client-notifications.js +38 -0
  12. package/client.js +6487 -0
  13. package/cordis.patch.yml +10 -0
  14. package/fanout-host.js +1507 -0
  15. package/fanout.js +2981 -0
  16. package/host/composition.js +155 -0
  17. package/host/expert-group-runtime.js +822 -0
  18. package/host/expert-group-snapshot.js +450 -0
  19. package/host/fanout-runtime.js +822 -0
  20. package/host/handoff.js +80 -0
  21. package/host/lineage.js +274 -0
  22. package/host/notifications.js +189 -0
  23. package/host/persistence-health.js +389 -0
  24. package/host/recovery.js +242 -0
  25. package/host/rpc-transport.js +573 -0
  26. package/host/settings-runtime.js +168 -0
  27. package/host/settings-source.js +265 -0
  28. package/host/storage-binding.js +94 -0
  29. package/host/system-toast.js +916 -0
  30. package/image-routing.js +263 -0
  31. package/index.js +9327 -0
  32. package/install.ps1 +219 -0
  33. package/orchestrator.js +239 -0
  34. package/package.json +142 -0
  35. package/prompt-contracts.js +148 -0
  36. package/prompt-inspector.js +584 -0
  37. package/prompt-sidecar.js +610 -0
  38. package/resource-budget.js +540 -0
  39. package/role-registry.js +286 -0
  40. package/rpc-host.js +310 -0
  41. package/scripts/assemble-client.mjs +605 -0
  42. package/scripts/build.mjs +124 -0
  43. package/shared.js +4003 -0
  44. package/storage.js +190 -0
  45. package/uninstall.ps1 +47 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Client 共享边界(浏览器安全的纯 ES 模块)。
3
+ *
4
+ * 这里保存 Client 双面共用的三组纯逻辑,不引用 React、DOM、cordis 或任何 Node API:
5
+ * - freshness boundary:统一 session epoch、请求序列、settings revision、catalog
6
+ * generation 与 fan-out cursor,只保存 JSON 兼容快照,不发请求、不渲染;
7
+ * - RPC 归一:把 DSH Connection envelope 与同源 fetch 归一成同一种 fetch-like
8
+ * Response,错误 code/status/details 在两条传输上完全一致;
9
+ * - settings 提交:动态校验后的提交与本地 revision 辅助同步。
10
+ *
11
+ * client.js 是 DSH 的 classic bundle(`window.__ModuleLoader__.load({ id, factory })`),
12
+ * 不能使用 ESM import;因此它通过下面的 descriptor 契约把本模块登记到同一个
13
+ * ModuleLoader 表里,测试与面板共用同一份实现,不再复制逻辑:
14
+ *
15
+ * globalThis.__ModuleLoader__.load(__DEG_CLIENT_FRESHNESS_DESCRIPTOR__);
16
+ *
17
+ * descriptor.factory 是同步的(DSH 的 require 只做同步 materialize),只依赖
18
+ * globalThis,因此加载顺序不影响它。
19
+ *
20
+ * ## 权威实现与发布装载路径
21
+ *
22
+ * 本文件是 freshness/RPC 的**唯一权威实现**,两条装载路径共用同一份源码:
23
+ * 1. 测试与相邻模块直接 `import` 本模块(ESM);
24
+ * 2. 发布 bundle 由 `scripts/assemble-client.mjs` 在构建期把本文件**投影**成 classic 片段
25
+ * (行首 `export ` 前缀去掉 + 追加"检查点 + ModuleLoader 登记"尾部),排在唯一
26
+ * descriptor 之前执行;因此真实 Host 上一定会登记 `__DEG_CLIENT_FRESHNESS_DESCRIPTOR__`,
27
+ * client.js 的 `require("dsh-expert-group/client-freshness")` 命中同一张模块表并取回它。
28
+ *
29
+ * client.js 里的同名内联实现只是"宿主没有该模块"(旧 Host、缓存里的旧页面)的兼容
30
+ * fallback,不再是无验证的手工副本:构建期差分(`probeFreshnessFace`,见
31
+ * scripts/assemble-client.mjs)与 test/client-shared-boundary.test.mjs 会逐项比较
32
+ * 权威实现、投影片段与内联 fallback,任何漂移都会让 `pnpm build` / `pnpm test` 失败。
33
+ */
34
+
35
+ /** 与 client.js 共用的模块 id:必须与 descriptor.id 完全一致。 */
36
+ export const CLIENT_FRESHNESS_MODULE_ID = "dsh-expert-group/client-freshness";
37
+
38
+ /**
39
+ * classic 投影片段发布同一份导出面用的 globalThis Symbol 键(与相邻侧车模块的
40
+ * `Symbol.for("dsh-expert-group/<name>")` 约定一致;字符串与模块 id 相同是刻意的,
41
+ * 两者分别是模块表键与符号表键,不共享命名空间)。
42
+ * 测试据此证明真实 bundle 装载的是本文件的实现,而不是 client.js 的内联副本。
43
+ */
44
+ export const CLIENT_FRESHNESS_GLOBAL_KEY = "dsh-expert-group/client-freshness";
45
+
46
+ /**
47
+ * RPC 错误 code -> HTTP-ish status。settings 冲突必须是 409(客户端据此刷新并
48
+ * 保留 dirty 草稿),会话缺失是 404,认证/通道问题是 403,取消是 499。
49
+ * @param {object} [error]
50
+ * @returns {number}
51
+ */
52
+ export function rpcErrorStatus(error) {
53
+ const code = error?.code;
54
+ if (code === "settings-conflict") return 409;
55
+ if (code === "not-found" || code === "session-not-found") return 404;
56
+ if (code === "forbidden" || code === "connection-required") return 403;
57
+ if (code === "gateway/cancelled") return 499;
58
+ if (code === "bad-request") return 400;
59
+ return 500;
60
+ }
61
+
62
+ /**
63
+ * 将 Connection envelope 归一为 fetch-like Response,保持两条传输的错误字段一致。
64
+ * @param {object} [result] - { ok, value, error, status }。
65
+ */
66
+ export function normalizePluginRpcResult(result) {
67
+ const ok = result?.ok === true;
68
+ const payload = ok ? result.value : { ok: false, error: result?.error };
69
+ const status = ok ? 200 : (Number.isSafeInteger(result?.status) && result.status > 0 ? result.status : rpcErrorStatus(result?.error));
70
+ return {
71
+ ok,
72
+ status,
73
+ async json() { return payload; },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * 优先使用 DSH Connection 的独立插件 channel,认证和 envelope 由 Connection 统一处理;
79
+ * 没有新 Connection 的旧 Host 才回落到同源 fetch,保持历史客户端可用。
80
+ *
81
+ * @param {object} connection - ctx.connection(可选)。
82
+ * @param {object} request - RPC body。
83
+ * @param {object} [init] - fetch init(signal/credentials/headers 等)。
84
+ * @param {Function} [fetchImpl] - fetch 实现,默认 globalThis.fetch。
85
+ * @param {{path:string, channel:string, endpoint:string}} rpcTarget - 路由常量。
86
+ */
87
+ export async function requestPluginRpc(connection, request, init = {}, fetchImpl = globalThis.fetch, rpcTarget) {
88
+ const rpc = connection?.rpc;
89
+ if (typeof rpc?.call === "function") {
90
+ const result = await rpc.call(rpcTarget.channel, rpcTarget.endpoint, request, init.signal);
91
+ return normalizePluginRpcResult(result);
92
+ }
93
+ if (typeof fetchImpl !== "function") throw new Error("当前 DSH 不支持插件 RPC");
94
+ return fetchImpl(rpcTarget.path, {
95
+ ...init,
96
+ method: init.method ?? "POST",
97
+ credentials: init.credentials ?? "same-origin",
98
+ headers: { "content-type": "application/json", ...(init.headers ?? {}) },
99
+ body: init.body ?? JSON.stringify(request),
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Client 双面共享的 freshness seam:统一 session epoch、请求序列、settings revision、
105
+ * catalog generation 和 fan-out cursor。它只保存 JSON 兼容快照,不渲染、不发请求;
106
+ * 所有迟到响应必须先通过对应 accept 方法,卸载后不会再通知调用方。
107
+ */
108
+ export function createClientFreshnessBoundary({ sessionId = "", onUpdate } = {}) {
109
+ let disposed = false;
110
+ let currentSessionId = typeof sessionId === "string" ? sessionId : "";
111
+ let sessionEpoch = 0;
112
+ let sequence = 0;
113
+ let notify = typeof onUpdate === "function" ? onUpdate : null;
114
+ const latestRequests = new Map();
115
+ let settings = { revision: -1, value: undefined };
116
+ let catalog = { generation: -1, value: undefined };
117
+ let fanout = { sessionId: currentSessionId, generation: -1, cursor: 0, snapshot: undefined };
118
+
119
+ const requestKey = (kind, key = "") => String(kind) + "\u0000" + String(key ?? "");
120
+ const isObject = (value) => value !== null && typeof value === "object";
121
+ const safeRevision = (value) => Number.isSafeInteger(value) && value >= 0;
122
+ const result = (accepted, reason, state) => ({ accepted, reason, ...(state === undefined ? {} : { state }) });
123
+ const emit = (kind, state) => {
124
+ if (disposed || notify === null) return;
125
+ try { notify({ kind, state }); } catch { /* UI 订阅者异常不能破坏响应判定。 */ }
126
+ };
127
+ const tokenIsCurrent = (token) => {
128
+ if (disposed) return false;
129
+ if (token === undefined) return true;
130
+ if (!isObject(token)) return false;
131
+ if (token.epoch !== sessionEpoch || token.sessionId !== currentSessionId) return false;
132
+ return latestRequests.get(token.key) === token.sequence;
133
+ };
134
+
135
+ const api = {
136
+ beginSession(nextSessionId = "") {
137
+ if (disposed) return Object.freeze({ accepted: false, reason: "disposed", sessionId: currentSessionId, epoch: sessionEpoch });
138
+ currentSessionId = typeof nextSessionId === "string" ? nextSessionId : "";
139
+ sessionEpoch += 1;
140
+ sequence += 1;
141
+ latestRequests.clear();
142
+ fanout = { sessionId: currentSessionId, generation: -1, cursor: 0, snapshot: undefined };
143
+ return Object.freeze({ accepted: true, sessionId: currentSessionId, epoch: sessionEpoch, sequence });
144
+ },
145
+ beginRequest(kind, key = "") {
146
+ if (disposed) return Object.freeze({ accepted: false, reason: "disposed", kind, key, sessionId: currentSessionId, epoch: sessionEpoch, sequence });
147
+ const normalizedKey = requestKey(kind, key);
148
+ const token = { accepted: true, kind, key: normalizedKey, sessionId: currentSessionId, epoch: sessionEpoch, sequence: ++sequence };
149
+ latestRequests.set(normalizedKey, token.sequence);
150
+ return Object.freeze(token);
151
+ },
152
+ isCurrent(token) {
153
+ return tokenIsCurrent(token);
154
+ },
155
+ isSessionCurrent(token) {
156
+ return !disposed && isObject(token) && token.epoch === sessionEpoch && token.sessionId === currentSessionId;
157
+ },
158
+ acceptSettingsSnapshot({ revision, value, requestToken } = {}) {
159
+ if (!tokenIsCurrent(requestToken)) return result(false, "stale-request", settings);
160
+ if (!safeRevision(revision)) return result(false, "invalid-revision", settings);
161
+ if (revision < settings.revision) return result(false, "stale-revision", settings);
162
+ if (revision === settings.revision && settings.value !== undefined) return result(false, "duplicate", settings);
163
+ settings = { revision, value };
164
+ emit("settings", settings);
165
+ return result(true, undefined, settings);
166
+ },
167
+ acceptCatalogResponse({ generation, groups, value, requestToken } = {}) {
168
+ if (!tokenIsCurrent(requestToken)) return result(false, "stale-request", catalog);
169
+ if (!safeRevision(generation)) return result(false, "invalid-generation", catalog);
170
+ if (generation < catalog.generation) return result(false, "stale-generation", catalog);
171
+ if (generation === catalog.generation && catalog.value !== undefined) return result(false, "duplicate", catalog);
172
+ catalog = { generation, value: value === undefined ? groups : value };
173
+ emit("catalog", catalog);
174
+ return result(true, undefined, catalog);
175
+ },
176
+ acceptFanoutSnapshot({ sessionId: incomingSessionId, generation, cursor, snapshot, requestToken } = {}) {
177
+ if (incomingSessionId !== currentSessionId) return result(false, "stale-session", fanout);
178
+ if (!tokenIsCurrent(requestToken)) return result(false, "stale-request", fanout);
179
+ if (!safeRevision(generation)) return result(false, "invalid-generation", fanout);
180
+ if (!safeRevision(cursor)) return result(false, "invalid-cursor", fanout);
181
+ if (generation < fanout.generation) return result(false, "stale-generation", fanout);
182
+ if (cursor < fanout.cursor) return result(false, "stale-cursor", fanout);
183
+ if (generation === fanout.generation && cursor === fanout.cursor && fanout.snapshot !== undefined) return result(false, "duplicate", fanout);
184
+ fanout = { sessionId: currentSessionId, generation, cursor, snapshot };
185
+ emit("fanout", fanout);
186
+ return result(true, undefined, fanout);
187
+ },
188
+ acceptFanoutEvents({ sessionId: incomingSessionId, generation, cursor, events, snapshot, requestToken } = {}) {
189
+ return api.acceptFanoutSnapshot({
190
+ sessionId: incomingSessionId,
191
+ generation,
192
+ cursor,
193
+ snapshot: snapshot === undefined ? events : snapshot,
194
+ requestToken,
195
+ });
196
+ },
197
+ acceptFanoutAction({ sessionId: incomingSessionId, generation, requestToken } = {}) {
198
+ if (incomingSessionId !== currentSessionId) return result(false, "stale-session");
199
+ if (!tokenIsCurrent(requestToken)) return result(false, "stale-request");
200
+ if (generation !== undefined && (!safeRevision(generation) || generation < fanout.generation)) return result(false, "stale-generation");
201
+ return result(true, undefined, fanout);
202
+ },
203
+ snapshot() {
204
+ return {
205
+ disposed,
206
+ sessionId: currentSessionId,
207
+ epoch: sessionEpoch,
208
+ settings,
209
+ catalog,
210
+ fanout,
211
+ };
212
+ },
213
+ dispose() {
214
+ if (disposed) return false;
215
+ disposed = true;
216
+ latestRequests.clear();
217
+ notify = null;
218
+ settings = { revision: -1, value: undefined };
219
+ catalog = { generation: -1, value: undefined };
220
+ fanout = { sessionId: currentSessionId, generation: -1, cursor: 0, snapshot: undefined };
221
+ return true;
222
+ },
223
+ };
224
+ return Object.freeze(api);
225
+ }
226
+
227
+ /**
228
+ * 通过 Host 动态校验并提交完整 settings 候选,失败时不改变本地 dirty 草稿。
229
+ *
230
+ * @param {object} settings - 完整候选值。
231
+ * @param {number} revision - 本地观察到的 settings revision。
232
+ * @param {object} deps
233
+ * @param {Function} [deps.request] - legacy fetch 实现。
234
+ * @param {object} [deps.connection] - ctx.connection。
235
+ * @param {Function} deps.normalizeSettingsDraft - 候选归一(由 client.js 注入,依赖角色表)。
236
+ * @param {Array<string>} deps.roles - 启用的角色 id。
237
+ * @param {{path:string, channel:string, endpoint:string}} deps.rpcTarget - RPC 路由常量。
238
+ */
239
+ export async function commitSettingsCandidate(settings, revision, { request = globalThis.fetch, connection, normalizeSettingsDraft, roles, rpcTarget } = {}) {
240
+ const body = { action: "settings-commit", settings, expectedRevision: revision };
241
+ const response = typeof connection?.rpc?.call === "function"
242
+ ? await requestPluginRpc(connection, body, {}, globalThis.fetch, rpcTarget)
243
+ : await request(rpcTarget.path, {
244
+ method: "POST",
245
+ credentials: "same-origin",
246
+ headers: { "content-type": "application/json" },
247
+ body: JSON.stringify(body),
248
+ });
249
+ // settings 冲突必须把 Host 的权威 revision 暴露给调用方:客户端据此只推进本地
250
+ // revision,同时保留 dirty 草稿;缺失该字段时退回 payload.revision 兼容旧 Host。
251
+ return readSettingsCommitOutcome(response, normalizeSettingsDraft, roles);
252
+ }
253
+
254
+ /**
255
+ * 解析 settings 提交响应:成功返回归一后的 settings 与 Host 权威 revision;
256
+ * 失败抛出结构化错误(code/status/currentRevision),调用方据此保留 dirty 草稿。
257
+ * currentRevision 依次取 error.details.revision、error.details.currentRevision、
258
+ * payload.revision,兼容只回传其中之一的 Host 版本。
259
+ *
260
+ * @param {object} response - fetch-like Response(Connection 归一结果或真实 fetch)。
261
+ * @param {Function} normalizeDraft - 候选归一函数(由 client.js 注入角色表)。
262
+ * @param {Array<string>} roles - 启用的角色 id。
263
+ */
264
+ export async function readSettingsCommitOutcome(response, normalizeDraft, roles) {
265
+ let payload;
266
+ try {
267
+ payload = await response.json();
268
+ } catch {
269
+ throw new Error("设置保存响应无效");
270
+ }
271
+ if (!response.ok || payload?.ok !== true) {
272
+ const issue = payload?.error && typeof payload.error === "object" ? payload.error : {};
273
+ const message = issue.message ?? payload?.message;
274
+ const error = new Error(typeof message === "string" ? message : "设置保存失败");
275
+ if (typeof issue.code === "string") error.code = issue.code;
276
+ if (Number.isSafeInteger(response.status) && response.status > 0) error.status = response.status;
277
+ const currentRevision = issue.details?.revision ?? issue.details?.currentRevision ?? payload?.revision;
278
+ if (Number.isSafeInteger(currentRevision) && currentRevision >= 0) error.currentRevision = currentRevision;
279
+ throw error;
280
+ }
281
+ if (!payload.settings || typeof payload.settings !== "object" || Array.isArray(payload.settings)
282
+ || !Number.isSafeInteger(payload.revision) || payload.revision < 0) {
283
+ throw new Error("设置保存响应无效");
284
+ }
285
+ const rolePrompts = payload.rolePrompts !== null && typeof payload.rolePrompts === "object"
286
+ && !Array.isArray(payload.rolePrompts)
287
+ ? payload.rolePrompts
288
+ : void 0;
289
+ return {
290
+ settings: normalizeDraft(payload.settings, roles),
291
+ revision: payload.revision,
292
+ ...(rolePrompts === void 0 ? {} : { rolePrompts }),
293
+ };
294
+ }
295
+
296
+ /**
297
+ * classic bundle 登记用 descriptor:client.js 把它交给 `window.__ModuleLoader__.load`。
298
+ * factory 同步且无参数依赖,加载顺序不影响结果。
299
+ */
300
+ export const __DEG_CLIENT_FRESHNESS_DESCRIPTOR__ = Object.freeze({
301
+ id: CLIENT_FRESHNESS_MODULE_ID,
302
+ factory: () => Object.freeze({
303
+ createClientFreshnessBoundary,
304
+ rpcErrorStatus,
305
+ normalizePluginRpcResult,
306
+ requestPluginRpc,
307
+ commitSettingsCandidate,
308
+ readSettingsCommitOutcome,
309
+ // client.js 通过本字段把 descriptor 登记回同一张表(见 registerClientFreshnessModule)。
310
+ registerClientFreshnessModule,
311
+ }),
312
+ });
313
+
314
+ /**
315
+ * 已登记加载器的记账键。挂在当前 realm 的 globalThis 上而不是模块作用域里:
316
+ * DSH 的 `ClientModuleSystem.register` 对同一个 id 是**抛错**语义
317
+ * (`duplicate factory registration`),而 HMR 只失效主插件 id 后会重新执行同一份
318
+ * bundle(投影片段也会再跑一次),所以"每个加载器实例只登记一次"必须跨执行共享。
319
+ * 副作用是可用性权衡:freshness 源改动在 HMR 期间保持旧模块记录,整页刷新才更新。
320
+ */
321
+ const REGISTERED_LOADERS_KEY = Symbol.for("dsh-expert-group/client-freshness#registered-loaders");
322
+
323
+ /**
324
+ * 取当前 realm 的"已登记加载器"弱集合。用鸭子类型判断而不是 `instanceof`,
325
+ * 避免跨 realm(vm / iframe / 测试替身)时误判并重新登记。
326
+ */
327
+ function registeredLoaderSet(host) {
328
+ const existing = host?.[REGISTERED_LOADERS_KEY];
329
+ if (existing !== null && typeof existing === "object"
330
+ && typeof existing.has === "function" && typeof existing.add === "function" && typeof existing.delete === "function") {
331
+ return existing;
332
+ }
333
+ try {
334
+ // 不枚举、不参与序列化:只是本 realm 的装载记账。
335
+ const created = new WeakSet();
336
+ Object.defineProperty(host, REGISTERED_LOADERS_KEY, { value: created, configurable: true, writable: true });
337
+ return created;
338
+ } catch {
339
+ // globalThis 被冻结/受限时退回"不记账"的旧行为:宁可重复登记交给宿主判定,也不阻断装载。
340
+ return null;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * client.js 的 ModuleLoader adapter:在 bundle 内把本模块登记进同一个模块表。
346
+ * 没有可用加载器或该加载器已登记过时静默跳过(宿主对重复 id 会抛错)。
347
+ *
348
+ * @param {object} [loader] - DSH 的 `window.__ModuleLoader__` 门面。
349
+ * @returns {boolean} 本次是否执行了登记。
350
+ */
351
+ export function registerClientFreshnessModule(loader = globalThis.__ModuleLoader__) {
352
+ if (loader === null || typeof loader !== "object" || typeof loader.load !== "function") return false;
353
+ const registered = registeredLoaderSet(globalThis);
354
+ if (registered !== null && registered.has(loader)) return false;
355
+ if (registered !== null) registered.add(loader);
356
+ try {
357
+ loader.load(__DEG_CLIENT_FRESHNESS_DESCRIPTOR__);
358
+ } catch (error) {
359
+ // 登记失败必须回滚记账:否则下一次执行会误判成"已登记"而永久放弃装载。
360
+ if (registered !== null) registered.delete(loader);
361
+ throw error;
362
+ }
363
+ return true;
364
+ }