@sema-agent/settings-schema 1.4.1 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,29 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 1.5.0 — 2026-09-04 — 扩展命名空间 + 剥键告警截断标志(sema-server S-118 源头修;新键/新导出=minor)
10
+
11
+ 成案:sema-server 修 cli 用户 P1「`/model` 热加载被装饰键整拒」时在下游连打四层补丁(按 kind 放宽→按域→按键路径闭集→
12
+ 64 满额特判),每层都被下一轮复审打穿;clay 裁「源头修完再发」+ 抽象优先四原则(sema-comms [6351])。根因在本包:
13
+ 「未知键」一个信号被要求同时回答「已知键拼错」(该拒)与「别的写者有意放的扩展键」(该放),按键名扫描分不出意图——
14
+ 意图只能在**声明处**说清。
15
+
16
+ **① `EXTENSION_NAMESPACES`(新导出)+ `isReservedExtensionKey(domain, parentPath, key)`(新导出,分段匹配:父路径 + 键名前缀,不在点号路径上猜——对抗复审 [medium] 采:`semaX-v2`/`semaX.foo` 这类合法前缀成员此前会被后缀文法误报)**:域文档里由别的写者按契约
17
+ 放置、本包刻意不认的键。登记的键照旧被 zod strip(行为零变,写者自己回读文件),但 {@link droppedKeys} / `parseDomainLoud`
18
+ **不点名、不计数**。首条登记:`models` 条目级 `sema[A-Z]…`(壳 cli 的自留字段族,cli [5273] design/cli-190c 实键清单
19
+ 六键 `semaChannel`/`semaLabel`/`semaProbe`/`semaProbeAt`/`semaEffortDefault`/`semaVision`,全条目级、顶层零装饰键 ⇒ 顶层
20
+ `sema*` 仍是未知键)。消费方从此一条规则:**未登记的未知键 = 拼错 = 错误,零特例**。新写者/新层级加行 = minor。
21
+
22
+ **② `unknown-keys-dropped` / `unknown-keys-carried` 告警加可选 `truncatedFrom?: number`**(与 `hosts-grandfathered.truncatedFrom`
23
+ 同形):`keys` 仍有界(`DROP_SCAN_MAX_KEYS`=64),超界时带真实总数——扫描本就 O(文档键数),上限只界告警载荷不界扫描。
24
+ 把有界列表当穷举决策输入正是 S-118 第四层被打穿的形(13 模型 × 6 装饰键填满 64 后拼错键不在列表里);①之后扩展键不计数,
25
+ ②让剩下的截断在消费方看得见。
26
+
27
+ 钉 `test/extension-namespace-1.5.test.ts` 七格(六装饰键零告警且仍 strip / 顶层 sema* 照报 / 前缀契约即前缀(`semaGPT_5`/`semaX-v2`/`semaX.foo` 成员,`semax`/`Sema` 不是)/ S-118 真案
28
+ 78 扩展键 + 1 拼错恰点名 1 / 表门:锚定正则+真实域名 / dropped 与 carried 各自 64+truncatedFrom)。`droppedKeys` /
29
+ `carriedUnknownKeys` 导出签名不变(仍返回有界 `string[]`,是同一次扫描的投影)。消费方:sema-server(提货后删下游四层
30
+ 特判与 `domainNotices` 机制)、cli(不消费告警面,零改;实键面若变请改表)。
31
+
9
32
  ## 1.4.1 — 2026-09-02 — 文案 patch(cli [6037]/[6038] 请件)
10
33
 
11
34
  - 复审出处注词族全清:src 12 处+d.ts 同族(`codex …`→`对抗复审 …`,语义零损)——1.4.0 件⑦「52→1」的三族 regex 未含此词族=漏计根因;npm 包注释=对外文案面,壳 bundle hygiene 禁表命中即发布腿红。
@@ -175,11 +175,15 @@ export type EffectiveReadWarning = {
175
175
  }
176
176
  /** 域文档里那些**不属于该域 schema** 的键:行为照旧(zod 非 strict = 剥掉,前向兼容不动),但点名
177
177
  * 说出来。`keys` = 点号路径(`infraCostRates.toolCalMicroUsd` / `commandPolicy.0.typo`),见
178
- * {@link droppedKeys} 的扫描边界。 */
178
+ * {@link droppedKeys} 的扫描边界。**1.5.0**:①{@link EXTENSION_NAMESPACES} 登记的扩展键(别的写者按契约
179
+ * 放在文档里、本包刻意不认的键,如壳写在 models 条目上的 `sema*`)**不算未知键**——既不点名也不计数;
180
+ * ②`keys` 有界(`DROP_SCAN_MAX_KEYS`),超界时带 `truncatedFrom`(= 真实未知键总数,与 `hosts-grandfathered`
181
+ * 同形),消费方据此知道列表**不是穷举**——把有界诊断当穷举决策输入,是服务端消费方在配置门上三次被复审打穿的根因。 */
179
182
  | {
180
183
  domain: DomainName;
181
184
  kind: "unknown-keys-dropped";
182
185
  keys: string[];
186
+ truncatedFrom?: number;
183
187
  }
184
188
  /** open-world 域(`.passthrough()`)里**已送达但本包不认识**的键:值原样到了消费方手里(承运),
185
189
  * 但拼错的键名同样该有人说 —— 认不认归持有键表的消费方,`keys` 是点号路径。 */
@@ -187,6 +191,7 @@ export type EffectiveReadWarning = {
187
191
  domain: DomainName;
188
192
  kind: "unknown-keys-carried";
189
193
  keys: string[];
194
+ truncatedFrom?: number;
190
195
  }
191
196
  /** `config.d/` 里存在、但本地读取集**永远不会读**的文件(名字不是域,或是域但不便携)。 */
192
197
  | {
@@ -195,6 +200,31 @@ export type EffectiveReadWarning = {
195
200
  domain?: DomainName;
196
201
  why: "not-a-domain" | "non-portable-domain";
197
202
  };
203
+ /**
204
+ * **扩展命名空间**(1.5.0):域文档里**由别的写者按契约放置、本包刻意不认**的键。它们照旧被 zod strip
205
+ * (本包与引擎消费方看不见——这正是契约:壳自己回读文件取值),但 {@link droppedKeys} **不点名、不计数**。
206
+ * 没有这张表,「未知键」一个信号要同时回答「已知键拼错了」(该拒)与「别的写者有意放的扩展键」(该放)
207
+ * 两个问题——按键名扫描永远分不出意图,消费方只能在下游猜(服务端消费方的真案:按 kind→按域→按键路径闭集→
208
+ * 满额特判,四层各被下一轮复审打穿)。意图只能在**声明处**说清:写者在这里登记自己的命名空间,之后
209
+ * 所有未被登记的未知键都是拼错,消费方一条规则:未知键 = 错误,零特例。
210
+ *
211
+ * 登记形(分段):`at` = 父路径正则(点号形含尾点,顶层 `""`,首尾锚定,test 有门)+ `key` = 键名前缀正则;`owner` = 写者;
212
+ * `since` = 契约出处。新写者/新层级来这里加行,并在 CHANGELOG 点名——这是 additive(minor)。
213
+ *
214
+ * · `models` 条目级 `sema[A-Z]…`:壳(cli)的自留字段族(`semaChannel`/`semaLabel`/`semaProbe`/`semaProbeAt`/
215
+ * `semaEffortDefault`/`semaVision`,壳侧 models.json 实键清单 design/cli-190c:「`sema` 前缀是壳侧自留字段的既定
216
+ * 约定」,**全条目级、顶层零装饰键**)。顶层 `sema*` 不在此列 ⇒ 仍是未知键(拼错或越界)。
217
+ */
218
+ export declare const EXTENSION_NAMESPACES: Partial<Record<DomainName, ReadonlyArray<{
219
+ readonly at: RegExp;
220
+ readonly key: RegExp;
221
+ readonly owner: string;
222
+ readonly since: string;
223
+ }>>>;
224
+ /** 该键是否落在 {@link EXTENSION_NAMESPACES} 登记的扩展命名空间里(⇒ 不是未知键)。**分段匹配**:`at` 对父路径(点号形,含尾点;
225
+ * 顶层 = `""`),`key` 对键名本身——不在拼好的点号路径上猜(键名里可以含 `.`,`semaX.foo` 是一个键不是两层),前缀契约就是
226
+ * 前缀契约(`semaGPT_5` / `semaX-v2` 都是成员,后缀不设未声明的文法)。 */
227
+ export declare function isReservedExtensionKey(domain: DomainName, parentPath: string, key: string): boolean;
198
228
  /**
199
229
  * 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`),点号路径形。
200
230
  *
@@ -140,8 +140,33 @@ export function legacyGovernanceFromRuntime(rawRuntime) {
140
140
  }
141
141
  /** 扫描深度上限(嵌套再深的配置文档不存在;有上限才不会被恶意深链拖住)。 */
142
142
  const DROP_SCAN_MAX_DEPTH = 8;
143
- /** 一条警告最多点名多少个键(告警载荷有界;截断即说明,见 {@link droppedKeys})。 */
143
+ /** 一条警告最多点名多少个键(告警载荷有界;超界时警告带 `truncatedFrom` = 真实总数,见 {@link droppedKeys})。 */
144
144
  const DROP_SCAN_MAX_KEYS = 64;
145
+ /**
146
+ * **扩展命名空间**(1.5.0):域文档里**由别的写者按契约放置、本包刻意不认**的键。它们照旧被 zod strip
147
+ * (本包与引擎消费方看不见——这正是契约:壳自己回读文件取值),但 {@link droppedKeys} **不点名、不计数**。
148
+ * 没有这张表,「未知键」一个信号要同时回答「已知键拼错了」(该拒)与「别的写者有意放的扩展键」(该放)
149
+ * 两个问题——按键名扫描永远分不出意图,消费方只能在下游猜(服务端消费方的真案:按 kind→按域→按键路径闭集→
150
+ * 满额特判,四层各被下一轮复审打穿)。意图只能在**声明处**说清:写者在这里登记自己的命名空间,之后
151
+ * 所有未被登记的未知键都是拼错,消费方一条规则:未知键 = 错误,零特例。
152
+ *
153
+ * 登记形(分段):`at` = 父路径正则(点号形含尾点,顶层 `""`,首尾锚定,test 有门)+ `key` = 键名前缀正则;`owner` = 写者;
154
+ * `since` = 契约出处。新写者/新层级来这里加行,并在 CHANGELOG 点名——这是 additive(minor)。
155
+ *
156
+ * · `models` 条目级 `sema[A-Z]…`:壳(cli)的自留字段族(`semaChannel`/`semaLabel`/`semaProbe`/`semaProbeAt`/
157
+ * `semaEffortDefault`/`semaVision`,壳侧 models.json 实键清单 design/cli-190c:「`sema` 前缀是壳侧自留字段的既定
158
+ * 约定」,**全条目级、顶层零装饰键**)。顶层 `sema*` 不在此列 ⇒ 仍是未知键(拼错或越界)。
159
+ */
160
+ export const EXTENSION_NAMESPACES = {
161
+ models: [{ at: /^models\.\d+\.$/, key: /^sema[A-Z]/, owner: "cli", since: "shell key census design/cli-190c (2026-08-26)" }],
162
+ };
163
+ /** 该键是否落在 {@link EXTENSION_NAMESPACES} 登记的扩展命名空间里(⇒ 不是未知键)。**分段匹配**:`at` 对父路径(点号形,含尾点;
164
+ * 顶层 = `""`),`key` 对键名本身——不在拼好的点号路径上猜(键名里可以含 `.`,`semaX.foo` 是一个键不是两层),前缀契约就是
165
+ * 前缀契约(`semaGPT_5` / `semaX-v2` 都是成员,后缀不设未声明的文法)。 */
166
+ export function isReservedExtensionKey(domain, parentPath, key) {
167
+ const rules = EXTENSION_NAMESPACES[domain];
168
+ return rules !== undefined && rules.some((r) => r.at.test(parentPath) && r.key.test(key));
169
+ }
145
170
  /** 纯 JSON 对象(不是数组、不是类实例/带原型的东西)——只对这种做键集对比,别的形状交给 parse 判。 */
146
171
  function isPlainJsonObject(v) {
147
172
  if (v === null || typeof v !== "object" || Array.isArray(v))
@@ -149,30 +174,42 @@ function isPlainJsonObject(v) {
149
174
  const proto = Object.getPrototypeOf(v);
150
175
  return proto === Object.prototype || proto === null;
151
176
  }
152
- function collectDropped(raw, parsed, prefix, depth, spared, out) {
153
- if (depth > DROP_SCAN_MAX_DEPTH || out.length >= DROP_SCAN_MAX_KEYS)
177
+ function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
178
+ if (depth > DROP_SCAN_MAX_DEPTH)
154
179
  return;
155
180
  if (Array.isArray(raw) && Array.isArray(parsed)) {
156
181
  // 长度不同 = 整条元素被丢/被裁(hosts 的 grandfather clamp 有自己的警告),不在本判据内,别造重复噪声。
157
182
  if (raw.length !== parsed.length)
158
183
  return;
159
184
  for (let i = 0; i < raw.length; i++)
160
- collectDropped(raw[i], parsed[i], `${prefix}${i}.`, depth + 1, [], out);
185
+ collectDropped(domain, raw[i], parsed[i], `${prefix}${i}.`, depth + 1, [], acc);
161
186
  return;
162
187
  }
163
188
  if (!isPlainJsonObject(raw) || !isPlainJsonObject(parsed))
164
189
  return;
165
190
  for (const k of Object.keys(raw)) {
166
- if (out.length >= DROP_SCAN_MAX_KEYS)
167
- return;
168
191
  if (depth === 0 && spared.includes(k))
169
192
  continue;
170
193
  if (!Object.prototype.hasOwnProperty.call(parsed, k)) {
171
- out.push(prefix + k);
194
+ if (isReservedExtensionKey(domain, prefix, k))
195
+ continue; // 扩展命名空间:按契约放的键,不是未知键
196
+ acc.total += 1;
197
+ if (acc.keys.length < DROP_SCAN_MAX_KEYS)
198
+ acc.keys.push(prefix + k);
172
199
  continue;
173
200
  }
174
- collectDropped(raw[k], parsed[k], `${prefix}${k}.`, depth + 1, spared, out);
201
+ collectDropped(domain, raw[k], parsed[k], `${prefix}${k}.`, depth + 1, spared, acc);
202
+ }
203
+ }
204
+ function scanDropped(domain, raw, parsed) {
205
+ const acc = { keys: [], total: 0 };
206
+ try {
207
+ collectDropped(domain, raw, parsed, "", 0, domain === "runtime" ? GOVERNANCE_MIRROR_KEYS : [], acc);
208
+ }
209
+ catch {
210
+ return { keys: [], total: 0 }; // 反射/访问器抛错:告警是尽力而为的审计线,绝不能把解析拖下水,更不能回显其错误文案
175
211
  }
212
+ return acc;
176
213
  }
177
214
  /**
178
215
  * 一份域文档里被域 schema **丢掉的键**(zod 对象默认 `"strip"`),点号路径形。
@@ -195,14 +232,7 @@ function collectDropped(raw, parsed, prefix, depth, spared, out) {
195
232
  * 让其错误外泄(错误文案可能带 secret,与 parseDomain 的 R13 同纪律),扫描失败即当作"没看见",不影响解析。
196
233
  */
197
234
  export function droppedKeys(domain, raw, parsed) {
198
- const out = [];
199
- try {
200
- collectDropped(raw, parsed, "", 0, domain === "runtime" ? GOVERNANCE_MIRROR_KEYS : [], out);
201
- }
202
- catch {
203
- return []; // 反射/访问器抛错:告警是尽力而为的审计线,绝不能把解析拖下水,更不能回显其错误文案
204
- }
205
- return out;
235
+ return scanDropped(domain, raw, parsed).keys;
206
236
  }
207
237
  /**
208
238
  * open-world 域(`.passthrough()`)的**已知键表**:路径 → 该层已知键(`""` = 顶层)。键集从 schema 的
@@ -226,23 +256,29 @@ const OPEN_WORLD_KNOWN_KEYS = {
226
256
  * 子树)不再下探,那本就是消费方自定义的内容,逐层猜没有意义。
227
257
  */
228
258
  export function carriedUnknownKeys(domain, parsed) {
259
+ return scanCarried(domain, parsed).keys;
260
+ }
261
+ function scanCarried(domain, parsed) {
229
262
  const table = OPEN_WORLD_KNOWN_KEYS[domain];
263
+ const acc = { keys: [], total: 0 };
230
264
  if (!table || !isPlainJsonObject(parsed))
231
- return [];
232
- const out = [];
265
+ return acc;
233
266
  for (const [path, known] of Object.entries(table)) {
234
267
  const node = path === "" ? parsed : parsed[path];
235
268
  if (!isPlainJsonObject(node))
236
269
  continue;
237
270
  const prefix = path === "" ? "" : `${path}.`;
238
271
  for (const k of Object.keys(node)) {
239
- if (out.length >= DROP_SCAN_MAX_KEYS)
240
- return out;
241
- if (!known.includes(k))
242
- out.push(prefix + k);
272
+ if (known.includes(k))
273
+ continue;
274
+ if (isReservedExtensionKey(domain, prefix, k))
275
+ continue;
276
+ acc.total += 1;
277
+ if (acc.keys.length < DROP_SCAN_MAX_KEYS)
278
+ acc.keys.push(prefix + k);
243
279
  }
244
280
  }
245
- return out;
281
+ return acc;
246
282
  }
247
283
  /**
248
284
  * 一域的 parse + **静默剥键/静默承运两条告警**(0.19.0)。`buildEffective` 与 `FileConfigStore.getDomain`
@@ -252,12 +288,12 @@ export function carriedUnknownKeys(domain, parsed) {
252
288
  export function parseDomainLoud(domain, raw, onWarning) {
253
289
  const parsed = parseDomain(domain, raw);
254
290
  if (onWarning) {
255
- const dropped = droppedKeys(domain, raw, parsed);
256
- if (dropped.length > 0)
257
- onWarning({ domain, kind: "unknown-keys-dropped", keys: dropped });
258
- const carried = carriedUnknownKeys(domain, parsed);
259
- if (carried.length > 0)
260
- onWarning({ domain, kind: "unknown-keys-carried", keys: carried });
291
+ const dropped = scanDropped(domain, raw, parsed);
292
+ if (dropped.keys.length > 0)
293
+ onWarning({ domain, kind: "unknown-keys-dropped", keys: dropped.keys, ...(dropped.total > dropped.keys.length ? { truncatedFrom: dropped.total } : {}) });
294
+ const carried = scanCarried(domain, parsed);
295
+ if (carried.keys.length > 0)
296
+ onWarning({ domain, kind: "unknown-keys-carried", keys: carried.keys, ...(carried.total > carried.keys.length ? { truncatedFrom: carried.total } : {}) });
261
297
  }
262
298
  return parsed;
263
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-registry, sema-server, and the TOC desktop/CLI. One source of truth. (Renamed at 1.0.0 from @sema-agent/registry-core — name and docs only, no behavior change; note the range moves 0.19.0 -> 1.0.0, so a dependency must become ^1.0.0. The old names @sema-agent/registry-core <=0.19.0 and @sema-ai/registry-core remain published, frozen, for migration.)",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",