@sema-agent/client-core 0.41.0 → 0.43.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.
@@ -234,10 +234,41 @@ export async function loadCatalogWithSources(opts) {
234
234
  const timeoutMs = opts?.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
235
235
  const attempts = [];
236
236
  const warnings = [];
237
- let hit = null;
238
237
  let lastDialed;
238
+ // ── 委托:载荷判决 / 三层合并 / 来源标注全在 resolveModelCatalog ──────────────────────────
239
+ // 传给它的 `fetchJson` 只是「把已经拿到的这一份交出去」的闭包 —— 候选链与传输门是本文件的活,
240
+ // 校验与合并是它的活,两边不重叠也不互相重写。
241
+ const resolveWith = async (p) => {
242
+ const onlineUrl = p?.url ?? lastDialed ?? sources[0];
243
+ const resolveOpts = {
244
+ ...(onlineUrl !== undefined ? { onlineUrl } : {}),
245
+ fetchJson: async () => {
246
+ if (p === null)
247
+ throw new Error(`all ${sources.length} catalog source(s) failed; see attempts[]`);
248
+ return p.doc;
249
+ },
250
+ ...(opts?.overrides !== undefined ? { overrides: opts.overrides } : {}),
251
+ ...(opts?.nowMs !== undefined ? { nowMs: opts.nowMs } : {}),
252
+ };
253
+ return resolveModelCatalog(resolveOpts);
254
+ };
255
+ // ══ 🔴 [4982] 候选② 的**同形存量**(0.42.0 异源复审 [high] 采纳)══════════════════════════
256
+ //
257
+ // 病根与「缓存腿从没被问过」是**同一条**:候选链的停止判据锚在**传输层**(`hit !== null`)而不是
258
+ // **内容判决**。后果比缓存那一格更重 —— 首源发得下来但内容不合格时,`break` 让**后续健康源
259
+ // 永远不会被请求**:一次坏发布、或一个 CDN 的半更新窗,就能把整条后备链压掉。
260
+ // ⇒ 停止判据换成 `accepted !== null`(内容判决),逐源「传输 → 旁签 → **内容校验**」,
261
+ // 只有真被接受的那一份才停链。
262
+ //
263
+ // 🔴 `rejected` 是**诚实兜底**不是备选源:全链都被拒时,拿**第一份被拒的**去出结果,
264
+ // 这样 `online.reason` 说的是真话(`schema-version-unsupported`),而不是退成
265
+ // 「一个源都没连上」(`fetch-failed`)—— 那会把「内容坏了」谎报成「网络坏了」。
266
+ // 它**绝不**参与「用哪份目录」的竞争:被拒就是被拒,`online.ok === false` 时合并结果本就
267
+ // 回落内置表。
268
+ let accepted = null;
269
+ let rejected = null;
239
270
  for (const src of sources) {
240
- if (hit !== null)
271
+ if (accepted !== null)
241
272
  break;
242
273
  const got = await guardedGet(src, allowedHosts, fetchImpl, timeoutMs);
243
274
  if (got.outcome !== 'insecure-url' && got.outcome !== 'host-not-allowed')
@@ -289,7 +320,17 @@ export async function loadCatalogWithSources(opts) {
289
320
  warnings.push(`catalog sha256 sidecar unavailable for ${finalUrl} (${shaGot.outcome}) — payload accepted without the checksum`);
290
321
  }
291
322
  attempts.push({ url: src, outcome: 'ok', ...(got.status !== undefined ? { status: got.status } : {}), shaChecked });
292
- hit = { url: finalUrl, doc, raw: got.text, shaChecked };
323
+ const candidate = { url: finalUrl, doc, raw: got.text, shaChecked };
324
+ // 内容判决就在这里做 —— 传输成功**不等于**这份目录能用。
325
+ const verdict = await resolveWith(candidate);
326
+ if (verdict.online.ok) {
327
+ accepted = { hit: candidate, base: verdict };
328
+ continue;
329
+ }
330
+ if (rejected === null)
331
+ rejected = { hit: candidate, base: verdict };
332
+ warnings.push(`catalog source ${finalUrl} was fetched but REJECTED by content validation ` +
333
+ `(${verdict.online.reason ?? 'unknown'}) — trying the next source`);
293
334
  }
294
335
  // ── 缓存腿(口缺席 ⇒ 整条不启用,cacheHit 键缺席)────────────────────────────────────────
295
336
  const cache = opts?.cache;
@@ -297,7 +338,15 @@ export async function loadCatalogWithSources(opts) {
297
338
  let cacheHit = cache !== undefined ? false : undefined;
298
339
  let cacheStale;
299
340
  let cachedSourceUrl;
300
- if (hit === null && cache !== undefined && cachePath !== undefined) {
341
+ /**
342
+ * 读一次缓存信封 → `SourceHit`(+ 陈旧判)。**永不抛**;坏形/缺件记 warn 返 null。
343
+ * 🔴 [4982] 候选②(0.42.0)提出来的:此前这段是内联的,只有「传输全败」那一条路走得到它。
344
+ * 现在有**两个**调用点(传输全败 / 传输 ok 但内容被拒),提成一处才不会有两份读法。
345
+ * `alreadyWarned` = 第二次调用时不重复堆同一批 warn(同一份坏缓存说两遍是噪音)。
346
+ */
347
+ const readCachedHit = async (alreadyWarned) => {
348
+ if (cache === undefined || cachePath === undefined)
349
+ return null;
301
350
  let text = null;
302
351
  let readError;
303
352
  try {
@@ -306,52 +355,109 @@ export async function loadCatalogWithSources(opts) {
306
355
  catch (e) {
307
356
  readError = shortError(e);
308
357
  }
309
- if (readError !== undefined)
358
+ if (readError !== undefined && !alreadyWarned)
310
359
  warnings.push(`catalog cache read failed: ${readError}`);
311
- if (typeof text === 'string' && text.length > 0) {
312
- let env;
313
- let envError;
314
- try {
315
- env = JSON.parse(text);
316
- }
317
- catch (e) {
318
- envError = shortError(e);
319
- }
320
- if (envError !== undefined)
360
+ if (typeof text !== 'string' || text.length === 0)
361
+ return null;
362
+ let env;
363
+ let envError;
364
+ try {
365
+ env = JSON.parse(text);
366
+ }
367
+ catch (e) {
368
+ envError = shortError(e);
369
+ }
370
+ if (envError !== undefined) {
371
+ if (!alreadyWarned)
321
372
  warnings.push(`catalog cache is not parsable JSON (ignored): ${envError}`);
322
- const doc = env?.catalog;
323
- const url = env?.sourceUrl;
324
- if (doc !== undefined && typeof url === 'string' && url.length > 0) {
325
- // 缓存里那份**就是**当初的线上载荷 —— 照样过 validateOnlineCatalog(下面同一条路),
326
- // 不给它开后门:一份当年合法、今天已超区间的文档必须照样被拒。
327
- hit = { url, doc, raw: text, shaChecked: false };
328
- cacheHit = true;
329
- cachedSourceUrl = url;
330
- if (opts?.nowMs !== undefined && typeof env?.fetchedAt === 'number') {
331
- cacheStale = opts.nowMs - env.fetchedAt > CATALOG_CACHE_STALE_MS;
332
- }
333
- }
334
- else if (envError === undefined) {
373
+ return null;
374
+ }
375
+ const doc = env?.catalog;
376
+ const url = env?.sourceUrl;
377
+ if (doc === undefined || typeof url !== 'string' || url.length === 0) {
378
+ if (!alreadyWarned)
335
379
  warnings.push('catalog cache envelope missing sourceUrl/catalog (ignored)');
336
- }
380
+ return null;
337
381
  }
338
- }
339
- // ── 委托:载荷判决 / 三层合并 / 来源标注全在 resolveModelCatalog ──────────────────────────
340
- // 传给它的 `fetchJson` 只是「把已经拿到的这一份交出去」的闭包 —— 候选链与传输门是本文件的活,
341
- // 校验与合并是它的活,两边不重叠也不互相重写。
342
- const payload = hit;
343
- const onlineUrl = payload?.url ?? lastDialed ?? sources[0];
344
- const resolveOpts = {
345
- ...(onlineUrl !== undefined ? { onlineUrl } : {}),
346
- fetchJson: async () => {
347
- if (payload === null)
348
- throw new Error(`all ${sources.length} catalog source(s) failed; see attempts[]`);
349
- return payload.doc;
350
- },
351
- ...(opts?.overrides !== undefined ? { overrides: opts.overrides } : {}),
352
- ...(opts?.nowMs !== undefined ? { nowMs: opts.nowMs } : {}),
382
+ // 缓存里那份**就是**当初的线上载荷 —— 照样过 validateOnlineCatalog(下面同一条路),
383
+ // 不给它开后门:一份当年合法、今天已超区间的文档必须照样被拒。
384
+ const stale = opts?.nowMs !== undefined && typeof env?.fetchedAt === 'number'
385
+ ? opts.nowMs - env.fetchedAt > CATALOG_CACHE_STALE_MS
386
+ : undefined;
387
+ return {
388
+ hit: { url, doc, raw: text, shaChecked: false },
389
+ ...(stale !== undefined ? { stale } : {}),
390
+ };
353
391
  };
354
- const base = await resolveModelCatalog(resolveOpts);
392
+ // ══ 缓存腿:**内容判决**缺席时才问它(传输全败 内容被拒 两条路共用这一格)═══════════════
393
+ //
394
+ // 🔴 [4982] 候选②(P0-KPI,0.42.0):此前这条腿的门槛是 `hit === null`,而 `hit` 只记录**传输层**
395
+ // 结果(200 + 旁签过就算 hit)。于是「线上目录发得下来、但内容被 validateOnlineCatalog 拒」
396
+ // 这条缝里,loader 认为「有 hit」⇒ **永不查缓存**(即便缓存里躺着一份上次真正被接受过的
397
+ // 合法目录),用户只拿到内置精简表;而 `cacheHit:false` 这个**诚实位反过来说谎** —— 它读起来
398
+ // 是「没有缓存」,真相是「有缓存,但从头到尾没人问过它」。
399
+ // 判据锚在**真正决定结果的量**上:决定「用不用得上目录」的是**内容判决**(`accepted`),
400
+ // 不是传输判决(`hit !== null`)。异源复审 [high] 之后,候选链的停止判据也换成了同一个量
401
+ // (见上方 `accepted` 头注)—— 两处同根同治,不留同形存量。
402
+ // 🔴 缓存那份**不开后门**:它照样过同一个 `resolveModelCatalog`。也被拒时**不拿它顶替**
403
+ // (一份同样不合格的文档顶替不了什么),`cacheHit` 诚实留在 `false` —— 本位的语义是
404
+ // 「这次的目录**是从缓存来的**」,不是「问过缓存」;抬成 true 会让「缓存救场了」与
405
+ // 「缓存也坏了」在读数上不可分。「问过但没用上」那件事由 warning 说。
406
+ /**
407
+ * 线上腿的**归因汇总**(异源复审 [medium] 采纳,0.42.0)。
408
+ *
409
+ * 🔴 修的是一句会指错方向的话:此前只要**任一**源产生 `rejected`,回落 warning 就写
410
+ * 「every online catalog source was fetched but REJECTED」。而其余源完全可能是网络失败、
411
+ * 畸形 JSON、sha 不匹配、或被域白名单挡下 —— 把一次 CDN/网络事故说成「目录发布损坏」,
412
+ * 运维会照着错误方向查,恢复时间平白变长。
413
+ * ⇒ 按 `attempts` 的**真实计数**分两类措辞:全部进过内容校验且全被拒 ⇒ 说 "every … REJECTED";
414
+ * 混合故障 ⇒ 逐类报数,并保留内容拒绝的 reason。
415
+ */
416
+ const onlineLegSummary = () => {
417
+ const contentRejected = attempts.filter((a) => a.outcome === 'ok').length;
418
+ const otherFailures = attempts.length - contentRejected;
419
+ const reason = rejected?.base.online.reason ?? 'unknown';
420
+ if (otherFailures === 0 && contentRejected > 0) {
421
+ return `every online catalog source (${contentRejected}) was fetched but REJECTED by content validation (${reason})`;
422
+ }
423
+ return (`the online catalog leg failed in more than one way: ${contentRejected} source(s) were fetched but ` +
424
+ `REJECTED by content validation (${reason}), ${otherFailures} source(s) failed before content ` +
425
+ 'validation (transport / integrity / policy — see attempts[])');
426
+ };
427
+ let cached = null;
428
+ if (accepted === null)
429
+ cached = await readCachedHit(false);
430
+ let payload;
431
+ let base;
432
+ if (accepted !== null) {
433
+ payload = accepted.hit;
434
+ base = accepted.base;
435
+ }
436
+ else if (cached !== null) {
437
+ const fromCache = await resolveWith(cached.hit);
438
+ if (fromCache.online.ok) {
439
+ payload = cached.hit;
440
+ base = fromCache;
441
+ cacheHit = true;
442
+ cachedSourceUrl = cached.hit.url;
443
+ if (cached.stale !== undefined)
444
+ cacheStale = cached.stale;
445
+ if (rejected !== null)
446
+ warnings.push(`${onlineLegSummary()} — fell back to the cached catalog (${cached.hit.url})`);
447
+ }
448
+ else {
449
+ // 缓存那份也不合格 ⇒ 诚实回落:`online` 报的仍是**线上腿自己**的判词(有被拒的那一份就用
450
+ // 它的,一个都没连上就是 fetch-failed),绝不拿缓存的判词冒充线上的。
451
+ payload = rejected?.hit ?? null;
452
+ base = rejected?.base ?? (await resolveWith(null));
453
+ warnings.push(`${onlineLegSummary()}, and the cached catalog was rejected by content validation too — ` +
454
+ 'falling back to the built-in table');
455
+ }
456
+ }
457
+ else {
458
+ payload = rejected?.hit ?? null;
459
+ base = rejected?.base ?? (await resolveWith(null));
460
+ }
355
461
  // 线上腿真的被接受了才写缓存(被 validateOnlineCatalog 拒掉的载荷绝不进缓存 ——
356
462
  // 否则下一次断网时我们会把一份已知不合格的文档当兜底)。
357
463
  if (base.online.ok && payload !== null && cacheHit !== true && cache !== undefined && cachePath !== undefined) {
@@ -86,6 +86,25 @@ export interface ToolEndResultArmLike {
86
86
  settledBy?: unknown;
87
87
  /** 见 {@link settledBy} —— 两键同批同源(#263 半场,0.30.8),合读、缺席不反推、转录不是认证。 */
88
88
  approver?: unknown;
89
+ /**
90
+ * 这次 deny **是哪一条拒绝臂**(core ≥5.35 `AskDenyResolution`;A-D2 半场,0.42.0)——
91
+ * `human_refused` / `window_expired` / `no_approver` / `blanket_allow_refused` /
92
+ * `approver_unavailable` / `task_aborted` / `presentation_failed` / `approver_error` /
93
+ * `approver_contract`。
94
+ *
95
+ * 🔴 与 {@link settledBy} **合读而不互替**:settledBy 只说「这次等待是哪一**种**收场」,本位
96
+ * 分类的是**拒绝臂本身**。单锚 settledBy 会把「审批方违约」(approver 回了契约外的东西)
97
+ * 那一臂一起误收进「窗口走完了」。
98
+ * 🔴 **判据用正面匹配,不用「不等于」**(异源复审 [medium] 采纳,0.42.0):要判「审批窗口自己
99
+ * 走完了」,写 `settledBy === 'timeout' && resolution === 'window_expired'`。
100
+ * **绝不**写 `resolution !== 'approver_contract'` —— 那个不等式对**缺席**(旧引擎不发这一位)和
101
+ * 对**任何未来新码**都为真,于是「不知道是怎么拒的」会被折成「窗口自然结束」,正是本位存在要防的
102
+ * 那件事;两条正交轴在那种写法里被一个否定比较替代掉了。缺席与未知值一律进 `default` 分支。
103
+ * 🔴 **缺席不带语义,不许反推**:每一次真执行了的调用、每一次 policy/hook 直拒、以及
104
+ * durable/decide 腿的结算都不带这一位;缺席 ≠「不是拒绝」也 ≠「人拒的」。
105
+ * 🔴 本形同样是**防御性读形**(`unknown`):九词是引擎的闭集,消费方分支已知值 + 永远带 default。
106
+ */
107
+ resolution?: unknown;
89
108
  parentToolCallId?: unknown;
90
109
  uuid?: unknown;
91
110
  session_id?: unknown;
@@ -64,8 +64,32 @@
64
64
  * method exists (`start`/`stop`/`interrupt`/`sendMessage`/`onEvent`/`onToolPermissionRequest`/
65
65
  * `respondToToolPermission` are all real CC LocalSessions methods — see the 854-channel census).
66
66
  *
67
+ * ══ 🔴 证据等级标注:本文件的 CC 断言取自**桌面 unpack**,本地语料库不可复验 ════════════════
68
+ *
69
+ * (test [5087] 提出的「语料**种类**缺口」,cli [5088] 认领三件之①,随 0.42.0 落;判据形见下)
70
+ *
71
+ * 本文件里所有以「CC 如何如何」为形的断言 —— 上面那句「212 methods / 854-channel census /
72
+ * 方法名逐字保留」、下面 `checkLocalSessionEvent` 的「CC 的 `fQe` 逐字段对照」、以及一切引
73
+ * `.vite/build/index.chunk-*.js` 坐标的句子 —— **证据等级 = 桌面 unpack(CC Electron app 的
74
+ * `.vite/build` 产物),本地语料库不可复验**。
75
+ *
76
+ * 为什么这一条必须写在这里(而不是只写进台账):
77
+ * · 本仓手边可复验的参照语料**只覆盖终端 CLI 形态**的静态产物,**没有**桌面壳的 unpack 产物。
78
+ * 这不是「语料版本旧」,是**产物形态不对** —— 拿 CLI 形态的语料去 grep 桌面壳里的符号只会得到
79
+ * 零命中,而零命中在这里**既不能证真也不能证伪**;
80
+ * · 「CC 取证纪律」要求 CC 断言的合法证据只有两种:本机 claude 实测,或语料二进制直证。本文件
81
+ * 的这一批两种都够不着 ⇒ 断言**保留**(它当初是照着真产物写的),但**必须带着这个等级一起读**;
82
+ * · 🔴 **这段注释随 `dist/*.d.ts` 出包**,三端读到的就是它。只把等级写进不进 tarball 的台账,
83
+ * 消费端读到的仍是一句看起来可复验、实际不可复验的断言(与本文件 `workerBadge` 出身订正
84
+ * 同一条理由)。
85
+ *
86
+ * **对下游的判据**:凡要对这些断言做「CC 到底是不是这样」的复核,**别拿终端 CLI 形态的语料当反证**
87
+ * ——零命中是语料形态缺口的必然结果,不是断言错。真正的复核路只有一条:取一份 CC 桌面版
88
+ * unpack 产物再对照(已在册为独立小件,做成后参照语料库增桌面形态子目录)。
89
+ *
67
90
  * Event payload contract: ported in FORM from CC's `onEvent` runtime validator `fQe`
68
- * (unpack `.vite/build/index.chunk-CnWKsyE_.js:369773`). The load-bearing property we keep
91
+ * (unpack `.vite/build/index.chunk-CnWKsyE_.js:369773`;**证据等级:桌面 unpack,本地语料库
92
+ * 不可复验** —— 见上方标注段). The load-bearing property we keep
69
93
  * byte-faithful ([1832] axiom 2, independently proven by CC's own code): on an otherwise
70
94
  * fully-schema'd IPC surface, the `message`/`messages` fields (= CC session-vocabulary SDKMessage
71
95
  * bodies, our transcript plane) are UNVALIDATED passthrough positions, while every other field is a
@@ -64,8 +64,32 @@
64
64
  * method exists (`start`/`stop`/`interrupt`/`sendMessage`/`onEvent`/`onToolPermissionRequest`/
65
65
  * `respondToToolPermission` are all real CC LocalSessions methods — see the 854-channel census).
66
66
  *
67
+ * ══ 🔴 证据等级标注:本文件的 CC 断言取自**桌面 unpack**,本地语料库不可复验 ════════════════
68
+ *
69
+ * (test [5087] 提出的「语料**种类**缺口」,cli [5088] 认领三件之①,随 0.42.0 落;判据形见下)
70
+ *
71
+ * 本文件里所有以「CC 如何如何」为形的断言 —— 上面那句「212 methods / 854-channel census /
72
+ * 方法名逐字保留」、下面 `checkLocalSessionEvent` 的「CC 的 `fQe` 逐字段对照」、以及一切引
73
+ * `.vite/build/index.chunk-*.js` 坐标的句子 —— **证据等级 = 桌面 unpack(CC Electron app 的
74
+ * `.vite/build` 产物),本地语料库不可复验**。
75
+ *
76
+ * 为什么这一条必须写在这里(而不是只写进台账):
77
+ * · 本仓手边可复验的参照语料**只覆盖终端 CLI 形态**的静态产物,**没有**桌面壳的 unpack 产物。
78
+ * 这不是「语料版本旧」,是**产物形态不对** —— 拿 CLI 形态的语料去 grep 桌面壳里的符号只会得到
79
+ * 零命中,而零命中在这里**既不能证真也不能证伪**;
80
+ * · 「CC 取证纪律」要求 CC 断言的合法证据只有两种:本机 claude 实测,或语料二进制直证。本文件
81
+ * 的这一批两种都够不着 ⇒ 断言**保留**(它当初是照着真产物写的),但**必须带着这个等级一起读**;
82
+ * · 🔴 **这段注释随 `dist/*.d.ts` 出包**,三端读到的就是它。只把等级写进不进 tarball 的台账,
83
+ * 消费端读到的仍是一句看起来可复验、实际不可复验的断言(与本文件 `workerBadge` 出身订正
84
+ * 同一条理由)。
85
+ *
86
+ * **对下游的判据**:凡要对这些断言做「CC 到底是不是这样」的复核,**别拿终端 CLI 形态的语料当反证**
87
+ * ——零命中是语料形态缺口的必然结果,不是断言错。真正的复核路只有一条:取一份 CC 桌面版
88
+ * unpack 产物再对照(已在册为独立小件,做成后参照语料库增桌面形态子目录)。
89
+ *
67
90
  * Event payload contract: ported in FORM from CC's `onEvent` runtime validator `fQe`
68
- * (unpack `.vite/build/index.chunk-CnWKsyE_.js:369773`). The load-bearing property we keep
91
+ * (unpack `.vite/build/index.chunk-CnWKsyE_.js:369773`;**证据等级:桌面 unpack,本地语料库
92
+ * 不可复验** —— 见上方标注段). The load-bearing property we keep
69
93
  * byte-faithful ([1832] axiom 2, independently proven by CC's own code): on an otherwise
70
94
  * fully-schema'd IPC surface, the `message`/`messages` fields (= CC session-vocabulary SDKMessage
71
95
  * bodies, our transcript plane) are UNVALIDATED passthrough positions, while every other field is a
@@ -370,7 +394,10 @@ const checkSeatChromeEnvelope = (v, field) => {
370
394
  };
371
395
  /**
372
396
  * onEvent payload validator — line-for-line FORM port of CC's `fQe`
373
- * (`.vite/build/index.chunk-CnWKsyE_.js:369773`). Field-by-field mapping:
397
+ * (`.vite/build/index.chunk-CnWKsyE_.js:369773`)
398
+ * 🔴 **证据等级:桌面 unpack,本地语料库不可复验**(0.42.0 标注件,判据与复核路见文件头注的
399
+ * 「证据等级标注」段;下面每一条 `CC …` 对照句都受该等级约束)。
400
+ * Field-by-field mapping:
374
401
  * - `type`/`sessionId`: required strings (same);`type` 另加闭集成员校验(REF-CC-065,词由本宿主铸);
375
402
  * - `message`: CC's minified source reads `typeof e.message<"u"` inside a comma expression whose
376
403
  * value is discarded — i.e. NO validation, a deliberate transcript-plane passthrough. Same here.