dsh-cindy-host 0.1.21 → 0.1.23

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,33 @@
3
3
  本文件记录**用户可见**的变化与**每次发布验证过的 DSH 版本**。格式遵循
4
4
  [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
5
5
 
6
+ ## [0.1.23] - 2026-10-14
7
+
8
+ ### Fixed
9
+
10
+ - 手机删除已不存在的 DSH 会话时,在删除标记持久化成功后返回最小删除确认,重复删除也成功,避免手机因 NOT_FOUND 回滚而留下幽灵任务。
11
+ - 仅放宽纯删除元数据请求(可同时取消置顶);归档、重命名和混合编辑仍保留 NOT_FOUND。保存或读取失败不冒充删除成功,不伪造完整会话。
12
+
13
+ ### 验证
14
+
15
+ - 完整测试:770 通过 / 3 跳过 / 0 失败;另设置 CINDY_SOURCE_ROOT 补跑其中 2 项真实双端契约测试,均通过。剩余跳过项为需显式启用的 11 分钟慢上传测试。
16
+ - 通道审计:54 served / 142 declined / 0 unclassified。干净 DSH 0.2.0-rc.2 隔离 profile 加载 tarball 成功,HTTP 连续两次删除不存在会话均返回成功;未构建原生凭据依赖,未登录账号。
17
+ - 旧版整体验收 67/77:存在账号/历史数据前置条件缺失及旧协议断言,不计为全绿。用户明确接受本次专项验证范围后发布;手机端实测及真实宿主重启生效仍需单独确认。
18
+
19
+ ## [0.1.22] - 2026-10-03
20
+
21
+ ### Fixed
22
+
23
+ - 补齐桌面模型选择入口要求的真实上下文容量:Host 在请求级按精确 provider/model 从 DSH llm.resolveModelInfo 获取 context.contextWindow,不再仅转发省略容量的 catalog。三 alias 共用同一结果,查询去重、最多4路并发、总期限3秒,不修改模型路由或默认选择。
24
+ - 保留手机依赖的 routing 存在标记与未知容量0;单模型元数据失败不删除模型、不清空其它模型,不用 defaultMaxTokens 或硬编码数值冒充容量。真正未知容量在旧桌面的 >0 校验下仍会被过滤,需要客户端明确支持未知合同;本补丁不声称修复旧客户端这一固有限制。
25
+ - 双端回归现在经过真实桌面 parseDeviceProvidersPayload 和手机 deviceProvidersCache,再进入共享选择器。保留0.1.21响应“手机可见、桌面全被过滤”的红例;之前仅测共享筛选不构成完整桌面入口验收。
26
+
27
+ ### 验证
28
+
29
+ - 完整测试:768 通过 / 1 跳过 / 0 失败;通道审计 54 served / 142 declined / 0 unclassified。
30
+ - 重启后的真实 HTTP 响应经过桌面解析器与手机缓存入口:新建及已有会话均 Host 7 / Desktop 7 / Mobile 7,2 个供应商、未知容量 0 项。此为本次安装环境观测,不是固定模型数量。
31
+ - 用户已确认当前双端界面测试通过。安装目录补丁与源码一致;本修复由 0.1.22 发布,0.1.21 不包含真实容量补齐。
32
+
6
33
  ## [0.1.21] - 2026-10-02
7
34
 
8
35
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cindy-host",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
@@ -1041,6 +1041,7 @@ export function createChannelRouter({
1041
1041
  wrote = true;
1042
1042
  }
1043
1043
 
1044
+ let deleteAcknowledgement;
1044
1045
  const writesMeta = patch.status !== undefined || patch.pinnedAt !== undefined;
1045
1046
  if (writesMeta) {
1046
1047
  const applyFlags = capabilities.applySessionFlags;
@@ -1053,6 +1054,13 @@ export function createChannelRouter({
1053
1054
  const echoed = {};
1054
1055
  if (patch.status !== undefined) echoed.status = applied.status;
1055
1056
  if (patch.pinnedAt !== undefined) echoed.pinnedAt = applied.pinnedAt;
1057
+ // Deleting an already absent source session is still a successful hide once
1058
+ // its flags are saved. The phone needs only the fields it wrote; do not
1059
+ // fabricate a full session or extend this acknowledgement to mixed edits.
1060
+ if (patch.status === 'deleted' && applied.status === 'deleted'
1061
+ && Object.keys(patch).every((key) => key === 'status' || key === 'pinnedAt')) {
1062
+ deleteAcknowledgement = { id: sessionId, ...echoed };
1063
+ }
1056
1064
  if (typeof capabilities.publishSessionMeta === 'function') capabilities.publishSessionMeta(sessionId, echoed);
1057
1065
  }
1058
1066
 
@@ -1061,6 +1069,7 @@ export function createChannelRouter({
1061
1069
  // unchanged row as "revert my edit" (that is how these buttons looked dead once already).
1062
1070
  if (wrote) invalidateSessionRows();
1063
1071
  const row = await sessionRow(sessionId);
1072
+ if (row === undefined && deleteAcknowledgement !== undefined) return invokeResult(request, deleteAcknowledgement);
1064
1073
  return row === undefined ? invokeError(request, 'NOT_FOUND', `No DSH session ${sessionId}`) : invokeResult(request, row);
1065
1074
  }
1066
1075
 
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
+ }