dsh-cindy-host 0.1.20 → 0.1.22

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/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
3
3
  本文件记录**用户可见**的变化与**每次发布验证过的 DSH 版本**。格式遵循
4
4
  [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
5
5
 
6
+ ## [0.1.22] - 2026-10-03
7
+
8
+ ### Fixed
9
+
10
+ - 补齐桌面模型选择入口要求的真实上下文容量:Host 在请求级按精确 provider/model 从 DSH llm.resolveModelInfo 获取 context.contextWindow,不再仅转发省略容量的 catalog。三 alias 共用同一结果,查询去重、最多4路并发、总期限3秒,不修改模型路由或默认选择。
11
+ - 保留手机依赖的 routing 存在标记与未知容量0;单模型元数据失败不删除模型、不清空其它模型,不用 defaultMaxTokens 或硬编码数值冒充容量。真正未知容量在旧桌面的 >0 校验下仍会被过滤,需要客户端明确支持未知合同;本补丁不声称修复旧客户端这一固有限制。
12
+ - 双端回归现在经过真实桌面 parseDeviceProvidersPayload 和手机 deviceProvidersCache,再进入共享选择器。保留0.1.21响应“手机可见、桌面全被过滤”的红例;之前仅测共享筛选不构成完整桌面入口验收。
13
+
14
+ ### 验证
15
+
16
+ - 完整测试:768 通过 / 1 跳过 / 0 失败;通道审计 54 served / 142 declined / 0 unclassified。
17
+ - 重启后的真实 HTTP 响应经过桌面解析器与手机缓存入口:新建及已有会话均 Host 7 / Desktop 7 / Mobile 7,2 个供应商、未知容量 0 项。此为本次安装环境观测,不是固定模型数量。
18
+ - 用户已确认当前双端界面测试通过。安装目录补丁与源码一致;本修复由 0.1.22 发布,0.1.21 不包含真实容量补齐。
19
+
20
+ ## [0.1.21] - 2026-10-02
21
+
22
+ ### Fixed
23
+
24
+ - 修复桌面供应商目录上线后手机模型选择器为空:`maker:provider:list` 为每个已广告的 DSH agent 别名补齐 `routing[agent]: {}` 可用性标记。仍只由 DSH catalog 决定 connected 和模型清单,不发送 endpoint、认证信息或 headers,不臆造 wireProtocol。
25
+ - 补充投影/通道回归与可选的真实 Cindy registry、分段列表、统一选择器跨仓契约测试;设置 `CINDY_SOURCE_ROOT` 后运行 `node --test test/host-models-cindy-contract.test.js`(需支持 registerHooks/TypeScript stripping 的 Node)。覆盖旧响应复现空列表、新建/已有会话、多来源、不可连接/禁用 runtime 和目录读取失败。
26
+ - 运行期补证:当前 DSH `buildModelCatalog` 不输出 `contextWindow`,旧投影把所有缺失窗口的模型丢弃,导致能力列表有模型而 provider 列表为空。缺失/无效窗口现在以 `0` 表示未知展示容量,不作为模型准入条件;跨仓测试改为真实的无窗口目录结构。
27
+
28
+ ### 验证
29
+
30
+ - 完整单元与契约测试:**759 通过 / 1 跳过 / 0 失败**,包含真实 Cindy 消费端及无 contextWindow 的 DSH 目录结构。
31
+ - 运行中的 desktop Host 返回 **2 个供应商 / 7 个模型**;真实 HTTP 响应经过 Cindy 新建/已有会话筛选均得到 7 个可选项。用户已确认手机端模型列表恢复。
32
+ - 保留桌面供应商目录和真实 DSH 模型路由;不增加客户端专用模型表。
33
+
6
34
  ## [0.1.20] - 2026-10-01
7
35
 
8
36
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cindy-host",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
package/src/dsh-plugin.js CHANGED
@@ -12,6 +12,7 @@ import { createAttachmentMaterializer } from './host-attachments.js';
12
12
  import { createGoalWriter } from './host-goals.js';
13
13
  import { startHost } from './host.js';
14
14
  import { refusalError } from './cindy-channels.js';
15
+ import { enrichModelCatalog } from './host-model-catalog.js';
15
16
  import { hydrateImageAttachments } from './cindy-message-row.js';
16
17
  import { API_PREFIX, createHostRoutes } from './host-routes.js';
17
18
 
@@ -957,7 +958,19 @@ export function buildDshSource(ctx, serviceName, options = {}) {
957
958
  * asks once per device: an empty catalog does not look like a missing
958
959
  * feature, it looks like a Host with nothing to offer.
959
960
  */
960
- modelCatalog: () => sessionController.modelCatalog(),
961
+ modelCatalog: async () => {
962
+ const catalog = await sessionController.modelCatalog();
963
+ const llm = controls.llm;
964
+ const enriched = await enrichModelCatalog(catalog,
965
+ typeof llm?.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : undefined);
966
+ if (enriched?.contextFailures?.length) {
967
+ ctx.logger?.warn?.('Cindy model context metadata unavailable', {
968
+ count: enriched.contextFailures.length,
969
+ codes: [...new Set(enriched.contextFailures.map((item) => item.code))],
970
+ });
971
+ }
972
+ return enriched;
973
+ },
961
974
  /**
962
975
  * Make sure a session has a live agent, and say whether it does.
963
976
  *
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Request-scoped display enrichment, not a second model registry.
3
+ * DSH's catalog owns membership/defaults; llm.resolveModelInfo owns exact-route
4
+ * context capacity. Keep metadata failures separate from catalog failures.
5
+ */
6
+ export const MODEL_CONTEXT_CONCURRENCY = 4;
7
+ // Mobile device-link uses a 15s invoke deadline. Spend at most 3s of that on
8
+ // optional enrichment, leaving the existing catalog/transport their own budget.
9
+ export const MODEL_CONTEXT_TIMEOUT_MS = 3_000;
10
+ const positiveWindow = (value) => Number.isSafeInteger(value) && value > 0;
11
+
12
+ export async function enrichModelCatalog(catalog, resolveModelInfo, {
13
+ timeoutMs = MODEL_CONTEXT_TIMEOUT_MS, signal,
14
+ } = {}) {
15
+ if (!Array.isArray(catalog?.groups)) return catalog;
16
+ const groups = catalog.groups.map((group) => ({ ...group,
17
+ models: (Array.isArray(group?.models) ? group.models : []).map((model) => ({ ...model })),
18
+ }));
19
+ const routes = new Map();
20
+ for (const group of groups) {
21
+ if (typeof group.id !== 'string' || !group.id) continue;
22
+ for (const model of group.models) {
23
+ if (typeof model.id !== 'string' || !model.id || positiveWindow(model.contextWindow)) continue;
24
+ const key = JSON.stringify([group.id, model.id]);
25
+ if (!routes.has(key)) routes.set(key, { provider: group.id, id: model.id, models: [] });
26
+ routes.get(key).models.push(model);
27
+ }
28
+ }
29
+ const pending = [...routes.values()];
30
+ const contextFailures = [];
31
+ const failed = (route, code) => contextFailures.push({ provider: route.provider, model: route.id, code });
32
+ if (typeof resolveModelInfo !== 'function') {
33
+ for (const route of pending) failed(route, 'metadata-service-unavailable');
34
+ return { ...catalog, groups, contextFailures };
35
+ }
36
+ if (!pending.length) return { ...catalog, groups, contextFailures };
37
+ const controller = new AbortController();
38
+ const cancel = () => controller.abort();
39
+ if (signal?.aborted) cancel();
40
+ else signal?.addEventListener('abort', cancel, { once: true });
41
+ let expire;
42
+ const expired = new Promise((resolve) => { expire = resolve; });
43
+ const aborted = () => expire({ code: signal?.aborted ? 'metadata-cancelled' : 'metadata-deadline' });
44
+ controller.signal.addEventListener('abort', aborted, { once: true });
45
+ if (controller.signal.aborted) aborted();
46
+ const timer = setTimeout(cancel, timeoutMs);
47
+ let cursor = 0;
48
+ async function worker() {
49
+ while (cursor < pending.length) {
50
+ const route = pending[cursor++];
51
+ if (controller.signal.aborted) {
52
+ failed(route, signal?.aborted ? 'metadata-cancelled' : 'metadata-deadline');
53
+ continue;
54
+ }
55
+ // Observe rejection even if a non-cooperative resolver outlives the deadline.
56
+ const lookup = Promise.resolve()
57
+ .then(() => resolveModelInfo(route.provider, route.id, controller.signal))
58
+ .then((value) => ({ value }), () => ({ code: 'metadata-read-failed' }));
59
+ const result = await Promise.race([lookup, expired]);
60
+ if (result.code) { failed(route, result.code); continue; }
61
+ const info = result.value;
62
+ if (info?.provider !== route.provider || info?.id !== route.id) {
63
+ failed(route, 'metadata-identity-mismatch'); continue;
64
+ }
65
+ const window = info?.context?.contextWindow;
66
+ if (!positiveWindow(window)) { failed(route, 'metadata-unknown'); continue; }
67
+ for (const model of route.models) model.contextWindow = window;
68
+ }
69
+ }
70
+ try {
71
+ await Promise.all(Array.from({ length: Math.min(MODEL_CONTEXT_CONCURRENCY, pending.length) }, worker));
72
+ return { ...catalog, groups, contextFailures };
73
+ } finally {
74
+ clearTimeout(timer);
75
+ signal?.removeEventListener('abort', cancel);
76
+ controller.signal.removeEventListener('abort', aborted);
77
+ }
78
+ }
@@ -160,7 +160,7 @@ export function toPermissionOptions(names) {
160
160
  }
161
161
 
162
162
  /**
163
- * Project DSH's provider-grouped catalog into Cindy desktop's ProviderView wire shape.
163
+ * Project DSH's provider-grouped catalog into Cindy desktop/mobile ProviderView wire shape.
164
164
  * Routing credentials never cross this boundary; every Cindy harness alias reaches the
165
165
  * same DSH catalog, so each provider exposes the same models under all aliases.
166
166
  */
@@ -173,8 +173,11 @@ export function toProviderList(catalog, agentKinds = []) {
173
173
  const models = [];
174
174
  for (const model of Array.isArray(group.models) ? group.models : []) {
175
175
  if (typeof model?.id !== 'string' || model.id === '') continue;
176
- const contextWindow = Number(model.contextWindow);
177
- if (!Number.isFinite(contextWindow) || contextWindow <= 0) continue;
176
+ // Current DSH catalogs omit contextWindow. It is display metadata, not
177
+ // route eligibility: 0 means unknown in Cindy and hides the capacity label.
178
+ // Never discard a routable model or invent a positive capacity for it.
179
+ const window = Number(model.contextWindow);
180
+ const contextWindow = Number.isFinite(window) && window > 0 ? window : 0;
178
181
  const efforts = Array.isArray(model?.reasoning?.efforts)
179
182
  ? model.reasoning.efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '')
180
183
  : [];
@@ -198,6 +201,10 @@ export function toProviderList(catalog, agentKinds = []) {
198
201
  name: typeof group.name === 'string' && group.name !== '' ? group.name : group.id,
199
202
  agents,
200
203
  connected: routable.has(group.id),
204
+ // Cindy registry requires routing[agent] to exist before a runtime is selectable.
205
+ // Empty markers advertise our aliases without inventing a wire protocol or
206
+ // exposing DSH execution details (endpoints, credentials, or headers).
207
+ routing: Object.fromEntries(agents.map((agent) => [agent, {}])),
201
208
  models: Object.fromEntries(agents.map((agent) => [agent, models.map((model) => ({ ...model }))])),
202
209
  });
203
210
  }