@sema-agent/client-core 0.58.0 → 0.59.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.
@@ -90,7 +90,12 @@ import { approvalCallKey, liveFrameCallKey } from './gateIdentity.js';
90
90
  // 整只丢掉(B-025)。单源化之后「词表加员」这件事在**上游一处**发生,本包跟着走。
91
91
  // 🔴 值级 import 的代价已记账:peer 地板随本批抬到 `>=8.2.0`(run-sdk-floor-test.mjs 的 ①c 把
92
92
  // 声明与门常量逐位绑死),包总入口闭包的外部包集合仍恒等于 `{diff, @sema-agent/sdk}`(singleton 门)。
93
- import { RULE_OFFER_MATCHES, RULE_OFFER_BATCH_MEMBER_KINDS, RULE_OFFER_UNCOVERED_REASONS } from '@sema-agent/sdk';
93
+ // ── sdk 8.3.0 单源闭词表(S-125③ / S-114 / S-15 第五单;0.59.0 提货批)────────────────────────
94
+ // 同上一条纪律的第二例:`ruleOffersAbsence` 的三词、`denialLimitFallback.limit` 的二词都是
95
+ // **上游属主**的闭集。本包**不拿它们做窄读判定**(两处都按开集透传,见各自帧键 JSDoc),再导出
96
+ // 的用途是让三端写 `switch` 时有一份**与包同一个数组对象**的词表可数,而不是各自抄字面量 ——
97
+ // B-025 的根因正是手抄。⇒ 值级 import 让 peer 地板在编译期真被钉到 8.3.0。
98
+ import { RULE_OFFER_MATCHES, RULE_OFFER_BATCH_MEMBER_KINDS, RULE_OFFER_UNCOVERED_REASONS, RULE_OFFERS_ABSENCE_REASONS, DENIAL_LIMIT_KINDS, } from '@sema-agent/sdk';
94
99
  /** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
95
100
  * [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
96
101
  export function isFsApprovalGate(gate) {
@@ -147,6 +152,15 @@ export function structurallyEqual(a, b) {
147
152
  * 再导出让三端与包**共用同一份数组对象**,词表加员时一处改、四处跟。
148
153
  */
149
154
  export { RULE_OFFER_MATCHES, RULE_OFFER_BATCH_MEMBER_KINDS, RULE_OFFER_UNCOVERED_REASONS };
155
+ /**
156
+ * 两张新闭词表的**运行期**再导出(sdk 8.3.0 `as const` 单源;与上面三张同一条纪律)。
157
+ *
158
+ * ⚠️ **本包自己不拿它们做窄读判定**,这与 `RULE_OFFER_MATCHES` 一族刻意不同,理由写在两处帧键的
159
+ * JSDoc 里:`origin` 与 `ruleOffersAbsence` 的词表属主是 core、server 侧已按闭集拒过词表外的值,
160
+ * 包再校一遍只会在 core 加员当天把一个**合法**值判没(`wiring_manifest.autoMode.reason` 的
161
+ * 透传纪律逐字同规)。再导出是给端的 `switch` 一份可数的表 —— 端仍必须带 `default` 臂。
162
+ */
163
+ export { RULE_OFFERS_ABSENCE_REASONS, DENIAL_LIMIT_KINDS };
150
164
  // W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层(cli 装配不动)。
151
165
  const cardPortByKey = createSessionSlot();
152
166
  const cardPortMissesByKey = new Map();
@@ -447,6 +461,19 @@ export const TOOL_APPROVAL_FRAME_KEYS_MIRROR = [
447
461
  // 直证,同文件旧键 0 命中)。✅ **sdk 8.2.0 锚已含本键**(S-134 锚②)⇒ 0.58.0 提货批按退出
448
462
  // 条件删掉了对账门里那条 AHEAD_OF_ANCHOR 领先登记,本键回到「逐元素相等」的常态。
449
463
  'ruleOffers',
464
+ // S-15 第五单(client-core 0.59.0):server 7.55.0 起真发 `ruleOffersAbsence`(core #490 修②;
465
+ // engine 7.60.0 fixture 直证)。⚠️ 与 `ruleOffers` **同形不同命**:它是**追平**不是领先 ——
466
+ // sdk 8.3.0 的运行期锚已含本键(node 直读 27 项实证)⇒ **不**进 AHEAD_OF_ANCHOR
467
+ // (往那张表里塞一个锚已有的键,它的第二条退出条件当场红)。
468
+ 'ruleOffersAbsence',
469
+ // S-114(client-core 0.59.0):server 7.57.0 起真发 `denialLimitFallback`(core 7.4.0 #548;
470
+ // engine 7.60.0 fixture 直证)。同上一条:sdk 8.3.0 锚已含本键 ⇒ 追平,不进领先表。
471
+ // 🔴 本键与 `requiresRealApproval` 是孪生键,但两者在镜像里各占一格 —— 合并会让「只有一位在场」
472
+ // 的坏形无处显形。
473
+ 'denialLimitFallback',
474
+ // S-125③/#564(client-core 0.59.0):server 7.57.0 起真发 `origin`(core 7.5.0 `ASK_ORIGINS`
475
+ // 八词;engine 7.60.0 fixture 直证)。同上:sdk 8.3.0 锚已含本键 ⇒ 追平,不进领先表。
476
+ 'origin',
450
477
  // #341/[5214]③(client-core 0.43.0):server 7.46.0 起真发 `inputHasBidi`(E-14 Trojan Source 族;
451
478
  // 7.44 fixture 同文件 0 命中)。仍**领先** sdk 8.2.0 锚(node 直读 18 项无本键)⇒ AHEAD_OF_ANCHOR 登记。
452
479
  'inputHasBidi',
@@ -830,6 +857,40 @@ function isNonNegativeFinite(v) {
830
857
  function isWireRecordCarrier(v) {
831
858
  return typeof v === 'object' && v !== null && !Array.isArray(v);
832
859
  }
860
+ /**
861
+ * `denialLimitFallback` 的**窄读器**(S-114,0.59.0)—— wire 是 JSON:注入面 / 旧 server / 比本包新
862
+ * 一版的 server 都可能给别的形,坏形一律降**缺席**(与 `readToolApprovalDelegation` 同族纪律)。
863
+ *
864
+ * 🔴 **四成员全必填,缺一整只丢**(与 server `approval-card.ts` 的 `readDenialLimitFallback`
865
+ * `.strict()` 同判据,与 core 的形一字不差):半只对象上的 `consecutive` 会被人当成**真实的
866
+ * 连续拒次数**读,而它可能只是一个恰好在场的键。「一格空白的计数」比不渲这张卡更坏。
867
+ * 🔴 **两个计数与窗按「有限非负数」判,不折 0**:`Number.isFinite` 单独会放行负数,而三者的定义域
868
+ * 按上游契约本就非负(计数是次数,窗是 ms 且 `0` = 不武装,是**合法读数**不是缺席)。
869
+ * NaN / Infinity / 负数 = 坏形降缺席,**绝不猜**(同文件 `delegation.depth` 的旧教训)。
870
+ * 🔴 **`limit` 按开集读**(非空串即收):闭二词表 `DENIAL_LIMIT_KINDS` 只作再导出给端数,
871
+ * 包内**不拿它做判定** —— 词表属主是 core,抄一份就是给自己立第二个判官(B-025 的病形);
872
+ * server 侧已按闭集拒过词表外的值,包再校一遍只会在 core 加员当天把一个合法值判没。
873
+ * ⚠️ 这条与「四成员全必填」不矛盾:必填说的是**在场性**,开集说的是**取值域**。
874
+ */
875
+ function readDenialLimitFallback(v) {
876
+ if (!isWireRecordCarrier(v))
877
+ return undefined;
878
+ const d = v;
879
+ if (!isNonNegativeFinite(d.consecutive))
880
+ return undefined;
881
+ if (!isNonNegativeFinite(d.total))
882
+ return undefined;
883
+ if (!isNonNegativeFinite(d.autoDenyAfterMs))
884
+ return undefined;
885
+ if (typeof d.limit !== 'string' || d.limit === '')
886
+ return undefined;
887
+ return {
888
+ consecutive: d.consecutive,
889
+ total: d.total,
890
+ limit: d.limit,
891
+ autoDenyAfterMs: d.autoDenyAfterMs,
892
+ };
893
+ }
833
894
  /**
834
895
  * `match` 是不是**闭词表成员** —— 表来自 sdk 8.2.0 `RULE_OFFER_MATCHES`(`exact` / `prefix` /
835
896
  * `wildcard` / `subpath`),**本包不再手抄字面量**。
@@ -1165,6 +1226,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
1165
1226
  // #334(0.43.0):新键 `ruleOffers`(server ≥7.46.0 判别联合)优先,旧键 `ruleSuggestions`
1166
1227
  // (≤7.45)归一成 `kind:'single'` 兜底 —— 两代经同一把窄读器,包内出口只有一个形。
1167
1228
  const ruleOffers = readRuleOfferSupply(frame.ruleOffers, frame.ruleSuggestions);
1229
+ // S-114(0.59.0):限额回落卡的四成员窄读**算一次**(读器是纯函数,但两次调用会让「上卡的那一份
1230
+ // 与判在场的那一份是不是同一个对象」在读代码时需要推理 —— 单点求值把它变成显然的)。
1231
+ const denialLimitFallback = readDenialLimitFallback(frame.denialLimitFallback);
1168
1232
  const card = await surfaceApprovalCard({
1169
1233
  toolName,
1170
1234
  args: args,
@@ -1237,6 +1301,23 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
1237
1301
  // 「没扫到」渲成「已确认干净」,而这一位存在的全部理由就是「人眼读到的顺序 ≠ 真跑的字节顺序」。
1238
1302
  // 🔴 披露不清洗:args 字节在本层一个都不改(清洗会让卡上显示的与真跑的不是同一个东西)。
1239
1303
  ...(frame.inputHasBidi === true ? { inputHasBidi: true } : {}),
1304
+ // ── S-15 第五单(0.59.0):报价缺席因由透传 ────────────────────────────────────────────────
1305
+ // **非空串才 stamp**,词表**不校**(开集;server 已按闭集拒过词表外的值,包再校一遍只会在
1306
+ // core 加员当天把合法值判没 —— `autoMode.reason` 的透传纪律逐字同规)。缺席/坏形 ⇒ 键不 stamp。
1307
+ // 🔴 与 `ruleOffers` 引擎侧互斥,但本层**不据此互删**:两者同时在场是上游坏形,原样上卡让端
1308
+ // 看得见,包替上游把矛盾湮灭掉只会让排障说不清是谁窄没的。
1309
+ ...(typeof frame.ruleOffersAbsence === 'string' && frame.ruleOffersAbsence !== ''
1310
+ ? { ruleOffersAbsence: frame.ruleOffersAbsence }
1311
+ : {}),
1312
+ // ── S-114(0.59.0):auto 分类器限额回落卡透传 ──────────────────────────────────────────────
1313
+ // 经 `readDenialLimitFallback` 四成员窄读(缺一整只丢;判据本体在该函数顶注)。
1314
+ // 🔴 stamp 的是**窄读产物**而不是原对象:端拿到的四座恒是有限非负数 + 非空 limit 串,
1315
+ // 不必各自再写一遍同样的判断(三端各写一遍 = 三份会漂的判官)。
1316
+ ...(denialLimitFallback !== undefined ? { denialLimitFallback } : {}),
1317
+ // ── S-125③/#564(0.59.0):ask 出身透传 ────────────────────────────────────────────────────
1318
+ // 非空串才 stamp,词表**不校**(同 ruleOffersAbsence)。🔴 缺席 ⇒ 键不 stamp,**绝不折成
1319
+ // `"policy"`**:那是一个正面事实(出自部署 ToolPolicy),把「老引擎没报」折进去 = 替引擎编话。
1320
+ ...(typeof frame.origin === 'string' && frame.origin !== '' ? { origin: frame.origin } : {}),
1240
1321
  });
1241
1322
  const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
1242
1323
  if (card.kind === 'failed') {
package/dist/index.d.ts CHANGED
@@ -148,6 +148,7 @@ export * from './liveQuestionStore.js';
148
148
  export * from './engineInlineTaskStats.js';
149
149
  export * from './printToolResultFrame.js';
150
150
  export * from './engineCapsCache.js';
151
+ export * from './sqlEngineCapability.js';
151
152
  export * from './engineToolLabelStore.js';
152
153
  export * from './fleetTaskDesc.js';
153
154
  export type * from './types/engineState.js';
package/dist/index.js CHANGED
@@ -160,6 +160,8 @@ export * from './liveQuestionStore.js';
160
160
  export * from './engineInlineTaskStats.js';
161
161
  export * from './printToolResultFrame.js';
162
162
  export * from './engineCapsCache.js';
163
+ // S-131(0.59.0):`Capabilities.sql` 的四态窄读器 —— 壳侧那份逐字上收(导出名同名 = drift-lock)。
164
+ export * from './sqlEngineCapability.js';
163
165
  export * from './engineToolLabelStore.js';
164
166
  export * from './fleetTaskDesc.js';
165
167
  // ── B2 批:通知族合并 / caps-wire 门族 / 模型面纯逻辑 / control 路由(2026-07-27)──────────────
@@ -105,6 +105,24 @@ export interface ToolEndResultArmLike {
105
105
  * 🔴 本形同样是**防御性读形**(`unknown`):九词是引擎的闭集,消费方分支已知值 + 永远带 default。
106
106
  */
107
107
  resolution?: unknown;
108
+ /**
109
+ * 这次 deny 是**引擎的窗自己拒的**(S-125⑧,0.59.0;core 7.4.0 auto 模式分类器的限额回落窗 →
110
+ * server ≥7.57.0 `tool_end.autoDenied`)。`eventToSdkMessage` 的 `case 'tool_end'` 臂**严格
111
+ * `true` 才上臂**,与 {@link settledBy}/{@link approver}/{@link resolution} 同款「臂带、卡不带」。
112
+ *
113
+ * 🔴 **它答的是既有两键答不了的那一问**:`settledBy:"timeout"` 与 `resolution:"window_expired"`
114
+ * 覆盖**一切**审批窗到期(普通 ask 的 TTL 也在内),分不出「这一次到期是**自动拒**收的场」还是
115
+ * 「park 了等人」。要渲「自动拒(限额回落)」而不是「已转后台候批」,判据只有这一位。
116
+ * 🔴 **缺席不带语义,不许反推**:缺席同时覆盖「不是自动拒」「老引擎(<7.57.0)不报」「这次根本
117
+ * 没走审批」三形 ⇒ **禁**读成「是人拒的」。
118
+ * 🔴 **机读位是二值的**:`true` 才有意义,「在场但不是 true」没有语义 —— 消费端同样只认严格 `true`。
119
+ * 🔴 **不据它自铸第二只定时器/第二张资格表**:窗的执行全在引擎(上游
120
+ * `tool_approval.denialLimitFallback` 顶注的同一条禁令),本位是**事后**的判别位。
121
+ * ⇄ 卡面那一侧的孪生位 = `ApprovalCardRequest.denialLimitFallback`(带触限计数与窗),两者是
122
+ * 同一件事的**事前 / 事后**两个面:卡上告诉人「再拒就到限额了,这一次必须你来批」,本位告诉人
123
+ * 「那张卡的窗走完了,引擎替你拒了」。
124
+ */
125
+ autoDenied?: unknown;
108
126
  parentToolCallId?: unknown;
109
127
  uuid?: unknown;
110
128
  session_id?: unknown;
package/dist/seam.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { ModelUsage, SDKMessage } from '@sema-agent/agent-types';
10
10
  import type { EngineTurnUsage } from './adapter/downstream/turnUsageToModelUsage.js';
11
- import type { WiringManifestAutoMode, WiringManifestModelGate } from './adapter/downstream/eventToSdkMessage.js';
11
+ import type { WiringManifestAutoMode, WiringManifestModelGate, WiringManifestMcpEntry } from './adapter/downstream/eventToSdkMessage.js';
12
12
  /**
13
13
  * `AbortSignal` 的结构型(B3 扩容,SEAM-GAP-4)。
14
14
  *
@@ -393,22 +393,28 @@ export type ChromeEvent = {
393
393
  | HumanInputChromeEvent | EngineNoticeChromeEvent | TextSegmentEndChromeEvent | WiringManifestChromeEvent;
394
394
  /**
395
395
  * {@link ChromeEvent} 的 `wiring_manifest` 臂(core #524 + core 147③,server ≥7.58.0)——
396
- * 引擎接线自述里**两段面向终端用户的事实**,其余每一段仍不投影(射程见 eventToSdkMessage 的
396
+ * 引擎接线自述里**三段面向终端用户的事实**(S-124 起 `mcp[]` 是第三段),其余每一段仍不投影(射程见 eventToSdkMessage 的
397
397
  * `case 'wiring_manifest'` 头注)。
398
398
  *
399
- * ── 🔴 宿主消费义务(三条,全部是「不许做什么」)────────────────────────────────────────────
399
+ * ── 🔴 宿主消费义务(四条,全部是「不许做什么」)────────────────────────────────────────────
400
400
  * ① **缺席不可反推**。`modelGate` 缺席 = 本 run 没有门卸(core 只在真卸时铸段),
401
401
  * `autoMode` 缺席 = 老 mint / 外部 derive **没报**。两者都**不许**被渲成一句肯定句
402
402
  * (「没有工具被卸掉」/「auto 未武装」)—— 那是把「没报」说成「报了个否」。
403
- * 本臂在两段都不成形时**根本不会到达**,所以宿主见到本臂就至少有一段是真读数。
403
+ * `mcp` 缺席同理(义务④)。本臂在**三段都不成形**时根本不会到达,所以宿主见到本臂就至少有
404
+ * 一段是真读数。
404
405
  * ② **`autoMode.reason` 六词逐字呈现,不许映射**到 `/v1/capabilities.permissionModeAuto.reason`
405
406
  * 的六词:两套词表**同名不同义**(`settings_denied` 在 capabilities 那边折 `no_intent`
406
407
  * 不折 `denied`)。要两面都说,就两面各自读、各自渲,绝不归一。
407
408
  * ③ **`modelGate.restore` 原样呈现**:它是 core 铸的**逐字**恢复办法,宿主自己拼一句
408
409
  * 「试试把某某开关关掉」等于替引擎编了一条它没说过的出口。
410
+ * ④ **`mcp: []` 不许当缺席**(S-124,0.59.0)。空数组是「这条腿一台 MCP 都没申报」这句**正面
411
+ * 事实**,缺席才是「老 mint / 外部 derive 没报」—— 判在场写 `mcp !== undefined`,写
412
+ * `mcp?.length` 就把两句话折成了一句。条目里 `toolCount: 0` 同理(连上了、零工具 ≠ 没报)。
413
+ * ⚠️ 反过来本包也**不会**拿 `[]` 骗你:一份非空却整表读不出来的回体在投影层就落成**段缺席**
414
+ * (异源对抗复审 r1 真病),所以你读到的 `[]` 一定是引擎真报的零申报,不是「都被丢光了」。
409
415
  * 🔴 **幂等**:durable 腿重放会再送同一帧(与 `workspace_changed`/`engine_notice` 同纪律),
410
416
  * 宿主按 run/leg 去重,别按到达次数计数。
411
- * 缺席(宿主不接本臂)= 这两条披露在该宿主上看不见,**不是**报错。
417
+ * 缺席(宿主不接本臂)= 这三条披露在该宿主上看不见,**不是**报错。
412
418
  */
413
419
  export interface WiringManifestChromeEvent {
414
420
  kind: 'wiring_manifest';
@@ -417,6 +423,20 @@ export interface WiringManifestChromeEvent {
417
423
  modelGate?: WiringManifestModelGate;
418
424
  /** effective 腿恒在;缺席只表示「没报」(见义务①)。 */
419
425
  autoMode?: WiringManifestAutoMode;
426
+ /**
427
+ * 本条腿**申报的每台 MCP 服务器**的连接时快照(S-124 / core 7.5.0,server ≥7.60.0)。
428
+ *
429
+ * 🔴 **空数组不是缺席**(core 顶注逐字,也是本臂第四条消费义务):`[]` = 「这条腿一台都没申报」
430
+ * (一句正面事实,该渲成那句话);**缺席** = 老 mint / 外部 derive **没报**(什么都别渲)。
431
+ * 宿主写 `mcp?.length ? … : …` 就把两者折成了一件 —— 判在场用 `mcp !== undefined`。
432
+ * ⚠️ 本包这一侧的对偶承诺:非空却零行幸存(整表读不出来)的回体**不铸 `[]`**,落段缺席。
433
+ * 🔴 **不与 `GET /v1/sessions/:id/mcp` 的二态合并、也不互相校验**(server 裁定逐字):那条端点的
434
+ * 真源是 server 自己的部署面台账,本段的真源是 core 这条腿 materialize 的连接时快照 ——
435
+ * 同名不同源,合并只会造出一个「哪个才算数」的新问题。
436
+ * 🔴 **`errorCode` 按开集分支**(十词 + `http_<status>` 形),`switch` 必须带 `default` 臂;
437
+ * 远端错误**自由文本**(`error`)上游就不投,宿主也拿不到 —— 可操作的因由在 `errorCode`。
438
+ */
439
+ mcp?: readonly WiringManifestMcpEntry[];
420
440
  /** core 铸的事件身份(uuidv7 形);wire 未必带 ⇒ 缺席时本键不在场。 */
421
441
  eventId?: string;
422
442
  }
package/dist/seam.js CHANGED
@@ -50,9 +50,11 @@ const CHROME_ARM_TABLE = {
50
50
  },
51
51
  wiring_manifest: {
52
52
  required: false,
53
- duty: '可选:渲引擎接线自述里的两段用户面事实(modelGate = 本 run 被模型门卸掉的工具 + 逐字恢复办法;' +
54
- 'autoMode = 武装位 + core 六词原因)。🔴 两段缺席一律不渲肯定句;reason 绝不映射 capabilities 六词;' +
55
- 'restore 原样呈现;durable 重放按 run/leg 幂等',
53
+ duty: '可选:渲引擎接线自述里的三段用户面事实(modelGate = 本 run 被模型门卸掉的工具 + 逐字恢复办法;' +
54
+ 'autoMode = 武装位 + core 六词原因;mcp[] = 本腿申报的每台 MCP 服务器的连接时快照)。' +
55
+ '🔴 三段缺席一律不渲肯定句;reason 绝不映射 capabilities 六词;restore 原样呈现;' +
56
+ 'mcp 的空数组是「一台都没申报」这句正面事实、**不是**缺席(判在场写 mcp !== undefined,' +
57
+ '别写 mcp?.length),errorCode 按开集分支必带 default;durable 重放按 run/leg 幂等',
56
58
  },
57
59
  text_segment_end: {
58
60
  required: false,
@@ -0,0 +1,131 @@
1
+ /**
2
+ * sqlEngineCapability — S-131「SQL 引擎姿态」的**三端共用读面**(server ≥7.60.0 的
3
+ * `GET /v1/capabilities` 新增 `sql` 位;sdk 8.3.0 `Capabilities.sql`)。
4
+ *
5
+ * ── 归层出身(不掩盖:这份窄读器**本来就该在这里**)────────────────────────────────────────
6
+ * 0.58.0 时本件写在壳里(cli `src/sema/sqlEngineCapability.ts`),理由是当时 client-core 的
7
+ * `engineCapsCache` 公面只到「平铺布尔 / 平铺串 / 嵌套布尔」三形,**没有嵌套对象的读口** ⇒ 这一格
8
+ * 在包侧结构上读不出来。0.59.0 两件同批补齐:`engineCapsCache` 加四态读口
9
+ * {@link import("./engineCapsCache.js").engineCapValue},本模块把壳那份窄读器**逐字上收**。
10
+ * 🔴 **导出名与壳侧那份逐字相同**,这是 drift-lock:包一发同名符号,壳的
11
+ * `run-layer-shadow-export-gate-test.mjs`(同名影子导出门)当场红,逼那份壳侧副本换装成
12
+ * `export { … } from '@sema-agent/client-core'`,而不是两边各自演进(DISEASE-SHAPES S37 的病形
13
+ * 正是「包侧有了、壳侧没删」)。
14
+ *
15
+ * ── 上游事实(fixture 直证,不采信 CHANGELOG)──────────────────────────────────────────────
16
+ * `@sema-agent/server/dist/http/routes/capabilities.js` 的回体里多一位
17
+ * `sql: projectSqlEngineCapability(deps.sqlEngineFacts?.())`;7.59.0 的同一文件**没有这个键**。
18
+ * 值的形(`server/dist/sql-engine-posture.d.ts` 逐字):
19
+ * · `{ engine, isolation, txnMode }` —— 驱动在**真连接**上 `SET` 完之后**回读**的值
20
+ * (「取实例真值,不按配置推断」);
21
+ * · `null` —— **本部署没有 SQL 后端**(env-only / local 文件后端)。逐字:「缺席就是缺席…
22
+ * 不铸一个看起来像答案的空壳」。
23
+ *
24
+ * ── 🔴 四态,不是两态([honest-absence-not-fabricated-zero])────────────────────────────────
25
+ * 这一格上有**四件互不相同**的事,消费端一件都不许折进另一件:
26
+ * ① `unobserved` —— **本进程一次 caps 响应都没观测到**。一次性 `sema doctor` 就是这一档
27
+ * (它不起 live client ⇒ caps tee 从来没触发过)。它**不是**「引擎没有 SQL」。
28
+ * ② `not_reported` —— caps 观测到了,而回体上**没有** `sql` 这个键 ⇒ 老引擎(<7.60.0)。
29
+ * 它**不是**「这台部署没有 SQL 后端」—— 老引擎有没有库,这个读面答不了。
30
+ * ③ `none` —— 引擎**明确说** `null`:这台部署没有 SQL 后端。这是一个**正面事实**,不是缺席;
31
+ * 把它渲成「未观测」等于把引擎真给的答案丢掉(壳自 spawn 的单机形恒落这一档,是常态读数)。
32
+ * ④ `present` —— 三座俱全,原样渲。
33
+ * 🔴 **绝不渲 0 / 绝不编一个姿态**:读不出来就说读不出来,哪一种读不出来也要分清楚。
34
+ * ⇄ 这四个词与 {@link import("./engineCapsCache.js").EngineCapValueState} 的四态**同一套词汇**
35
+ * (`unobserved` / `not_reported` / `null`→`none` / `value`→`present`):通用读口答「这一格上有
36
+ * 没有值」,本模块答「这一格的值成不成形」,刻意分层 —— 通用口不认识 `sql` 的三座,窄读器不该
37
+ * 重新实现一遍缓存与代际。
38
+ *
39
+ * ── 🔴 UNTRUSTED-for-display ──────────────────────────────────────────────────────────────
40
+ * `isolation` 是从**数据库服务器**变量里回读的串(`@@transaction_isolation` 一族),不是引擎铸的
41
+ * 闭词;三座一律只渲染、绝不参与任何判定,呈现前过本包的单行消毒单源
42
+ * ({@link escapeDisplayControlChars})。
43
+ */
44
+ /**
45
+ * 三座俱全时的读数(server `SqlEngineCapability` 逐字同形)。
46
+ *
47
+ * 🔴 **`txnMode` 的域含 `null`,而 `null` 不是缺席**(fixture `sql-driver.d.ts` 逐字:
48
+ * 「TiDB's session `tidb_txn_mode`; `null` = this engine has no such indicator (**NOT**
49
+ * "optimistic")」;铸点逐字 `txnMode: engine === "tidb" ? "pessimistic" : null`)⇒
50
+ * **三种引擎里有两种(`innodb` / `pg`)这一位恒 `null`**。把 `null` 判成畸形 = 两种正常部署
51
+ * 的完整读数被整条丢掉、doctor 错报「没有引擎响应」—— 那正是 B-025 的病形(消费域比铸点域
52
+ * 更窄)在新件上复发。
53
+ * 🔴 型写 `string | null` 而**不是**手抄上游那个 `'pessimistic' | null` 闭词:抄一份就是给自己
54
+ * 立第二个判官(上游哪天给别的引擎加一个指示词,手抄的那份会把它判畸形)。窄读域只许**等于
55
+ * 或宽于**铸点域。
56
+ */
57
+ export interface SqlEngineCapabilityView {
58
+ engine: string;
59
+ isolation: string;
60
+ txnMode: string | null;
61
+ }
62
+ /** 四态读数(见文件头)。`unobserved` 由**读口**在这一格空缺时铸,不由投影铸。 */
63
+ export type SqlEngineReading = {
64
+ kind: 'unobserved';
65
+ } | {
66
+ kind: 'not_reported';
67
+ } | {
68
+ kind: 'none';
69
+ } | {
70
+ kind: 'present';
71
+ view: SqlEngineCapabilityView;
72
+ };
73
+ /**
74
+ * caps 回体 → 本格读数;**畸形一律 `undefined`**(= 这一格不写 ⇒ 读口答 `unobserved`)。
75
+ *
76
+ * 🔴 `undefined`(键不在)与 `null`(引擎说没有)**是两件事**,本函数是本包唯一区分它们的地方:
77
+ * 前者 ⇒ `not_reported`(老引擎),后者 ⇒ `none`(这台部署没库)。用 `??` / falsy 判会把
78
+ * 两者压成一件,而它们对运维的意思完全不同。
79
+ * 🔴 三座**任一**不是非空串 ⇒ 整条判畸形(`undefined`)。绝不留一个缺座的对象:一行写着
80
+ * 「isolation: 」的诊断比不渲这一行更坏(它看起来像一个答案)。
81
+ */
82
+ export declare function projectSqlEngineCapability(caps: unknown): SqlEngineReading | undefined;
83
+ /**
84
+ * 宿主 caps probe 的**读面 tee** 落点(壳侧与 mcpGate / sessionBackground / projectContext /
85
+ * crashConverged / workflowsGate 五条并列)。绝不 throw —— 读面腿不许反噬 caps 探测链。
86
+ *
87
+ * 🔴 投影 `undefined`(caps 畸形)⇒ 这一格**被删**而不是留着上一台引擎的旧值 —— 留旧值 = 拿一台
88
+ * 已经不在的引擎的读数去回答「这台现在是什么姿态」。
89
+ * ⚠️ **这一条只覆盖「收到了一份读不懂的回体」**。「同端口 respawn / 版本回滚之后新探测**失败或
90
+ * 还在飞**」是**另一条路** —— 那时本 tee 一次都不触发,挡不住旧读数继续被当成当代事实。
91
+ * 那一形由 {@link forgetSqlEngineReading} 在装配口(宿主的引擎温切臂)上收。
92
+ * 🔴 **不按 principal 分域**:SQL 姿态是**这台 worker 的连接事实**,与调用者是谁无关
93
+ * (与 `workflows` 那种 per-caller 的位刻意不同)—— 加一个用不上的去重域只会造出一条
94
+ * 「换主体就读不到」的假缺席。
95
+ */
96
+ export declare function noteEngineCapsForSqlEngine(baseUrl: string, caps: unknown): void;
97
+ /**
98
+ * 本进程观测到的 SQL 姿态;这一格空缺 ⇒ `{kind:'unobserved'}`(**绝不**折成 `none`)。
99
+ *
100
+ * 缺省读锚 = {@link engineWireTarget} 的 `baseUrl`(本包零 `process` —— portability 门盯着;
101
+ * 壳那份直读 `process.env.SEMA_LIVE_BASEURL`,而 `engineWireTarget()` 的 env 臂读的就是同一个变量
102
+ * ⇒ Node 壳上行为一字节不变,浏览器/桌面宿主则由 `installEngineWireTarget()` 显式装)。
103
+ */
104
+ export declare function observedSqlEngine(baseUrl?: string | undefined): SqlEngineReading;
105
+ /**
106
+ * 四态 → doctor 那一行的 detail 串。**唯一措辞真源**(三端共用一句话;别在各端的行装配里另写
107
+ * 一遍 —— 那正是这次上收要消灭的东西)。
108
+ *
109
+ * 🔴 三座是 UNTRUSTED-for-display(`isolation` 是从**数据库服务器**变量里回读的串):呈前消毒 +
110
+ * 封长。消毒放在这一处而不是各呈现点,是因为本函数的产物就是屏上那一串(单一出口)。
111
+ * 🔴 四句话刻意**互不相同、也互不蕴含**:「未观测」不许说成「没有库」,「老引擎不报」也不许
112
+ * 说成「没有库」—— 三种「读不出」对运维是三条不同的下一步。
113
+ */
114
+ export declare function sqlEngineDoctorDetail(reading: SqlEngineReading): string;
115
+ /**
116
+ * **换代失效口**(与 {@link import("./engineCapsCache.js").invalidateEngineCaps} 并列,由宿主在
117
+ * 引擎温切成功后调)。
118
+ *
119
+ * 🔴 **代际闸挡不住这一形**:那道闸挡的是「旧响应**写脏**新一代」(tee 只在确认代际仍当代之后
120
+ * 才落地),而本格的病是反过来的 —— 新一代的探测**根本没成功**(超时 / reject / 还在飞),
121
+ * tee 于是一次都没触发,上一台引擎留在同一端口上的读数就继续被 {@link observedSqlEngine}
122
+ * 当成**当代事实**答出去。同端口 respawn / 版本回滚 / 引擎起不来三条路都走这里,而它在
123
+ * doctor 上的形态最坏:一行**肯定句**(「这台部署是 tidb 悲观事务」/「这台引擎不报这一位,
124
+ * 要升级」),说的却是一台已经不在的引擎。
125
+ * 🔴 处置是**清成未观测**,不是留旧值也不是铸一个 `none` —— 「我这一代还没听到答案」是这一格
126
+ * 唯一诚实的话(与 {@link noteEngineCapsForSqlEngine} 对畸形回体的处置同向)。
127
+ * 空串 ⇒ no-op;绝不 throw(它跑在引擎温切路径上)。
128
+ */
129
+ export declare function forgetSqlEngineReading(baseUrl: string | undefined): void;
130
+ /** 测试钩子。 */
131
+ export declare function __resetSqlEngineReadingsForTests(): void;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * sqlEngineCapability — S-131「SQL 引擎姿态」的**三端共用读面**(server ≥7.60.0 的
3
+ * `GET /v1/capabilities` 新增 `sql` 位;sdk 8.3.0 `Capabilities.sql`)。
4
+ *
5
+ * ── 归层出身(不掩盖:这份窄读器**本来就该在这里**)────────────────────────────────────────
6
+ * 0.58.0 时本件写在壳里(cli `src/sema/sqlEngineCapability.ts`),理由是当时 client-core 的
7
+ * `engineCapsCache` 公面只到「平铺布尔 / 平铺串 / 嵌套布尔」三形,**没有嵌套对象的读口** ⇒ 这一格
8
+ * 在包侧结构上读不出来。0.59.0 两件同批补齐:`engineCapsCache` 加四态读口
9
+ * {@link import("./engineCapsCache.js").engineCapValue},本模块把壳那份窄读器**逐字上收**。
10
+ * 🔴 **导出名与壳侧那份逐字相同**,这是 drift-lock:包一发同名符号,壳的
11
+ * `run-layer-shadow-export-gate-test.mjs`(同名影子导出门)当场红,逼那份壳侧副本换装成
12
+ * `export { … } from '@sema-agent/client-core'`,而不是两边各自演进(DISEASE-SHAPES S37 的病形
13
+ * 正是「包侧有了、壳侧没删」)。
14
+ *
15
+ * ── 上游事实(fixture 直证,不采信 CHANGELOG)──────────────────────────────────────────────
16
+ * `@sema-agent/server/dist/http/routes/capabilities.js` 的回体里多一位
17
+ * `sql: projectSqlEngineCapability(deps.sqlEngineFacts?.())`;7.59.0 的同一文件**没有这个键**。
18
+ * 值的形(`server/dist/sql-engine-posture.d.ts` 逐字):
19
+ * · `{ engine, isolation, txnMode }` —— 驱动在**真连接**上 `SET` 完之后**回读**的值
20
+ * (「取实例真值,不按配置推断」);
21
+ * · `null` —— **本部署没有 SQL 后端**(env-only / local 文件后端)。逐字:「缺席就是缺席…
22
+ * 不铸一个看起来像答案的空壳」。
23
+ *
24
+ * ── 🔴 四态,不是两态([honest-absence-not-fabricated-zero])────────────────────────────────
25
+ * 这一格上有**四件互不相同**的事,消费端一件都不许折进另一件:
26
+ * ① `unobserved` —— **本进程一次 caps 响应都没观测到**。一次性 `sema doctor` 就是这一档
27
+ * (它不起 live client ⇒ caps tee 从来没触发过)。它**不是**「引擎没有 SQL」。
28
+ * ② `not_reported` —— caps 观测到了,而回体上**没有** `sql` 这个键 ⇒ 老引擎(<7.60.0)。
29
+ * 它**不是**「这台部署没有 SQL 后端」—— 老引擎有没有库,这个读面答不了。
30
+ * ③ `none` —— 引擎**明确说** `null`:这台部署没有 SQL 后端。这是一个**正面事实**,不是缺席;
31
+ * 把它渲成「未观测」等于把引擎真给的答案丢掉(壳自 spawn 的单机形恒落这一档,是常态读数)。
32
+ * ④ `present` —— 三座俱全,原样渲。
33
+ * 🔴 **绝不渲 0 / 绝不编一个姿态**:读不出来就说读不出来,哪一种读不出来也要分清楚。
34
+ * ⇄ 这四个词与 {@link import("./engineCapsCache.js").EngineCapValueState} 的四态**同一套词汇**
35
+ * (`unobserved` / `not_reported` / `null`→`none` / `value`→`present`):通用读口答「这一格上有
36
+ * 没有值」,本模块答「这一格的值成不成形」,刻意分层 —— 通用口不认识 `sql` 的三座,窄读器不该
37
+ * 重新实现一遍缓存与代际。
38
+ *
39
+ * ── 🔴 UNTRUSTED-for-display ──────────────────────────────────────────────────────────────
40
+ * `isolation` 是从**数据库服务器**变量里回读的串(`@@transaction_isolation` 一族),不是引擎铸的
41
+ * 闭词;三座一律只渲染、绝不参与任何判定,呈现前过本包的单行消毒单源
42
+ * ({@link escapeDisplayControlChars})。
43
+ */
44
+ import { escapeDisplayControlChars } from './fleetTaskDesc.js';
45
+ import { engineWireTarget } from './engineWireTarget.js';
46
+ /**
47
+ * caps 回体 → 本格读数;**畸形一律 `undefined`**(= 这一格不写 ⇒ 读口答 `unobserved`)。
48
+ *
49
+ * 🔴 `undefined`(键不在)与 `null`(引擎说没有)**是两件事**,本函数是本包唯一区分它们的地方:
50
+ * 前者 ⇒ `not_reported`(老引擎),后者 ⇒ `none`(这台部署没库)。用 `??` / falsy 判会把
51
+ * 两者压成一件,而它们对运维的意思完全不同。
52
+ * 🔴 三座**任一**不是非空串 ⇒ 整条判畸形(`undefined`)。绝不留一个缺座的对象:一行写着
53
+ * 「isolation: 」的诊断比不渲这一行更坏(它看起来像一个答案)。
54
+ */
55
+ export function projectSqlEngineCapability(caps) {
56
+ if (caps === null || typeof caps !== 'object')
57
+ return undefined;
58
+ if (!('sql' in caps))
59
+ return { kind: 'not_reported' };
60
+ const sql = caps.sql;
61
+ if (sql === null)
62
+ return { kind: 'none' };
63
+ if (sql === undefined)
64
+ return { kind: 'not_reported' };
65
+ if (typeof sql !== 'object')
66
+ return undefined;
67
+ const s = sql;
68
+ if (typeof s.engine !== 'string' || s.engine === '')
69
+ return undefined;
70
+ if (typeof s.isolation !== 'string' || s.isolation === '')
71
+ return undefined;
72
+ // 🔴 `txnMode`:`null` 是**铸点域内的合法值**(见 SqlEngineCapabilityView 顶注),不是畸形。
73
+ // 但这一位在铸点上**恒在场** ⇒ 键缺席(`undefined`)与非串非 null 仍判载体坏了。
74
+ if (s.txnMode !== null && (typeof s.txnMode !== 'string' || s.txnMode === ''))
75
+ return undefined;
76
+ return { kind: 'present', view: { engine: s.engine, isolation: s.isolation, txnMode: s.txnMode } };
77
+ }
78
+ const readingByBase = new Map();
79
+ /**
80
+ * 宿主 caps probe 的**读面 tee** 落点(壳侧与 mcpGate / sessionBackground / projectContext /
81
+ * crashConverged / workflowsGate 五条并列)。绝不 throw —— 读面腿不许反噬 caps 探测链。
82
+ *
83
+ * 🔴 投影 `undefined`(caps 畸形)⇒ 这一格**被删**而不是留着上一台引擎的旧值 —— 留旧值 = 拿一台
84
+ * 已经不在的引擎的读数去回答「这台现在是什么姿态」。
85
+ * ⚠️ **这一条只覆盖「收到了一份读不懂的回体」**。「同端口 respawn / 版本回滚之后新探测**失败或
86
+ * 还在飞**」是**另一条路** —— 那时本 tee 一次都不触发,挡不住旧读数继续被当成当代事实。
87
+ * 那一形由 {@link forgetSqlEngineReading} 在装配口(宿主的引擎温切臂)上收。
88
+ * 🔴 **不按 principal 分域**:SQL 姿态是**这台 worker 的连接事实**,与调用者是谁无关
89
+ * (与 `workflows` 那种 per-caller 的位刻意不同)—— 加一个用不上的去重域只会造出一条
90
+ * 「换主体就读不到」的假缺席。
91
+ */
92
+ export function noteEngineCapsForSqlEngine(baseUrl, caps) {
93
+ try {
94
+ if (typeof baseUrl !== 'string' || baseUrl === '')
95
+ return;
96
+ const reading = projectSqlEngineCapability(caps);
97
+ if (reading === undefined) {
98
+ readingByBase.delete(baseUrl);
99
+ return;
100
+ }
101
+ readingByBase.set(baseUrl, reading);
102
+ }
103
+ catch {
104
+ /* fail-soft:本 tee 任何分支都不许打断 caps 探测链 */
105
+ }
106
+ }
107
+ /**
108
+ * 本进程观测到的 SQL 姿态;这一格空缺 ⇒ `{kind:'unobserved'}`(**绝不**折成 `none`)。
109
+ *
110
+ * 缺省读锚 = {@link engineWireTarget} 的 `baseUrl`(本包零 `process` —— portability 门盯着;
111
+ * 壳那份直读 `process.env.SEMA_LIVE_BASEURL`,而 `engineWireTarget()` 的 env 臂读的就是同一个变量
112
+ * ⇒ Node 壳上行为一字节不变,浏览器/桌面宿主则由 `installEngineWireTarget()` 显式装)。
113
+ */
114
+ export function observedSqlEngine(baseUrl = engineWireTarget()?.baseUrl) {
115
+ if (typeof baseUrl !== 'string' || baseUrl === '')
116
+ return { kind: 'unobserved' };
117
+ return readingByBase.get(baseUrl) ?? { kind: 'unobserved' };
118
+ }
119
+ /** 单行 UNTRUSTED 座的展示上限(UTF-16 单元;与壳侧那份同值)。 */
120
+ const SQL_DETAIL_MAX = 40;
121
+ /**
122
+ * 单行 UNTRUSTED 座的呈前规整:**先按 {@link SQL_DETAIL_MAX} 截原字节,再过单行消毒**。
123
+ *
124
+ * 🔴 顺序刻意与壳那份相反,理由是两侧消毒器的**输出形不同**:壳那份用 `.` 占位(1:1,先截后清
125
+ * 与先清后截等价),本包的单源 {@link escapeDisplayControlChars} 用**可见转义** `\uXXXX`
126
+ * (1:6)—— 先清后截会把一个转义序列拦腰截断,屏上留下 `\u20` 这种既不是字符也不是转义的残片。
127
+ * 先截后清则至多让**渲染宽度**超出上限,而语义完整;截点劈开的代理对由消毒器自己收
128
+ * (孤代理项在它的字符集里)。
129
+ * ⚠️ **与壳那份的已知呈现差分**(换装时随批裁一次):壳 `sqlEngineDoctorDetail` 走的是
130
+ * `cleanUntrustedForDisplay`(`.` 占位形),而壳自己的 `untrustedDisplayText` 头注写着
131
+ * 「单行字段(路径/URL/参数键值)**必须**用 cleanUntrustedScalar」(可见转义形)——
132
+ * 三座正是单行标量。本包按那条规则实现,所以对**正常读数**(`tidb` / `REPEATABLE-READ` /
133
+ * `pessimistic`)两侧逐字节相同,只有含控制符的病态值渲染形不同。
134
+ */
135
+ function cleanSqlDetailScalar(v) {
136
+ return escapeDisplayControlChars(v.slice(0, SQL_DETAIL_MAX));
137
+ }
138
+ /**
139
+ * 四态 → doctor 那一行的 detail 串。**唯一措辞真源**(三端共用一句话;别在各端的行装配里另写
140
+ * 一遍 —— 那正是这次上收要消灭的东西)。
141
+ *
142
+ * 🔴 三座是 UNTRUSTED-for-display(`isolation` 是从**数据库服务器**变量里回读的串):呈前消毒 +
143
+ * 封长。消毒放在这一处而不是各呈现点,是因为本函数的产物就是屏上那一串(单一出口)。
144
+ * 🔴 四句话刻意**互不相同、也互不蕴含**:「未观测」不许说成「没有库」,「老引擎不报」也不许
145
+ * 说成「没有库」—— 三种「读不出」对运维是三条不同的下一步。
146
+ */
147
+ export function sqlEngineDoctorDetail(reading) {
148
+ switch (reading.kind) {
149
+ case 'unobserved':
150
+ return ('not observed — the engine reports it on /v1/capabilities; this one-shot process ' +
151
+ 'has no engine response (run /doctor inside the REPL after a turn)');
152
+ case 'not_reported':
153
+ return 'not reported by this engine — the capability position needs a newer engine';
154
+ case 'none':
155
+ return 'none — the engine reports no SQL backend on this deployment (local file stores)';
156
+ case 'present': {
157
+ const e = cleanSqlDetailScalar(reading.view.engine);
158
+ const i = cleanSqlDetailScalar(reading.view.isolation);
159
+ // 🔴 `null` 是上游明说的「这个引擎没有这个指示位」,**不是** `optimistic`(d.ts 逐字点名的
160
+ // 误读),也不是「读不出来」⇒ 渲一句显式的话,既不留白也不编一个档位。
161
+ // 消毒器对 `null` 无意义(它只吃串),所以这一格必须在消毒**之前**分臂。
162
+ const t = reading.view.txnMode === null
163
+ ? 'no txn-mode indicator on this engine'
164
+ : cleanSqlDetailScalar(reading.view.txnMode);
165
+ return `${e} · isolation ${i} · txn ${t}`;
166
+ }
167
+ }
168
+ }
169
+ /**
170
+ * **换代失效口**(与 {@link import("./engineCapsCache.js").invalidateEngineCaps} 并列,由宿主在
171
+ * 引擎温切成功后调)。
172
+ *
173
+ * 🔴 **代际闸挡不住这一形**:那道闸挡的是「旧响应**写脏**新一代」(tee 只在确认代际仍当代之后
174
+ * 才落地),而本格的病是反过来的 —— 新一代的探测**根本没成功**(超时 / reject / 还在飞),
175
+ * tee 于是一次都没触发,上一台引擎留在同一端口上的读数就继续被 {@link observedSqlEngine}
176
+ * 当成**当代事实**答出去。同端口 respawn / 版本回滚 / 引擎起不来三条路都走这里,而它在
177
+ * doctor 上的形态最坏:一行**肯定句**(「这台部署是 tidb 悲观事务」/「这台引擎不报这一位,
178
+ * 要升级」),说的却是一台已经不在的引擎。
179
+ * 🔴 处置是**清成未观测**,不是留旧值也不是铸一个 `none` —— 「我这一代还没听到答案」是这一格
180
+ * 唯一诚实的话(与 {@link noteEngineCapsForSqlEngine} 对畸形回体的处置同向)。
181
+ * 空串 ⇒ no-op;绝不 throw(它跑在引擎温切路径上)。
182
+ */
183
+ export function forgetSqlEngineReading(baseUrl) {
184
+ if (typeof baseUrl !== 'string' || baseUrl === '')
185
+ return;
186
+ readingByBase.delete(baseUrl);
187
+ }
188
+ /** 测试钩子。 */
189
+ export function __resetSqlEngineReadingsForTests() {
190
+ readingByBase.clear();
191
+ }