@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.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 (146) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +759 -2
  4. package/dist/app.d.ts +384 -0
  5. package/dist/app.js +1148 -0
  6. package/dist/cli.d.ts +22 -0
  7. package/dist/cli.js +354 -0
  8. package/dist/config.d.ts +97 -0
  9. package/dist/config.js +171 -0
  10. package/dist/handlers/agentHandlers.d.ts +18 -0
  11. package/dist/handlers/agentHandlers.js +20 -0
  12. package/dist/handlers/assetHandlers.d.ts +29 -0
  13. package/dist/handlers/assetHandlers.js +35 -0
  14. package/dist/handlers/capabilities.d.ts +26 -0
  15. package/dist/handlers/capabilities.js +138 -0
  16. package/dist/handlers/completionHandlers.d.ts +18 -0
  17. package/dist/handlers/completionHandlers.js +13 -0
  18. package/dist/handlers/configHandlers.d.ts +31 -0
  19. package/dist/handlers/configHandlers.js +59 -0
  20. package/dist/handlers/conversationHandlers.d.ts +118 -0
  21. package/dist/handlers/conversationHandlers.js +314 -0
  22. package/dist/handlers/handoffHandlers.d.ts +17 -0
  23. package/dist/handlers/handoffHandlers.js +14 -0
  24. package/dist/handlers/hostHandlers.d.ts +39 -0
  25. package/dist/handlers/hostHandlers.js +56 -0
  26. package/dist/handlers/index.d.ts +128 -0
  27. package/dist/handlers/index.js +92 -0
  28. package/dist/handlers/messageHandlers.d.ts +11 -0
  29. package/dist/handlers/messageHandlers.js +10 -0
  30. package/dist/handlers/modelHandlers.d.ts +17 -0
  31. package/dist/handlers/modelHandlers.js +19 -0
  32. package/dist/handlers/noticeHandlers.d.ts +24 -0
  33. package/dist/handlers/noticeHandlers.js +24 -0
  34. package/dist/handlers/questionHandlers.d.ts +16 -0
  35. package/dist/handlers/questionHandlers.js +16 -0
  36. package/dist/handlers/searchHandlers.d.ts +12 -0
  37. package/dist/handlers/searchHandlers.js +12 -0
  38. package/dist/handlers/workspaceHandlers.d.ts +29 -0
  39. package/dist/handlers/workspaceHandlers.js +47 -0
  40. package/dist/i18n/en.d.ts +243 -0
  41. package/dist/i18n/en.js +270 -0
  42. package/dist/i18n/index.d.ts +43 -0
  43. package/dist/i18n/index.js +72 -0
  44. package/dist/i18n/zh.d.ts +240 -0
  45. package/dist/i18n/zh.js +257 -0
  46. package/dist/index.d.ts +17 -0
  47. package/dist/index.js +15 -0
  48. package/dist/net/UploadReceiver.d.ts +186 -0
  49. package/dist/net/UploadReceiver.js +494 -0
  50. package/dist/net/WsHub.d.ts +231 -0
  51. package/dist/net/WsHub.js +689 -0
  52. package/dist/net/auth.d.ts +73 -0
  53. package/dist/net/auth.js +74 -0
  54. package/dist/net/discovery.d.ts +72 -0
  55. package/dist/net/discovery.js +171 -0
  56. package/dist/net/index.d.ts +14 -0
  57. package/dist/net/index.js +10 -0
  58. package/dist/runtime/ChatRunner.d.ts +374 -0
  59. package/dist/runtime/ChatRunner.js +1204 -0
  60. package/dist/runtime/ConversationDrafts.d.ts +43 -0
  61. package/dist/runtime/ConversationDrafts.js +47 -0
  62. package/dist/runtime/RunRegistry.d.ts +79 -0
  63. package/dist/runtime/RunRegistry.js +108 -0
  64. package/dist/runtime/ScopeRegistry.d.ts +153 -0
  65. package/dist/runtime/ScopeRegistry.js +259 -0
  66. package/dist/runtime/TransientRunner.d.ts +90 -0
  67. package/dist/runtime/TransientRunner.js +179 -0
  68. package/dist/runtime/index.d.ts +14 -0
  69. package/dist/runtime/index.js +10 -0
  70. package/dist/services/AgentService.d.ts +150 -0
  71. package/dist/services/AgentService.js +718 -0
  72. package/dist/services/AssetService.d.ts +338 -0
  73. package/dist/services/AssetService.js +739 -0
  74. package/dist/services/AutoNaming.d.ts +92 -0
  75. package/dist/services/AutoNaming.js +235 -0
  76. package/dist/services/CompletionService.d.ts +122 -0
  77. package/dist/services/CompletionService.js +442 -0
  78. package/dist/services/ConfigService.d.ts +171 -0
  79. package/dist/services/ConfigService.js +695 -0
  80. package/dist/services/ConversationService.d.ts +274 -0
  81. package/dist/services/ConversationService.js +902 -0
  82. package/dist/services/CredentialService.d.ts +33 -0
  83. package/dist/services/CredentialService.js +86 -0
  84. package/dist/services/DiscoveryService.d.ts +182 -0
  85. package/dist/services/DiscoveryService.js +998 -0
  86. package/dist/services/HandoffService.d.ts +189 -0
  87. package/dist/services/HandoffService.js +414 -0
  88. package/dist/services/Logger.d.ts +130 -0
  89. package/dist/services/Logger.js +256 -0
  90. package/dist/services/MessageService.d.ts +88 -0
  91. package/dist/services/MessageService.js +280 -0
  92. package/dist/services/ModelService.d.ts +89 -0
  93. package/dist/services/ModelService.js +489 -0
  94. package/dist/services/NoticeService.d.ts +93 -0
  95. package/dist/services/NoticeService.js +217 -0
  96. package/dist/services/QuestionService.d.ts +184 -0
  97. package/dist/services/QuestionService.js +457 -0
  98. package/dist/services/SearchService.d.ts +54 -0
  99. package/dist/services/SearchService.js +117 -0
  100. package/dist/services/WorkspaceService.d.ts +96 -0
  101. package/dist/services/WorkspaceService.js +213 -0
  102. package/dist/services/imageSize.d.ts +41 -0
  103. package/dist/services/imageSize.js +146 -0
  104. package/dist/services/index.d.ts +20 -0
  105. package/dist/services/index.js +15 -0
  106. package/dist/services/secretMask.d.ts +24 -0
  107. package/dist/services/secretMask.js +34 -0
  108. package/dist/storage/AssetRepository.d.ts +87 -0
  109. package/dist/storage/AssetRepository.js +180 -0
  110. package/dist/storage/ConversationRepository.d.ts +55 -0
  111. package/dist/storage/ConversationRepository.js +128 -0
  112. package/dist/storage/CredentialRepository.d.ts +28 -0
  113. package/dist/storage/CredentialRepository.js +47 -0
  114. package/dist/storage/Database.d.ts +95 -0
  115. package/dist/storage/Database.js +180 -0
  116. package/dist/storage/MessageRepository.d.ts +104 -0
  117. package/dist/storage/MessageRepository.js +323 -0
  118. package/dist/storage/MetaRepository.d.ts +21 -0
  119. package/dist/storage/MetaRepository.js +33 -0
  120. package/dist/storage/NoticeRepository.d.ts +31 -0
  121. package/dist/storage/NoticeRepository.js +89 -0
  122. package/dist/storage/QuestionRepository.d.ts +34 -0
  123. package/dist/storage/QuestionRepository.js +96 -0
  124. package/dist/storage/WorkspaceRepository.d.ts +25 -0
  125. package/dist/storage/WorkspaceRepository.js +44 -0
  126. package/dist/storage/dbWorker.d.ts +10 -0
  127. package/dist/storage/dbWorker.js +132 -0
  128. package/dist/storage/index.d.ts +20 -0
  129. package/dist/storage/index.js +16 -0
  130. package/dist/storage/migrations.d.ts +26 -0
  131. package/dist/storage/migrations.js +207 -0
  132. package/dist/storage/types.d.ts +298 -0
  133. package/dist/storage/types.js +14 -0
  134. package/dist/wire/errors.d.ts +105 -0
  135. package/dist/wire/errors.js +143 -0
  136. package/dist/wire/events.d.ts +102 -0
  137. package/dist/wire/events.js +13 -0
  138. package/dist/wire/frames.d.ts +106 -0
  139. package/dist/wire/frames.js +156 -0
  140. package/dist/wire/index.d.ts +31 -0
  141. package/dist/wire/index.js +31 -0
  142. package/dist/wire/methods.d.ts +1896 -0
  143. package/dist/wire/methods.js +99 -0
  144. package/dist/wire/states.d.ts +487 -0
  145. package/dist/wire/states.js +61 -0
  146. package/package.json +63 -3
@@ -0,0 +1,695 @@
1
+ /**
2
+ * 配置服务:`config.*`(F-F1 / F-F2 / F-E2 / F-F6 / F-F7 / F-A8 / D21)。
3
+ *
4
+ * 配置只有一处真相:数据根下的 `config.json`,读写都经 SDK 的 `ConfigManager`(D21)。
5
+ * 这个服务负责三件事:
6
+ *
7
+ * 1. **读得安全**:端点密钥读出即掩码(F-F2),明文不出网;顺带把「这份配置哪里不对」
8
+ * 结构化地报出来(F-A8:配错了要能当场修,问题指到具体字段)。
9
+ * 2. **写得及时**:每次写都回报「应用了什么」—— 哪些能力被重载、哪些 Workspace 的 Scope
10
+ * 被回收(随之重连 MCP)。改完不用重启(F-E2),客户端据此提示「已生效」。
11
+ * 3. **删得干净**:被引用的端点拒删并列出引用者(F-F2),默认项指向的东西不存在时
12
+ * 要能指出来(F-F7)。
13
+ *
14
+ * 「保存」这个动作本身不是状态:每次写就是一次生效(F-F1 没有全局保存键)。
15
+ */
16
+ import { DEFAULT_LANGUAGE, LANGUAGES, normalizeLanguage, render } from "../i18n/index.js";
17
+ import { HostError } from "../wire/errors.js";
18
+ import { MAX_ASSETS_PER_MESSAGE, MAX_ASSET_BYTES } from "../wire/index.js";
19
+ import { DiscoveryService } from "./DiscoveryService.js";
20
+ import { ModelService } from "./ModelService.js";
21
+ import { isSecretMask, maskSecret } from "./secretMask.js";
22
+ /** `host.reload` 认的几档范围(与契约同源) */
23
+ const RELOAD_SCOPES = ["config", "agents", "tools", "skills", "mcp"];
24
+ /** 可以写的三档行为开关取值(与 SDK `AppConfig` 的取值一致) */
25
+ const ABORTED_MESSAGE_STRATEGIES = ["keep", "drop"];
26
+ const ABORTED_TOOL_ROUND_STRATEGIES = ["fill", "drop"];
27
+ const AUTO_REFRESH_TOOLS = ["off", "outer-loop-start", "inner-loop-start"];
28
+ /** `maxToolOutput` 读不到时的兜底值(与出厂配置一致) */
29
+ const FALLBACK_MAX_TOOL_OUTPUT = 32_768;
30
+ // ---------------------------------------------------------------------------
31
+ // 文案与掩码
32
+ // ---------------------------------------------------------------------------
33
+ /** 一条给人看的事实(码 = 文案键,与素材层同口径:存码不存句子,D8) */
34
+ function text(messageKey, params = {}) {
35
+ return {
36
+ code: messageKey,
37
+ messageKey,
38
+ params,
39
+ message: render(DEFAULT_LANGUAGE, messageKey, params),
40
+ };
41
+ }
42
+ /** 必填的文本参数 */
43
+ function requireText(value, field) {
44
+ const raw = typeof value === "string" ? value.trim() : "";
45
+ if (raw === "") {
46
+ throw new HostError("invalid_params", {
47
+ params: { field },
48
+ detail: { field, reason: "empty" },
49
+ });
50
+ }
51
+ return raw;
52
+ }
53
+ /** 端点的地址至少要像个 http(s) 地址:拼错的地址会在第一次发请求时才炸出来 */
54
+ function requireBaseUrl(value) {
55
+ const raw = requireText(value, "endpoint.baseUrl");
56
+ if (!/^https?:\/\//i.test(raw)) {
57
+ throw new HostError("invalid_params", {
58
+ params: { field: "endpoint.baseUrl" },
59
+ detail: { field: "endpoint.baseUrl", reason: "not_http_url", value: raw },
60
+ });
61
+ }
62
+ return raw.replace(/\/+$/, "");
63
+ }
64
+ /** 只保留「入参真的给了」的字段(`undefined` 表示这次不改它) */
65
+ function definedOnly(input) {
66
+ const result = {};
67
+ for (const [key, value] of Object.entries(input)) {
68
+ if (value !== undefined)
69
+ result[key] = value;
70
+ }
71
+ return result;
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // 服务
75
+ // ---------------------------------------------------------------------------
76
+ export class ConfigService {
77
+ configManager;
78
+ modelService;
79
+ discoveryService;
80
+ agents;
81
+ findWorkspace;
82
+ applyPort;
83
+ now;
84
+ constructor(options) {
85
+ this.configManager = options.configManager;
86
+ this.agents = options.agents;
87
+ this.findWorkspace = options.findWorkspace;
88
+ this.modelService =
89
+ options.models ?? new ModelService({ configManager: options.configManager, agents: options.agents });
90
+ this.discoveryService =
91
+ options.discovery ??
92
+ new DiscoveryService({
93
+ configManager: options.configManager,
94
+ findWorkspace: options.findWorkspace,
95
+ });
96
+ this.applyPort = options.apply;
97
+ this.now = options.now ?? Date.now;
98
+ }
99
+ /** 能力发现服务(组装层要 `scopeAbilityPaths` / `mcpPaths` 时从这里拿同一份) */
100
+ get discovery() {
101
+ return this.discoveryService;
102
+ }
103
+ // -------------------------------------------------------------------------
104
+ // 读
105
+ // -------------------------------------------------------------------------
106
+ /** `config.get`(F-F1 / D21:读也走宿主,密钥掩码,同时报出配置问题) */
107
+ async get(_params = {}) {
108
+ const config = await this.readConfig();
109
+ return { config: await this.toView(config), issues: await this.issuesOf(config) };
110
+ }
111
+ /** `config.getDiscovery`(F-G1 / F-G2 / F-F6):转给发现服务,同一份口径 */
112
+ async getDiscovery(params = {}) {
113
+ return await this.discoveryService.discover(params);
114
+ }
115
+ // -------------------------------------------------------------------------
116
+ // 写:小设置
117
+ // -------------------------------------------------------------------------
118
+ /**
119
+ * `config.patch`(F-F1 / F-E2):默认项、上限、行为开关这些散落的小设置。
120
+ *
121
+ * 端点与模型各有自己的写方法(要做引用检查与掩码处理),不从这里进。
122
+ */
123
+ async patch(params) {
124
+ const config = await this.readConfig();
125
+ const patch = params.patch ?? {};
126
+ const sections = [];
127
+ let next = config;
128
+ if (patch.defaults !== undefined) {
129
+ next = { ...next, ...(await this.mergeDefaults(next, patch.defaults)) };
130
+ sections.push("defaults");
131
+ }
132
+ if (patch.limits !== undefined) {
133
+ next = { ...next, ...this.mergeLimits(next, patch.limits) };
134
+ sections.push("limits");
135
+ }
136
+ if (patch.behavior !== undefined) {
137
+ next = { ...next, ...this.mergeBehavior(next, patch.behavior) };
138
+ sections.push("behavior");
139
+ }
140
+ if (sections.length === 0) {
141
+ // 空补丁也要回报一次(客户端拿它当「已是最新」的确认),但不写盘
142
+ return { config: await this.toView(config), applied: await this.apply({ sections, config: false }) };
143
+ }
144
+ await this.configManager.write(next);
145
+ return {
146
+ config: await this.toView(next),
147
+ applied: await this.apply({ config: true, sections }),
148
+ };
149
+ }
150
+ /**
151
+ * `config.setDefaults`(F-F7 / D2 / D6 / R58)。
152
+ *
153
+ * 与 `config.patch` 的 `defaults` 是同一件事,只是入口更窄:默认助手、兜底模型、
154
+ * 迁移模型、图片模型、取名模型、自动命名开关,以及缺省语言(`hello` 没声明语言时用哪种)。
155
+ */
156
+ async setDefaults(params) {
157
+ const config = await this.readConfig();
158
+ const defaults = params.defaults ?? {};
159
+ const next = { ...config, ...(await this.mergeDefaults(config, defaults)) };
160
+ await this.configManager.write(next);
161
+ return {
162
+ config: await this.toView(next),
163
+ applied: await this.apply({ config: true, sections: ["defaults"] }),
164
+ };
165
+ }
166
+ /**
167
+ * `host.reload`(PRD F-E1 / 裁定 R63):从磁盘重读一遍,让改动生效。
168
+ *
169
+ * 不写盘:改的是磁盘上的东西(`config.json`、助手定义、技能 / 工具目录、MCP 文件),这里只
170
+ * 把「哪几类要重新装配」交给注入的生效一跳(回收受影响的 Scope + 合成一条通知),出参与
171
+ * `config.patch` 同一份形状,客户端于是可以用同一套提示。
172
+ */
173
+ async reload(params = {}) {
174
+ const scope = params.scope;
175
+ if (scope !== undefined && !RELOAD_SCOPES.includes(scope)) {
176
+ throw new HostError("invalid_params", {
177
+ params: { field: "scope" },
178
+ detail: { field: "scope", reason: "not_allowed", value: scope, allowed: [...RELOAD_SCOPES] },
179
+ });
180
+ }
181
+ const reloaded = reloadedOf(scope);
182
+ const config = await this.readConfig();
183
+ const outcome = this.applyPort ? await this.applyPort.apply(reloadChangeOf(reloaded)) : {};
184
+ return {
185
+ config: await this.toView(config),
186
+ applied: {
187
+ appliedAt: new Date(this.now()).toISOString(),
188
+ reloaded,
189
+ recycledWorkspaceIds: outcome.recycledWorkspaceIds ?? [],
190
+ // 重载本身不需要重启:Scope 回收之后下一次请求会按新读到的磁盘状态重建
191
+ restartRequired: false,
192
+ noticeId: outcome.noticeId ?? null,
193
+ },
194
+ };
195
+ }
196
+ // -------------------------------------------------------------------------
197
+ // 写:端点
198
+ // -------------------------------------------------------------------------
199
+ /**
200
+ * `config.saveEndpoint`(F-F2):同一个方法兼创建与修改。
201
+ *
202
+ * `apiKey` 省略 = 保留原值。把掩码当密钥存回去是这条路上最容易犯的错,所以:
203
+ * - 省略 → 保留;
204
+ * - 传回来的值正好等于当前掩码 → 当作「没改」(客户端把读到的整行原样写回来了);
205
+ * - 空串 → 把密钥清掉(这是唯一能清掉它的写法)。
206
+ */
207
+ async saveEndpoint(params) {
208
+ const input = params.endpoint;
209
+ const id = requireText(input?.id, "endpoint.id");
210
+ const config = await this.readConfig();
211
+ const existing = config.endpoints.find((endpoint) => endpoint.id === id);
212
+ const apiKey = this.resolveApiKey(input.apiKey, existing);
213
+ const saved = {
214
+ ...(existing ?? { id, name: "", baseUrl: "", apiKey: "" }),
215
+ id,
216
+ name: requireText(input.name, "endpoint.name"),
217
+ baseUrl: requireBaseUrl(input.baseUrl),
218
+ apiKey,
219
+ ...definedOnly({
220
+ description: input.description,
221
+ capabilities: input.capabilities,
222
+ }),
223
+ };
224
+ const endpoints = existing
225
+ ? config.endpoints.map((endpoint) => (endpoint.id === id ? saved : endpoint))
226
+ : [...config.endpoints, saved];
227
+ const next = { ...config, endpoints };
228
+ await this.configManager.write(next);
229
+ return {
230
+ endpoint: toEndpointView(saved),
231
+ applied: await this.apply({ config: true, sections: ["endpoints"] }),
232
+ };
233
+ }
234
+ /**
235
+ * `config.deleteEndpoint`(F-F2):被模型引用时拒删并列出引用者。
236
+ *
237
+ * `dryRun` 只算引用:客户端先拿这份清单去问用户(F-F9 的二次确认)。
238
+ */
239
+ async deleteEndpoint(params) {
240
+ const endpointId = requireText(params.endpointId, "endpointId");
241
+ const config = await this.readConfig();
242
+ if (!config.endpoints.some((endpoint) => endpoint.id === endpointId)) {
243
+ throw new HostError("not_found", { params: { entity: "endpoint" }, detail: { endpointId } });
244
+ }
245
+ const references = referencesToEndpoint(config, endpointId);
246
+ if (references.referenced) {
247
+ throw new HostError("config_invalid", {
248
+ messageKey: "config.referenced",
249
+ params: {
250
+ count: references.items.length,
251
+ names: references.items.map((item) => item.name).join(", "),
252
+ },
253
+ detail: { endpointId, references },
254
+ });
255
+ }
256
+ if (params.dryRun === true)
257
+ return { removed: false, references };
258
+ const next = {
259
+ ...config,
260
+ endpoints: config.endpoints.filter((endpoint) => endpoint.id !== endpointId),
261
+ };
262
+ await this.configManager.write(next);
263
+ return { removed: true, references };
264
+ }
265
+ // -------------------------------------------------------------------------
266
+ // 写:MCP(按 id 增删改)
267
+ // -------------------------------------------------------------------------
268
+ /**
269
+ * `config.saveMcp`(F-F6 / F-G1):有 id 则改、无则建,一次只动一条。
270
+ *
271
+ * `workspace` 作用域要落到某个项目自己的 `.ai-zen/mcp.json` 里,所以必须给 `workspaceId`。
272
+ * 合并与落盘的规则不在这里:发现服务的写入口只作一件事(把这一条叠到磁盘原文上),
273
+ * 于是这一版不认识的字段与 `oauth` 原样保留(F-F6)。
274
+ */
275
+ async saveMcp(params) {
276
+ const cwd = await this.mcpWriteCwd(params.scope, params.workspaceId);
277
+ const layer = await this.discoveryService.writeMcpEntry(params.scope, params.entry, cwd);
278
+ return {
279
+ layer,
280
+ applied: await this.apply({
281
+ mcp: true,
282
+ sections: ["mcp"],
283
+ workspaceIds: params.scope === "workspace" && params.workspaceId ? [params.workspaceId] : [],
284
+ }),
285
+ };
286
+ }
287
+ /**
288
+ * `config.deleteMcp`(F-F6):删掉这一层里的一条。
289
+ *
290
+ * `id` 本来就不在这一层里时不是失败(`removed: false`):重试一次删除、或另一个客户端
291
+ * 先一步删掉了,都不该变成一个错误(与 `config.deleteEndpoint` 的 `removed` 同口径)。
292
+ */
293
+ async deleteMcp(params) {
294
+ const cwd = await this.mcpWriteCwd(params.scope, params.workspaceId);
295
+ const { layer, removed } = await this.discoveryService.deleteMcpEntry(params.scope, params.id, cwd);
296
+ return {
297
+ layer,
298
+ removed,
299
+ applied: await this.apply({
300
+ mcp: true,
301
+ sections: ["mcp"],
302
+ workspaceIds: params.scope === "workspace" && params.workspaceId ? [params.workspaceId] : [],
303
+ }),
304
+ };
305
+ }
306
+ /** MCP 写到哪:`user` 作用域与项目无关,`workspace` 作用域必须能拿到项目的 cwd */
307
+ async mcpWriteCwd(scope, workspaceId) {
308
+ return scope === "workspace" ? await this.requireWorkspaceCwd(workspaceId) : null;
309
+ }
310
+ // -------------------------------------------------------------------------
311
+ // 视图
312
+ // -------------------------------------------------------------------------
313
+ /** 整个配置的只读形状(密钥掩码,F-F2) */
314
+ async toView(config) {
315
+ const presets = await this.configManager.readReasoningPresets();
316
+ return {
317
+ endpoints: config.endpoints.map((endpoint) => toEndpointView(endpoint)),
318
+ models: config.models.map((model) => this.modelService.toView(config, model, presets)),
319
+ imageModels: (config.imageModels ?? []).map((model) => ({
320
+ id: model.id,
321
+ name: model.name,
322
+ endpointId: model.endpointId,
323
+ modelName: model.modelName,
324
+ description: model.description ?? null,
325
+ defaultSize: model.defaultSize ?? null,
326
+ defaultQuality: model.defaultQuality ?? null,
327
+ custom: model.custom === true,
328
+ })),
329
+ defaults: {
330
+ defaultAgent: config.defaultAgent ?? null,
331
+ defaultModel: config.defaultModel ?? null,
332
+ defaultMigrationModel: config.defaultMigrationModel ?? null,
333
+ defaultImageModel: config.defaultImageModel ?? null,
334
+ namingModel: config.namingModel ?? null,
335
+ autoTitle: config.autoTitle !== false,
336
+ // 没设置就是 `null`(不是 `"en"`):读回来的要能与「设成了英文」区分开
337
+ language: config.language ?? null,
338
+ },
339
+ limits: {
340
+ maxToolOutput: config.maxToolOutput ?? FALLBACK_MAX_TOOL_OUTPUT,
341
+ maxAssetBytes: this.assetLimits().maxAssetBytes,
342
+ maxAssetsPerMessage: this.assetLimits().maxAssetsPerMessage,
343
+ },
344
+ behavior: {
345
+ // 读出来的永远是「运行时真正会用的那一档」:SDK 的迁移会把缺失的中断策略补成出厂默认,
346
+ // 工具刷新时机缺省按 inner-loop-start 算
347
+ abortedMessageStrategy: config.abortedMessageStrategy ?? "keep",
348
+ abortedToolRoundStrategy: config.abortedToolRoundStrategy ?? "fill",
349
+ autoRefreshTools: config.autoRefreshTools ?? "inner-loop-start",
350
+ },
351
+ configDir: this.configManager.basePath,
352
+ };
353
+ }
354
+ // -------------------------------------------------------------------------
355
+ // 内部:校验与合并
356
+ // -------------------------------------------------------------------------
357
+ async readConfig() {
358
+ return await this.configManager.read();
359
+ }
360
+ /** 端点的密钥:省略 / 等于当前掩码 → 保留原值;空串 → 清掉 */
361
+ resolveApiKey(incoming, existing) {
362
+ if (incoming === undefined)
363
+ return existing?.apiKey ?? "";
364
+ if (existing && isSecretMask(incoming, existing.apiKey))
365
+ return existing.apiKey;
366
+ return incoming.trim();
367
+ }
368
+ /**
369
+ * 默认项合并(F-F7)。
370
+ *
371
+ * 每个 id 都要能落到实处:默认助手要真有定义文件,模型要在模型清单里,图片模型要在图片模型
372
+ * 清单里。传 `null` 表示清空(不是「不改」——不改就不传这个键)。
373
+ */
374
+ async mergeDefaults(config, input) {
375
+ const modelIds = new Set(config.models.map((model) => model.id));
376
+ const imageModelIds = new Set((config.imageModels ?? []).map((model) => model.id));
377
+ if (input.defaultAgent !== undefined && input.defaultAgent !== null) {
378
+ const agentId = requireText(input.defaultAgent, "defaults.defaultAgent");
379
+ const exists = this.agents ? (await this.agents.read(agentId)) !== null : true;
380
+ if (!exists) {
381
+ throw new HostError("invalid_params", {
382
+ params: { field: "defaults.defaultAgent" },
383
+ detail: { field: "defaults.defaultAgent", reason: "agent_not_found", agentId },
384
+ });
385
+ }
386
+ }
387
+ const requireModel = (value, field) => {
388
+ if (value === undefined || value === null)
389
+ return;
390
+ const modelId = requireText(value, field);
391
+ if (!modelIds.has(modelId)) {
392
+ throw new HostError("invalid_params", {
393
+ params: { field },
394
+ detail: { field, reason: "model_not_found", modelId },
395
+ });
396
+ }
397
+ };
398
+ requireModel(input.defaultModel, "defaults.defaultModel");
399
+ requireModel(input.defaultMigrationModel, "defaults.defaultMigrationModel");
400
+ requireModel(input.namingModel, "defaults.namingModel");
401
+ if (input.defaultImageModel !== undefined && input.defaultImageModel !== null) {
402
+ const imageModelId = requireText(input.defaultImageModel, "defaults.defaultImageModel");
403
+ if (!imageModelIds.has(imageModelId)) {
404
+ throw new HostError("invalid_params", {
405
+ params: { field: "defaults.defaultImageModel" },
406
+ detail: {
407
+ field: "defaults.defaultImageModel",
408
+ reason: "image_model_not_found",
409
+ imageModelId,
410
+ },
411
+ });
412
+ }
413
+ }
414
+ // `null` → 清空:字段删掉,读出来就是「还没选」
415
+ return {
416
+ defaultAgent: pickDefault(config.defaultAgent, input.defaultAgent),
417
+ defaultModel: pickDefault(config.defaultModel, input.defaultModel),
418
+ defaultMigrationModel: pickDefault(config.defaultMigrationModel, input.defaultMigrationModel),
419
+ defaultImageModel: pickDefault(config.defaultImageModel, input.defaultImageModel),
420
+ namingModel: pickDefault(config.namingModel, input.namingModel),
421
+ autoTitle: input.autoTitle === undefined ? config.autoTitle : input.autoTitle,
422
+ language: pickLanguage(config.language, input.language, "defaults.language"),
423
+ };
424
+ }
425
+ /** 上限合并(F-F8:工具输出上限) */
426
+ mergeLimits(config, input) {
427
+ if (input.maxToolOutput === undefined)
428
+ return { maxToolOutput: config.maxToolOutput };
429
+ const value = input.maxToolOutput;
430
+ if (!Number.isInteger(value) || value <= 0) {
431
+ throw new HostError("invalid_params", {
432
+ params: { field: "limits.maxToolOutput" },
433
+ detail: { field: "limits.maxToolOutput", reason: "not_positive_integer", value },
434
+ });
435
+ }
436
+ return { maxToolOutput: value };
437
+ }
438
+ /** 行为开关合并(D15 的中断修复口径 + F-E2 的工具刷新时机) */
439
+ mergeBehavior(config, input) {
440
+ if (input.abortedMessageStrategy !== undefined) {
441
+ requireChoice(input.abortedMessageStrategy, "behavior.abortedMessageStrategy", ABORTED_MESSAGE_STRATEGIES);
442
+ }
443
+ if (input.abortedToolRoundStrategy !== undefined) {
444
+ requireChoice(input.abortedToolRoundStrategy, "behavior.abortedToolRoundStrategy", ABORTED_TOOL_ROUND_STRATEGIES);
445
+ }
446
+ if (input.autoRefreshTools !== undefined) {
447
+ requireChoice(input.autoRefreshTools, "behavior.autoRefreshTools", AUTO_REFRESH_TOOLS);
448
+ }
449
+ return {
450
+ abortedMessageStrategy: input.abortedMessageStrategy ?? config.abortedMessageStrategy,
451
+ abortedToolRoundStrategy: input.abortedToolRoundStrategy ?? config.abortedToolRoundStrategy,
452
+ autoRefreshTools: input.autoRefreshTools ?? config.autoRefreshTools,
453
+ };
454
+ }
455
+ /** workspace 作用域的 MCP 要写到项目里,所以必须能拿到 cwd */
456
+ async requireWorkspaceCwd(workspaceId) {
457
+ const id = requireText(workspaceId, "workspaceId");
458
+ const workspace = this.findWorkspace ? await this.findWorkspace(id) : undefined;
459
+ if (!workspace) {
460
+ throw new HostError("not_found", {
461
+ params: { entity: "workspace" },
462
+ detail: {
463
+ workspaceId: id,
464
+ reason: this.findWorkspace ? "workspace_not_found" : "workspace_lookup_not_configured",
465
+ },
466
+ });
467
+ }
468
+ return workspace.cwd;
469
+ }
470
+ /** 附件上限(D5:20MB/张、4 张/条):与 wire 层的常量同源 */
471
+ assetLimits() {
472
+ return { maxAssetBytes: MAX_ASSET_BYTES, maxAssetsPerMessage: MAX_ASSETS_PER_MESSAGE };
473
+ }
474
+ // -------------------------------------------------------------------------
475
+ // 内部:配置问题(F-A8)
476
+ // -------------------------------------------------------------------------
477
+ /**
478
+ * 这份配置现在有什么问题。
479
+ *
480
+ * `error` 是「这么用会立刻失败」(模型指向不存在的端点、默认模型没了);
481
+ * `warning` 是「能跑但不对劲」(端点还没配密钥、交接线不低于硬线)。
482
+ * 每条都带 `path`,客户端据此把光标放到那一栏。
483
+ */
484
+ async issuesOf(config) {
485
+ const issues = [];
486
+ config.endpoints.forEach((endpoint, index) => {
487
+ if (endpoint.apiKey.trim() === "") {
488
+ issues.push({
489
+ severity: "warning",
490
+ code: "endpoint_no_key",
491
+ text: text("endpoint.noKey", { endpoint: endpoint.name === "" ? endpoint.id : endpoint.name }),
492
+ path: `endpoints[${index}].apiKey`,
493
+ });
494
+ }
495
+ });
496
+ config.models.forEach((model, index) => {
497
+ if (!config.endpoints.some((endpoint) => endpoint.id === model.endpointId)) {
498
+ issues.push({
499
+ severity: "error",
500
+ code: "model_endpoint_missing",
501
+ text: text("model.endpointMissing", { model: model.name, id: model.endpointId }),
502
+ path: `models[${index}].endpointId`,
503
+ });
504
+ }
505
+ if (model.tokenThresholds.handoff >= model.tokenThresholds.guard) {
506
+ issues.push({
507
+ severity: "warning",
508
+ code: "threshold_order",
509
+ text: text("config.issue.thresholdOrder", {
510
+ name: model.name,
511
+ handoff: model.tokenThresholds.handoff,
512
+ guard: model.tokenThresholds.guard,
513
+ }),
514
+ path: `models[${index}].tokenThresholds`,
515
+ });
516
+ }
517
+ });
518
+ const modelIds = new Set(config.models.map((model) => model.id));
519
+ const defaultChecks = [
520
+ { field: "defaultModel", value: config.defaultModel },
521
+ { field: "defaultMigrationModel", value: config.defaultMigrationModel },
522
+ { field: "namingModel", value: config.namingModel },
523
+ ];
524
+ for (const check of defaultChecks) {
525
+ if (check.value !== undefined && !modelIds.has(check.value)) {
526
+ issues.push({
527
+ severity: "error",
528
+ code: "default_model_missing",
529
+ text: text("config.issue.defaultMissing", { field: check.field, id: check.value }),
530
+ path: check.field,
531
+ });
532
+ }
533
+ }
534
+ if (config.defaultImageModel !== undefined) {
535
+ const imageIds = new Set((config.imageModels ?? []).map((model) => model.id));
536
+ if (!imageIds.has(config.defaultImageModel)) {
537
+ issues.push({
538
+ severity: "error",
539
+ code: "default_image_model_missing",
540
+ text: text("config.issue.defaultMissing", {
541
+ field: "defaultImageModel",
542
+ id: config.defaultImageModel,
543
+ }),
544
+ path: "defaultImageModel",
545
+ });
546
+ }
547
+ }
548
+ if (config.defaultAgent !== undefined && this.agents) {
549
+ const exists = (await this.agents.read(config.defaultAgent)) !== null;
550
+ if (!exists) {
551
+ issues.push({
552
+ severity: "warning",
553
+ code: "default_agent_missing",
554
+ text: text("config.issue.defaultAgentMissing", { id: config.defaultAgent }),
555
+ path: "defaultAgent",
556
+ });
557
+ }
558
+ }
559
+ return issues;
560
+ }
561
+ // -------------------------------------------------------------------------
562
+ // 内部:生效报告(F-E2)
563
+ // -------------------------------------------------------------------------
564
+ /**
565
+ * 把一次改动「说出来」。
566
+ *
567
+ * `reloaded` 按改动涉及的段落推:端点 / 模型 / 图片模型 / 上限会改变工具与模型的可用性,
568
+ * MCP 会改变工具清单,所以这两种都算 `tools` 重载;`skills` 是每次装配现扫的目录,
569
+ * 配置改动不改变它。
570
+ */
571
+ async apply(change) {
572
+ const mcp = change.mcp === true;
573
+ const sections = change.sections;
574
+ const touchesTools = mcp ||
575
+ sections.some((section) => ["endpoints", "models", "imageModels", "limits"].includes(section));
576
+ const outcome = this.applyPort
577
+ ? await this.applyPort.apply({
578
+ config: change.config === true,
579
+ mcp,
580
+ agents: change.agents === true,
581
+ sections,
582
+ workspaceIds: change.workspaceIds ?? [],
583
+ })
584
+ : {};
585
+ return {
586
+ appliedAt: new Date(this.now()).toISOString(),
587
+ reloaded: {
588
+ config: change.config === true,
589
+ tools: touchesTools,
590
+ skills: false,
591
+ agents: change.agents === true,
592
+ mcp,
593
+ },
594
+ recycledWorkspaceIds: outcome.recycledWorkspaceIds ?? [],
595
+ // 配置变更走「重连能力」这条路(F-E2),没有需要重启才生效的配置项
596
+ restartRequired: false,
597
+ noticeId: outcome.noticeId ?? null,
598
+ };
599
+ }
600
+ }
601
+ // ---------------------------------------------------------------------------
602
+ // 纯函数
603
+ // ---------------------------------------------------------------------------
604
+ /** 端点定义 → 对外形状(密钥只给掩码,F-F2) */
605
+ function toEndpointView(endpoint) {
606
+ const apiKey = endpoint.apiKey ?? "";
607
+ return {
608
+ id: endpoint.id,
609
+ name: endpoint.name,
610
+ baseUrl: endpoint.baseUrl,
611
+ apiKeyMasked: maskSecret(apiKey),
612
+ apiKeySet: apiKey.trim() !== "",
613
+ description: endpoint.description ?? null,
614
+ capabilities: { filesApi: endpoint.capabilities?.filesApi === true },
615
+ };
616
+ }
617
+ /** 谁在指向这个端点:模型与图片模型 */
618
+ function referencesToEndpoint(config, endpointId) {
619
+ const items = [];
620
+ for (const model of config.models) {
621
+ if (model.endpointId === endpointId) {
622
+ items.push({ kind: "model", id: model.id, name: model.name });
623
+ }
624
+ }
625
+ for (const model of config.imageModels ?? []) {
626
+ if (model.endpointId === endpointId) {
627
+ items.push({ kind: "image_model", id: model.id, name: model.name });
628
+ }
629
+ }
630
+ return { referenced: items.length > 0, items };
631
+ }
632
+ /** 这次要求重载的范围里,哪几类东西要重新装配(不给范围 = 全部) */
633
+ function reloadedOf(scope) {
634
+ const all = scope === undefined;
635
+ return {
636
+ config: all || scope === "config",
637
+ tools: all || scope === "tools",
638
+ skills: all || scope === "skills",
639
+ agents: all || scope === "agents",
640
+ mcp: all || scope === "mcp",
641
+ };
642
+ }
643
+ /**
644
+ * 重载要交给生效一跳的改动描述。
645
+ *
646
+ * 只表达「哪几类变了」:重载不写盘,`sections` 于是不参与任何写入,只用来把范围说清楚;
647
+ * `workspaceIds` 留空表示影响所有项目(重读的是全局的磁盘文件)。
648
+ */
649
+ function reloadChangeOf(reloaded) {
650
+ const sections = Object.entries(reloaded)
651
+ .filter(([, on]) => on)
652
+ .map(([section]) => section);
653
+ return {
654
+ config: reloaded.config,
655
+ mcp: reloaded.mcp,
656
+ agents: reloaded.agents,
657
+ sections,
658
+ workspaceIds: [],
659
+ };
660
+ }
661
+ /** `null` 表示清空(返回 `undefined`,写盘时字段消失,读出来就是「还没选」) */
662
+ function pickDefault(current, incoming) {
663
+ if (incoming === undefined)
664
+ return current;
665
+ if (incoming === null)
666
+ return undefined;
667
+ return incoming.trim() === "" ? undefined : incoming.trim();
668
+ }
669
+ /**
670
+ * 缺省语言(裁定 R58):`undefined` 不改,`null` 清掉(回落英文),其余必须是受支持的语言。
671
+ *
672
+ * 不认的值直接驳回而不是悄悄换成英文:界面语言被悄悄改掉,用户会以为自己点错了。
673
+ */
674
+ function pickLanguage(current, incoming, field) {
675
+ if (incoming === undefined)
676
+ return current;
677
+ if (incoming === null)
678
+ return undefined;
679
+ if (typeof incoming !== "string" || !LANGUAGES.includes(incoming)) {
680
+ throw new HostError("invalid_params", {
681
+ params: { field },
682
+ detail: { field, reason: "not_a_language", value: incoming, allowed: [...LANGUAGES] },
683
+ });
684
+ }
685
+ return normalizeLanguage(incoming);
686
+ }
687
+ /** 枚举型字段的取值校验 */
688
+ function requireChoice(value, field, allowed) {
689
+ if (!allowed.includes(value)) {
690
+ throw new HostError("invalid_params", {
691
+ params: { field },
692
+ detail: { field, reason: "not_allowed", value, allowed },
693
+ });
694
+ }
695
+ }