@sema-agent/client-core 0.61.0 → 0.62.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 +127 -0
- package/README.md +8 -2
- package/dist/adapt/arms.js +76 -13
- package/dist/adapt/textStream.d.ts +31 -2
- package/dist/adapt/textStream.js +12 -1
- package/dist/adapter/runStream.js +15 -0
- package/dist/agentsWireCaps.d.ts +6 -2
- package/dist/engineWireSdk.d.ts +23 -2
- package/dist/engineWireSdk.js +71 -4
- package/dist/fleetTaskDesc.d.ts +19 -0
- package/dist/fleetTaskDesc.js +34 -0
- package/dist/interactiveHalt.d.ts +40 -17
- package/dist/interactiveHalt.js +32 -20
- package/dist/readFacePosture.js +1 -28
- package/dist/seam.d.ts +24 -1
- package/dist/seam.js +7 -0
- package/dist/seatContract.d.ts +84 -1
- package/dist/seatContract.js +37 -0
- package/dist/sqlEngineCapability.d.ts +2 -2
- package/dist/sqlEngineCapability.js +13 -14
- package/dist/subagentContentStore.d.ts +68 -1
- package/dist/subagentContentStore.js +390 -15
- package/dist/writeProtectionCapability.d.ts +4 -0
- package/dist/writeProtectionCapability.js +7 -3
- package/docs/INTEGRATION-CLIENTS.md +329 -7
- package/package.json +1 -1
|
@@ -38,10 +38,10 @@
|
|
|
38
38
|
*
|
|
39
39
|
* ── 🔴 UNTRUSTED-for-display ──────────────────────────────────────────────────────────────
|
|
40
40
|
* `isolation` 是从**数据库服务器**变量里回读的串(`@@transaction_isolation` 一族),不是引擎铸的
|
|
41
|
-
*
|
|
42
|
-
* ({@link
|
|
41
|
+
* 闭词;三座一律只渲染、绝不参与任何判定,呈现前过本包的单行消毒 + 封长单源
|
|
42
|
+
* ({@link capForDisplay}:先转义、后按转义结果封长)。
|
|
43
43
|
*/
|
|
44
|
-
import {
|
|
44
|
+
import { capForDisplay } from './fleetTaskDesc.js';
|
|
45
45
|
import { engineWireTarget } from './engineWireTarget.js';
|
|
46
46
|
import { engineCapsGeneration } from './engineCapsCache.js';
|
|
47
47
|
/**
|
|
@@ -131,21 +131,20 @@ export function observedSqlEngine(baseUrl = engineWireTarget()?.baseUrl) {
|
|
|
131
131
|
/** 单行 UNTRUSTED 座的展示上限(UTF-16 单元;与壳侧那份同值)。 */
|
|
132
132
|
const SQL_DETAIL_MAX = 40;
|
|
133
133
|
/**
|
|
134
|
-
* 单行 UNTRUSTED
|
|
134
|
+
* 单行 UNTRUSTED 座的呈前规整:**先转义、后按转义结果封长**({@link capForDisplay})。
|
|
135
135
|
*
|
|
136
|
-
* 🔴
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* (
|
|
136
|
+
* 🔴 0.62.0 改了顺序,理由记全(此前这里刻意写的是先截后转义):旧注释担心的是「先清后截会把一个
|
|
137
|
+
* 转义序列拦腰截断,屏上留下 `\u20` 这种既不是字符也不是转义的残片」—— 那个顾虑是对的,但它的
|
|
138
|
+
* 解法不是把顺序反过来,而是**截点避让**:共用铸点在截点落进一枚 `\uXXXX` 中间时回退到该 token
|
|
139
|
+
* 之前(合法代理对同理回退一位)。两个毛病只能同时消掉一次:先截后转义留下的是**预算失守**
|
|
140
|
+
* (纯控制字符的 40 字符原文转义后占 240 个显示字符,是承诺列宽的六倍),那是比残片更硬的一条。
|
|
141
141
|
* ⚠️ **与壳那份的已知呈现差分**(换装时随批裁一次):壳 `sqlEngineDoctorDetail` 走的是
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* `pessimistic`)两侧逐字节相同,只有含控制符的病态值渲染形不同。
|
|
142
|
+
* `.` 占位形消毒器(1:1,先截后清与先清后截等价),而壳自己的头注写着「单行字段(路径/URL/
|
|
143
|
+
* 参数键值)**必须**用可见转义形」—— 三座正是单行标量。本包按那条规则实现,所以对**正常读数**
|
|
144
|
+
* (`tidb` / `REPEATABLE-READ` / `pessimistic`)两侧逐字节相同,只有含控制符的病态值渲染形不同。
|
|
146
145
|
*/
|
|
147
146
|
function cleanSqlDetailScalar(v) {
|
|
148
|
-
return
|
|
147
|
+
return capForDisplay(v, SQL_DETAIL_MAX);
|
|
149
148
|
}
|
|
150
149
|
/**
|
|
151
150
|
* 四态 → doctor 那一行的 detail 串。**唯一措辞真源**(三端共用一句话;别在各端的行装配里另写
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* WIRE FACTS: with `forwardSubagentEvents: true` on the task request, the live stream carries the
|
|
12
12
|
* subagent's text_delta / reasoning_delta / tool_start / tool_end stamped with EventIdentity
|
|
13
|
+
* —— 🔴 **durable 腿(重放)送的是同一段的聚合形** `text` / `reasoning`(整段全文,同样带
|
|
14
|
+
* EventIdentity)。两类都要收:只收增量的话,续听重放尾的宿主整条子代转录都是空的
|
|
15
|
+
* (0.62.0 补;两类的处置**不同**,见 {@link publishSubagentContentEvent} 的吸收规则)
|
|
13
16
|
* (§E1 redact upstream; §E2 identity). 🔴 EventIdentity 的键是 `eventId` / `parentToolCallId`
|
|
14
17
|
* (+ LIVE 白名单四臂上的 `sourceTaskId` / `bgAgentId`)—— **没有 `taskId`**(sdk `events.d.ts` 的
|
|
15
18
|
* `interface EventIdentity` 直证;`taskId` 只长在 `meta` 首帧上)。所以内容帧到不了「自带引擎
|
|
@@ -46,6 +49,19 @@ export type SubagentContentItem = {
|
|
|
46
49
|
| {
|
|
47
50
|
kind: 'echo';
|
|
48
51
|
text: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* **截尾留痕**(0.62.0):字节预算把这条子代早先的内容丢掉了多少。恒**排在 items 最前**、
|
|
55
|
+
* 每条子代**至多一条**(丢一次加一条会把账本自己撑爆),随后续丢弃**原地更新**。
|
|
56
|
+
*
|
|
57
|
+
* 🔴 它存在的唯一理由是「不许无声消失」:没有它,用户读到的转录比真实发生的少一截而毫无提示,
|
|
58
|
+
* 而那正是本包在别处反复记账的那类静默失败。渲染端该把它渲成一行可见的省略说明。
|
|
59
|
+
* 🔴 本记录**自身不计入预算**(它是关于预算的元信息,不是内容)。
|
|
60
|
+
*/
|
|
61
|
+
| {
|
|
62
|
+
kind: 'truncated';
|
|
63
|
+
droppedBytes: number;
|
|
64
|
+
keptFrom: number;
|
|
49
65
|
};
|
|
50
66
|
/**
|
|
51
67
|
* 🔴 [2393] sweep-F15(2026-08-03 全窗复审)**记一笔口径分叉**(裁定:留 `| undefined`,写明为什么):
|
|
@@ -61,10 +77,22 @@ export type SubagentContentItem = {
|
|
|
61
77
|
* (那时两种口径就不能再共存了)。
|
|
62
78
|
*/
|
|
63
79
|
export interface SubagentContentEvent {
|
|
64
|
-
|
|
80
|
+
/**
|
|
81
|
+
* 🔴 六臂,两类语义(0.62.0 加 `text` / `reasoning` 两个**聚合**臂):
|
|
82
|
+
* · `*_delta` = **增量**,语义是「往缓冲里追加」;
|
|
83
|
+
* · `text` / `reasoning` = 一段的**权威全文**(durable 重放腿送的就是这一形),语义是
|
|
84
|
+
* 「这一段最终是这样」—— 处置**不是追加**,见 {@link publishSubagentContentEvent} 的吸收规则。
|
|
85
|
+
* 两类必须分得开:把聚合帧折成一发 `*_delta` 会让「活体流过 + 重放聚合」变成**双份**。
|
|
86
|
+
*/
|
|
87
|
+
type: 'text_delta' | 'reasoning_delta' | 'text' | 'reasoning' | 'tool_start' | 'tool_end';
|
|
65
88
|
taskId: string;
|
|
66
89
|
parentToolCallId: string;
|
|
67
90
|
delta?: string | undefined;
|
|
91
|
+
/** 聚合两臂(`text` / `reasoning`)的**整段全文**。刻意不复用 `delta` 位:同名不同义会让
|
|
92
|
+
* 「这一位是增量还是全文」变成读者要靠 `type` 去反推的事,而反推错的代价是内容重复上屏。 */
|
|
93
|
+
text?: string | undefined;
|
|
94
|
+
/** wire 上那一帧的**事件身份**(聚合两臂的幂等键;缺席 ⇒ 退回内容判据,见 publish 的两道闸)。 */
|
|
95
|
+
eventId?: string | undefined;
|
|
68
96
|
toolCallId?: string | undefined;
|
|
69
97
|
toolName?: string | undefined;
|
|
70
98
|
args?: unknown;
|
|
@@ -104,6 +132,38 @@ export interface SubagentContentEvent {
|
|
|
104
132
|
* 动本函数的块数组分支 ⇒ 同批看那条断言。
|
|
105
133
|
*/
|
|
106
134
|
export declare function coerceOutput(v: unknown): string | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* 本账本的**字节预算**。此前只有条数帽(每条子代 ≤ 200 条 item、≤ 32 条子代),而条数帽对
|
|
137
|
+
* 「一条工具结果里的一整个 base64 附件」「一段几 MB 的模型输出」**零判别力** —— 200 条的上限在
|
|
138
|
+
* **单条无上限**的前提下根本不是一个预算,实测面上一个子代槽位吃到十几 MB 常驻是可复现的。
|
|
139
|
+
*
|
|
140
|
+
* 🔴 单位是 **UTF-8 字节**(不是 JS 串长度):账本里装的是引擎送来的文本,而「多少内存 / 多少屏」
|
|
141
|
+
* 这两件事都跟编码后的体量走;按串长度算会让一段 CJK 的真实占用是名义值的三倍。
|
|
142
|
+
* 🔴 缺省值的来历(如实):对标产品的语料里**没有**对应的「子代转录字节预算」可对齐(查过),
|
|
143
|
+
* 所以这两个数不假称是 parity —— 它们来自 web 端探针实测的「单槽十几 MB」这一观测:总帽取
|
|
144
|
+
* 16 MiB(把整本账本按住在那个量级),每子代帽取总帽的 1/8 = 2 MiB(一条子代吃满也留得下
|
|
145
|
+
* 另外七条的位置)。端可以按自己的宿主改配,这两个数只是「不配时不至于无界」。
|
|
146
|
+
*/
|
|
147
|
+
export interface SubagentContentStoreConfig {
|
|
148
|
+
/** 单条子代**保留**的内容字节上限。超出 ⇒ 从最早的内容开始丢,并留一条 `truncated` 记录。 */
|
|
149
|
+
maxBytesPerTask: number;
|
|
150
|
+
/** 整本账本保留的内容字节上限。超出 ⇒ 整条**最久未用**的子代账本被清掉(不是无差别清空)。 */
|
|
151
|
+
maxBytesTotal: number;
|
|
152
|
+
}
|
|
153
|
+
/** 不配时生效的两位(理由见 {@link SubagentContentStoreConfig} 顶注)。 */
|
|
154
|
+
export declare const SUBAGENT_CONTENT_STORE_DEFAULTS: Readonly<SubagentContentStoreConfig>;
|
|
155
|
+
/**
|
|
156
|
+
* 配字节预算(宿主启动时调一次;两位可分开配,只给一位时另一位保持现值)。
|
|
157
|
+
*
|
|
158
|
+
* 🔴 **fail-loud**:0 / 负数 / 非有限数 / 非整数一律 `throw` —— 一个被**静默忽略**的预算配置正是
|
|
159
|
+
* 这本账本要消灭的那类失败(宿主以为配上了,而账本还在按缺省无声地丢或不丢)。
|
|
160
|
+
* 🔴 拒绝是**原子**的:任何一位不合法就整只拒,既有配置一个字节不动(半套配置比不配更坏)。
|
|
161
|
+
*/
|
|
162
|
+
export declare function configureSubagentContentStore(next: Partial<SubagentContentStoreConfig>): void;
|
|
163
|
+
/** 当前生效的两位(**快照**:改返回值不影响账本)。 */
|
|
164
|
+
export declare function subagentContentStoreConfig(): SubagentContentStoreConfig;
|
|
165
|
+
/** 测试钩:把预算复位到缺省。 */
|
|
166
|
+
export declare function __resetSubagentContentStoreConfigForTests(): void;
|
|
107
167
|
/**
|
|
108
168
|
* **内容归账那一半**(`parentToolCallId → taskId`,多对一):把停在 parent 键下的内容迁到 canonical
|
|
109
169
|
* task 键,并让后续内容帧的 `canonicalKey` 解析到 task。**不写** `taskToParent`。
|
|
@@ -157,6 +217,13 @@ export type SubagentViewSlot = {
|
|
|
157
217
|
kind: 'echo';
|
|
158
218
|
slot: string;
|
|
159
219
|
text: string;
|
|
220
|
+
}
|
|
221
|
+
/** 截尾留痕(0.62.0):渲染端渲一行「更早的 N 字节输出已被预算丢弃」类说明。 */
|
|
222
|
+
| {
|
|
223
|
+
kind: 'truncated';
|
|
224
|
+
slot: string;
|
|
225
|
+
droppedBytes: number;
|
|
226
|
+
keptFrom: number;
|
|
160
227
|
} | {
|
|
161
228
|
kind: 'tool';
|
|
162
229
|
slot: string;
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* WIRE FACTS: with `forwardSubagentEvents: true` on the task request, the live stream carries the
|
|
12
12
|
* subagent's text_delta / reasoning_delta / tool_start / tool_end stamped with EventIdentity
|
|
13
|
+
* —— 🔴 **durable 腿(重放)送的是同一段的聚合形** `text` / `reasoning`(整段全文,同样带
|
|
14
|
+
* EventIdentity)。两类都要收:只收增量的话,续听重放尾的宿主整条子代转录都是空的
|
|
15
|
+
* (0.62.0 补;两类的处置**不同**,见 {@link publishSubagentContentEvent} 的吸收规则)
|
|
13
16
|
* (§E1 redact upstream; §E2 identity). 🔴 EventIdentity 的键是 `eventId` / `parentToolCallId`
|
|
14
17
|
* (+ LIVE 白名单四臂上的 `sourceTaskId` / `bgAgentId`)—— **没有 `taskId`**(sdk `events.d.ts` 的
|
|
15
18
|
* `interface EventIdentity` 直证;`taskId` 只长在 `meta` 首帧上)。所以内容帧到不了「自带引擎
|
|
@@ -89,7 +92,241 @@ export function coerceOutput(v) {
|
|
|
89
92
|
const MAX_ITEMS_PER_TASK = 200;
|
|
90
93
|
const MAX_TASKS = 32;
|
|
91
94
|
const NOTIFY_COALESCE_MS = 250;
|
|
95
|
+
/** 不配时生效的两位(理由见 {@link SubagentContentStoreConfig} 顶注)。 */
|
|
96
|
+
export const SUBAGENT_CONTENT_STORE_DEFAULTS = Object.freeze({
|
|
97
|
+
maxBytesPerTask: 2 * 1024 * 1024,
|
|
98
|
+
maxBytesTotal: 16 * 1024 * 1024,
|
|
99
|
+
});
|
|
100
|
+
let storeConfig = { ...SUBAGENT_CONTENT_STORE_DEFAULTS };
|
|
101
|
+
/**
|
|
102
|
+
* 配字节预算(宿主启动时调一次;两位可分开配,只给一位时另一位保持现值)。
|
|
103
|
+
*
|
|
104
|
+
* 🔴 **fail-loud**:0 / 负数 / 非有限数 / 非整数一律 `throw` —— 一个被**静默忽略**的预算配置正是
|
|
105
|
+
* 这本账本要消灭的那类失败(宿主以为配上了,而账本还在按缺省无声地丢或不丢)。
|
|
106
|
+
* 🔴 拒绝是**原子**的:任何一位不合法就整只拒,既有配置一个字节不动(半套配置比不配更坏)。
|
|
107
|
+
*/
|
|
108
|
+
export function configureSubagentContentStore(next) {
|
|
109
|
+
const check = (name, v) => {
|
|
110
|
+
if (v === undefined)
|
|
111
|
+
return undefined;
|
|
112
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0) {
|
|
113
|
+
throw new TypeError(`configureSubagentContentStore: \`${name}\` must be a positive integer number of bytes (got ${String(v)})`);
|
|
114
|
+
}
|
|
115
|
+
return v;
|
|
116
|
+
};
|
|
117
|
+
const perTask = check('maxBytesPerTask', next.maxBytesPerTask);
|
|
118
|
+
const total = check('maxBytesTotal', next.maxBytesTotal);
|
|
119
|
+
// 🔴 两位的**关系**也要验(车内异源复审逼出:两位各自合法、合起来自相矛盾)。每子代帽大于总帽时,
|
|
120
|
+
// 单条子代可以合法地涨到每子代帽 —— 而总帽淘汰**不动当期那条**(它正在写),于是那一条就整只
|
|
121
|
+
// 越过了总帽,总帽从此形同虚设。判的是**落值之后**的那一对,不是这次传进来的那一位:只配一位
|
|
122
|
+
// 时另一位保持现值,矛盾同样可能是这次配出来的。
|
|
123
|
+
const nextPerTask = perTask ?? storeConfig.maxBytesPerTask;
|
|
124
|
+
const nextTotal = total ?? storeConfig.maxBytesTotal;
|
|
125
|
+
if (nextPerTask > nextTotal) {
|
|
126
|
+
throw new RangeError(`configureSubagentContentStore: \`maxBytesPerTask\` (${String(nextPerTask)}) must not exceed ` +
|
|
127
|
+
`\`maxBytesTotal\` (${String(nextTotal)}) — a per-task budget above the total is unreachable by ` +
|
|
128
|
+
'construction, and the total stops bounding anything.');
|
|
129
|
+
}
|
|
130
|
+
// 两位都验完才落 —— 第二位不合法时第一位也不许生效。
|
|
131
|
+
storeConfig.maxBytesPerTask = nextPerTask;
|
|
132
|
+
storeConfig.maxBytesTotal = nextTotal;
|
|
133
|
+
}
|
|
134
|
+
/** 当前生效的两位(**快照**:改返回值不影响账本)。 */
|
|
135
|
+
export function subagentContentStoreConfig() {
|
|
136
|
+
return { ...storeConfig };
|
|
137
|
+
}
|
|
138
|
+
/** 测试钩:把预算复位到缺省。 */
|
|
139
|
+
export function __resetSubagentContentStoreConfigForTests() {
|
|
140
|
+
storeConfig = { ...SUBAGENT_CONTENT_STORE_DEFAULTS };
|
|
141
|
+
}
|
|
92
142
|
const tasks = new Map();
|
|
143
|
+
/** 整本账本当下保留的内容字节(逐次增删维护;总帽读它)。 */
|
|
144
|
+
let totalBytes = 0;
|
|
145
|
+
/**
|
|
146
|
+
* 一段文本的 **UTF-8 字节数**,不做编码分配。
|
|
147
|
+
*
|
|
148
|
+
* 🔴 为什么不用 `TextEncoder`:它每次都要**分配**一个字节数组,而这条路是逐 token 的热路径
|
|
149
|
+
* (实测 ~23 帧/s 的增量流)。算术形零分配、结果逐字节相同。
|
|
150
|
+
* 🔴 为什么不按 `str.length`(UTF-16 单元)算:账本里装的是引擎送来的文本,一段 CJK 的真实占用
|
|
151
|
+
* 是串长度的三倍 —— 按串长度算等于把预算对中文内容放宽两倍。
|
|
152
|
+
*/
|
|
153
|
+
function utf8Len(str) {
|
|
154
|
+
let n = 0;
|
|
155
|
+
for (let i = 0; i < str.length; i++) {
|
|
156
|
+
const c = str.charCodeAt(i);
|
|
157
|
+
if (c < 0x80)
|
|
158
|
+
n += 1;
|
|
159
|
+
else if (c < 0x800)
|
|
160
|
+
n += 2;
|
|
161
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
|
|
162
|
+
// 合法代理对 = 一个 4 字节码位(高位后面不是低位时按 3 字节的孤代理项算,与编码器同)
|
|
163
|
+
const next = str.charCodeAt(i + 1);
|
|
164
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
165
|
+
n += 4;
|
|
166
|
+
i++;
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
n += 3;
|
|
170
|
+
}
|
|
171
|
+
else
|
|
172
|
+
n += 3;
|
|
173
|
+
}
|
|
174
|
+
return n;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 把 `chunk` 追加到 `prev` 之后,**字节账真正增加了多少**。
|
|
178
|
+
*
|
|
179
|
+
* 🔴 不能直接用 `utf8Len(chunk)`(车内异源复审逼出的真病):一对代理对被上游拆成**两帧**送来时,
|
|
180
|
+
* 前一帧尾部那枚高位代理项按孤代理项算 3 字节、后一帧首部那枚低位代理项也算 3 字节,合计 6;
|
|
181
|
+
* 而拼起来之后它是一个 4 字节码位。每拆一次就虚高 2 字节,而段闭合时是按**拼接后的串**扣账
|
|
182
|
+
* —— 加 6 扣 4,差额永久留在账上。长流里这个漂移会一路把总账推高,最后把还在用的账本淘汰掉。
|
|
183
|
+
* ⇒ 只在**拼接边界**上修正一次(O(1)),不重扫整条缓冲(缓冲能有 MB 级,而这是逐 token 的热路径)。
|
|
184
|
+
*/
|
|
185
|
+
function appendBytes(prev, chunk) {
|
|
186
|
+
const add = utf8Len(chunk);
|
|
187
|
+
if (prev.length === 0 || chunk.length === 0)
|
|
188
|
+
return add;
|
|
189
|
+
const lastPrev = prev.charCodeAt(prev.length - 1);
|
|
190
|
+
const firstNew = chunk.charCodeAt(0);
|
|
191
|
+
const joinsPair = lastPrev >= 0xd800 && lastPrev <= 0xdbff && firstNew >= 0xdc00 && firstNew <= 0xdfff;
|
|
192
|
+
return joinsPair ? add - 2 : add;
|
|
193
|
+
}
|
|
194
|
+
/** 一条 item 的字节账(`truncated` 是关于预算的元信息,自身不计)。 */
|
|
195
|
+
function itemBytesOf(it) {
|
|
196
|
+
switch (it.kind) {
|
|
197
|
+
case 'text':
|
|
198
|
+
case 'thinking':
|
|
199
|
+
case 'echo':
|
|
200
|
+
return utf8Len(it.text);
|
|
201
|
+
case 'tool': {
|
|
202
|
+
let n = utf8Len(it.name) + utf8Len(it.output ?? '');
|
|
203
|
+
// 工具参数是**开集** `unknown`:序列化一次算体量(每张卡只算一次,不在增量热路径上)。
|
|
204
|
+
try {
|
|
205
|
+
if (it.input !== undefined)
|
|
206
|
+
n += utf8Len(JSON.stringify(it.input) ?? '');
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// 循环引用/不可序列化 ⇒ 这一位按 0 计(它进不了展示面,也就占不了这本账的预算)
|
|
210
|
+
}
|
|
211
|
+
return n;
|
|
212
|
+
}
|
|
213
|
+
case 'truncated':
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** 从串**头部**裁掉至少 `want` 字节;返回剩下的尾巴与真正裁掉的字节数(不劈开合法代理对)。 */
|
|
218
|
+
function trimHeadBytes(str, want) {
|
|
219
|
+
let removed = 0;
|
|
220
|
+
let i = 0;
|
|
221
|
+
while (i < str.length && removed < want) {
|
|
222
|
+
const c = str.charCodeAt(i);
|
|
223
|
+
if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
|
|
224
|
+
const next = str.charCodeAt(i + 1);
|
|
225
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
226
|
+
removed += 4;
|
|
227
|
+
i += 2;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
removed += c < 0x80 ? 1 : c < 0x800 ? 2 : 3;
|
|
232
|
+
i += 1;
|
|
233
|
+
}
|
|
234
|
+
return { rest: str.slice(i), removed };
|
|
235
|
+
}
|
|
236
|
+
/** 字节账的唯一增减口(两本账同拍走,永不分叉)。 */
|
|
237
|
+
function addBytes(s, n) {
|
|
238
|
+
s.bytes += n;
|
|
239
|
+
totalBytes += n;
|
|
240
|
+
}
|
|
241
|
+
/** 记一次丢弃:更新/建立留痕记录(每条子代恒一条,恒排在最前)。 */
|
|
242
|
+
function noteDropped(s, dropped) {
|
|
243
|
+
if (dropped <= 0)
|
|
244
|
+
return;
|
|
245
|
+
s.droppedBytes += dropped;
|
|
246
|
+
const head = s.items[0];
|
|
247
|
+
if (head?.kind === 'truncated') {
|
|
248
|
+
head.droppedBytes = s.droppedBytes;
|
|
249
|
+
head.keptFrom = s.droppedBytes;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
s.items.unshift({ kind: 'truncated', droppedBytes: s.droppedBytes, keptFrom: s.droppedBytes });
|
|
253
|
+
s.itemBytes.unshift(0);
|
|
254
|
+
rebuildOpenTools(s);
|
|
255
|
+
}
|
|
256
|
+
/** items 被增删之后重建「开着的工具卡 → 下标」表(下标随 splice/unshift 整体漂移)。 */
|
|
257
|
+
function rebuildOpenTools(s) {
|
|
258
|
+
s.openTools.clear();
|
|
259
|
+
s.items.forEach((it, i) => {
|
|
260
|
+
if (it.kind === 'tool' && it.output === undefined)
|
|
261
|
+
s.openTools.set(it.id, i);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* 每子代帽:超出就**从最早的内容开始丢**,丢不动了再从缓冲头部裁。
|
|
266
|
+
*
|
|
267
|
+
* 🔴 顺序是「先 item 后缓冲」而不是反过来:缓冲是**活体尾巴**(用户正在看的那一段),item 是更早
|
|
268
|
+
* 的历史。反过来做会把用户眼前正在流的字丢掉,而屏上更早那一段完好无损 —— 那是错的一头。
|
|
269
|
+
* 🔴 留痕记录自身不参与丢弃(它是关于预算的元信息;丢掉它等于把「有东西被丢了」这件事也丢了)。
|
|
270
|
+
*/
|
|
271
|
+
function enforceTaskCap(s) {
|
|
272
|
+
const cap = storeConfig.maxBytesPerTask;
|
|
273
|
+
if (s.bytes <= cap)
|
|
274
|
+
return false;
|
|
275
|
+
let dropped = 0;
|
|
276
|
+
const first = () => (s.items[0]?.kind === 'truncated' ? 1 : 0);
|
|
277
|
+
while (s.bytes > cap && s.items.length > first()) {
|
|
278
|
+
const idx = first();
|
|
279
|
+
const b = s.itemBytes[idx] ?? 0;
|
|
280
|
+
s.items.splice(idx, 1);
|
|
281
|
+
s.itemBytes.splice(idx, 1);
|
|
282
|
+
addBytes(s, -b);
|
|
283
|
+
dropped += b;
|
|
284
|
+
}
|
|
285
|
+
if (s.bytes > cap) {
|
|
286
|
+
const cut = trimHeadBytes(s.thinkBuf, s.bytes - cap);
|
|
287
|
+
s.thinkBuf = cut.rest;
|
|
288
|
+
addBytes(s, -cut.removed);
|
|
289
|
+
dropped += cut.removed;
|
|
290
|
+
}
|
|
291
|
+
if (s.bytes > cap) {
|
|
292
|
+
const cut = trimHeadBytes(s.textBuf, s.bytes - cap);
|
|
293
|
+
s.textBuf = cut.rest;
|
|
294
|
+
addBytes(s, -cut.removed);
|
|
295
|
+
dropped += cut.removed;
|
|
296
|
+
}
|
|
297
|
+
if (dropped > 0) {
|
|
298
|
+
rebuildOpenTools(s);
|
|
299
|
+
noteDropped(s, dropped);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* 总帽:整条**最久未用**的子代账本被清掉(`tasks` 的迭代序就是 LRU 序,活跃项每次访问都会重插)。
|
|
306
|
+
* 🔴 刻意不动**当期**那条(它正在写),也刻意不做「每条各裁一点」——半截的转录比一条整齐的缺席更难读。
|
|
307
|
+
*/
|
|
308
|
+
function enforceTotalCap(currentKey) {
|
|
309
|
+
while (totalBytes > storeConfig.maxBytesTotal) {
|
|
310
|
+
let victim;
|
|
311
|
+
for (const k of tasks.keys()) {
|
|
312
|
+
if (k !== currentKey) {
|
|
313
|
+
victim = k;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (victim === undefined)
|
|
318
|
+
return; // 只剩当期那条:它已被每子代帽按住,这是能做到的最好结果
|
|
319
|
+
dropTask(victim);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** 删一条子代账本并把它的字节从总账里扣掉(唯一删除口)。 */
|
|
323
|
+
function dropTask(key) {
|
|
324
|
+
const s = tasks.get(key);
|
|
325
|
+
if (!s)
|
|
326
|
+
return;
|
|
327
|
+
totalBytes -= s.bytes;
|
|
328
|
+
tasks.delete(key);
|
|
329
|
+
}
|
|
93
330
|
// parentToolCallId ↔ engine taskId aliasing: CONTENT events carry ONLY {eventId, parentToolCallId}
|
|
94
331
|
// (EventIdentity — no taskId on the wire), while the panel rows / 查看态 lookups key by the ENGINE task id
|
|
95
332
|
// (task_progress carries BOTH). Content arriving before the first tick parks under the parent key and is
|
|
@@ -115,9 +352,20 @@ function stateFor(taskId, parentToolCallId) {
|
|
|
115
352
|
if (tasks.size >= MAX_TASKS) {
|
|
116
353
|
const oldest = tasks.keys().next().value;
|
|
117
354
|
if (oldest !== undefined)
|
|
118
|
-
|
|
355
|
+
dropTask(oldest); // 条数淘汰同样要把字节从总账里扣掉
|
|
119
356
|
}
|
|
120
|
-
s = {
|
|
357
|
+
s = {
|
|
358
|
+
items: [],
|
|
359
|
+
itemBytes: [],
|
|
360
|
+
textBuf: '',
|
|
361
|
+
thinkBuf: '',
|
|
362
|
+
openTools: new Map(),
|
|
363
|
+
parentToolCallId,
|
|
364
|
+
bytes: 0,
|
|
365
|
+
droppedBytes: 0,
|
|
366
|
+
seenAggregateIds: new Set(),
|
|
367
|
+
recordedSegments: new Set(),
|
|
368
|
+
};
|
|
121
369
|
tasks.set(taskId, s);
|
|
122
370
|
}
|
|
123
371
|
else {
|
|
@@ -126,25 +374,57 @@ function stateFor(taskId, parentToolCallId) {
|
|
|
126
374
|
return s;
|
|
127
375
|
}
|
|
128
376
|
function pushItem(s, item) {
|
|
377
|
+
const b = itemBytesOf(item);
|
|
129
378
|
s.items.push(item);
|
|
379
|
+
s.itemBytes.push(b);
|
|
380
|
+
addBytes(s, b);
|
|
130
381
|
if (s.items.length > MAX_ITEMS_PER_TASK) {
|
|
131
|
-
|
|
382
|
+
const cut = s.items.length - MAX_ITEMS_PER_TASK;
|
|
383
|
+
let freed = 0;
|
|
384
|
+
for (let i = 0; i < cut; i++)
|
|
385
|
+
freed += s.itemBytes[i] ?? 0;
|
|
386
|
+
s.items.splice(0, cut);
|
|
387
|
+
s.itemBytes.splice(0, cut);
|
|
388
|
+
addBytes(s, -freed);
|
|
389
|
+
// 条数帽丢掉的同样是**内容**,同样要留痕(此前这条路是无声的)。
|
|
390
|
+
noteDropped(s, freed);
|
|
132
391
|
// open-tool indexes shifted — rebuild from the surviving items
|
|
133
|
-
s
|
|
134
|
-
s.items.forEach((it, i) => {
|
|
135
|
-
if (it.kind === 'tool' && it.output === undefined)
|
|
136
|
-
s.openTools.set(it.id, i);
|
|
137
|
-
});
|
|
392
|
+
rebuildOpenTools(s);
|
|
138
393
|
}
|
|
139
394
|
}
|
|
140
395
|
/** Close out streaming buffers into items (segment boundary: a tool starts, or the run settles). */
|
|
141
396
|
function flushBuffers(s) {
|
|
142
|
-
|
|
143
|
-
|
|
397
|
+
// 🔴 缓冲的字节此前已经计过账;搬进 item 时先扣掉缓冲那一份,再由 pushItem 计 item 那一份
|
|
398
|
+
// (纯空白段被丢弃时同样要扣 —— 少扣一次,总账就会一路虚高到把别人的账本淘汰掉)。
|
|
399
|
+
addBytes(s, -utf8Len(s.thinkBuf));
|
|
400
|
+
const think = s.thinkBuf;
|
|
144
401
|
s.thinkBuf = '';
|
|
145
|
-
if (
|
|
146
|
-
pushItem(s, { kind: '
|
|
402
|
+
if (think.trim()) {
|
|
403
|
+
pushItem(s, { kind: 'thinking', text: think });
|
|
404
|
+
rememberSegment(s, think);
|
|
405
|
+
}
|
|
406
|
+
addBytes(s, -utf8Len(s.textBuf));
|
|
407
|
+
const text = s.textBuf;
|
|
147
408
|
s.textBuf = '';
|
|
409
|
+
if (text.trim()) {
|
|
410
|
+
pushItem(s, { kind: 'text', text });
|
|
411
|
+
rememberSegment(s, text);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** 有界 FIFO 记号(两本小账共用;只用来做幂等判定,不参与预算)。 */
|
|
415
|
+
const MAX_REPLAY_KEYS = 64;
|
|
416
|
+
function remember(set, key) {
|
|
417
|
+
if (set.has(key))
|
|
418
|
+
return;
|
|
419
|
+
if (set.size >= MAX_REPLAY_KEYS) {
|
|
420
|
+
const oldest = set.values().next().value;
|
|
421
|
+
if (oldest !== undefined)
|
|
422
|
+
set.delete(oldest);
|
|
423
|
+
}
|
|
424
|
+
set.add(key);
|
|
425
|
+
}
|
|
426
|
+
function rememberSegment(s, text) {
|
|
427
|
+
remember(s.recordedSegments, text);
|
|
148
428
|
}
|
|
149
429
|
function scheduleNotify(taskId) {
|
|
150
430
|
pendingNotify.add(taskId);
|
|
@@ -196,6 +476,9 @@ function aliasContentKey(parentToolCallId, taskId) {
|
|
|
196
476
|
pendingNotify.add(taskId);
|
|
197
477
|
const parked = tasks.get(parentToolCallId);
|
|
198
478
|
if (parked && !tasks.has(taskId)) {
|
|
479
|
+
// 🔴 这是**换键**不是删除:同一本账换个键继续用,内容一个字节都没走 ⇒ 这里**不许**走
|
|
480
|
+
// {@link dropTask}(那一口会把它的字节从总账里扣掉,而字节还在)。扣了之后总账会一路虚低,
|
|
481
|
+
// 总帽从此形同虚设。删除只有三个口:clearSubagentContent / 条数淘汰 / 总帽淘汰。
|
|
199
482
|
tasks.delete(parentToolCallId);
|
|
200
483
|
tasks.set(taskId, parked);
|
|
201
484
|
scheduleNotify(taskId);
|
|
@@ -211,6 +494,51 @@ export function registerSubagentAlias(parentToolCallId, taskId) {
|
|
|
211
494
|
return;
|
|
212
495
|
taskToParent.set(taskId, parentToolCallId);
|
|
213
496
|
}
|
|
497
|
+
/**
|
|
498
|
+
* 这一条**聚合帧**是不是「已经收下过的那一条」(durable 重放的两道闸;命中 ⇒ 整帧丢弃)。
|
|
499
|
+
*
|
|
500
|
+
* 为什么光靠缓冲前缀吸收不够(车内异源复审逼出的真病):吸收规则只认**当前缓冲**那一段。一条
|
|
501
|
+
* 跨过工具边界的 turn,早先那些段早就段闭合成 item、缓冲已空 —— 重放时它们的聚合帧再来一遍,
|
|
502
|
+
* `''` 是任何串的前缀,于是整段被当成新内容塞回缓冲,而且塞在**后面那段**之后(顺序也是错的)。
|
|
503
|
+
*
|
|
504
|
+
* 两道闸,顺序刻意如此:
|
|
505
|
+
* ① **事件身份**(`eventId`)—— 精确、无损:同一条账本帧重放多少次都只收一次。
|
|
506
|
+
* ② **整段内容**(`recordedSegments`)—— 兜底:上游没给身份、或本进程这条流是重连后新起的
|
|
507
|
+
* (身份没在这本账上留过),内容判据仍拦得住「这一段已经上过屏」。
|
|
508
|
+
* 📋 **如实留白**:闸 ② 的代价是——一条 turn 里出现**两段逐字节相同**的正文时,durable 腿上只会
|
|
509
|
+
* 留下一段。取舍是明写的:每次重连都把整轮正文再渲一遍是**必然**发生的用户可见损坏,而
|
|
510
|
+
* 「同一条 turn 里说了两遍一模一样的话」是罕见形,且活体腿上两段都在。
|
|
511
|
+
*/
|
|
512
|
+
function replaySeen(s, ev) {
|
|
513
|
+
const id = ev.eventId;
|
|
514
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
515
|
+
if (s.seenAggregateIds.has(id))
|
|
516
|
+
return true;
|
|
517
|
+
remember(s.seenAggregateIds, id);
|
|
518
|
+
}
|
|
519
|
+
const body = ev.text;
|
|
520
|
+
if (typeof body === 'string' && s.recordedSegments.has(body))
|
|
521
|
+
return true;
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* 聚合帧对当前缓冲的**吸收判定**(两个聚合臂共用一份;`null` = 这一帧整只丢弃)。
|
|
526
|
+
*
|
|
527
|
+
* 三形,顺序刻意如此:
|
|
528
|
+
* ① 缓冲是全文的**前缀**(缓冲为空时同样成立)⇒ 换成全文 —— 活体已流过的那半段被这一份接管,
|
|
529
|
+
* 同一条帧重复送达因此**幂等**(全文以自己为前缀)。
|
|
530
|
+
* ② 缓冲是全文被**预算裁过**的尾巴(全文以缓冲结尾)⇒ **丢弃这一帧**。没有这一条,一段已经被
|
|
531
|
+
* 裁掉头部的正文,每重放一次就会被整只追加回来:内容重复、顺序错乱,而且截尾统计会一路虚涨
|
|
532
|
+
* (裁掉的字节被反复重算)——「丢了多少」那个数会变成一句假话。
|
|
533
|
+
* ③ 两者都不是 ⇒ 真分岔,退回**追加**、两段都留下(少渲一段比替引擎判定「哪一段才算数」更坏)。
|
|
534
|
+
*/
|
|
535
|
+
function absorbAggregate(buf, agg) {
|
|
536
|
+
if (agg.startsWith(buf))
|
|
537
|
+
return agg;
|
|
538
|
+
if (buf.length > 0 && agg.endsWith(buf))
|
|
539
|
+
return null;
|
|
540
|
+
return buf + agg;
|
|
541
|
+
}
|
|
214
542
|
function canonicalKey(ev) {
|
|
215
543
|
// the wire's content events carry no taskId — runStream passes parentToolCallId in both slots
|
|
216
544
|
if (ev.taskId !== ev.parentToolCallId)
|
|
@@ -222,12 +550,47 @@ export function publishSubagentContentEvent(ev) {
|
|
|
222
550
|
const s = stateFor(key, ev.parentToolCallId);
|
|
223
551
|
switch (ev.type) {
|
|
224
552
|
case 'text_delta':
|
|
225
|
-
if (typeof ev.delta === 'string')
|
|
553
|
+
if (typeof ev.delta === 'string') {
|
|
554
|
+
// 🔴 增量按**拼接边界**记账(见 appendBytes:代理对被拆成两帧时按 chunk 单算会永久虚高)。
|
|
555
|
+
addBytes(s, appendBytes(s.textBuf, ev.delta));
|
|
226
556
|
s.textBuf += ev.delta;
|
|
557
|
+
}
|
|
227
558
|
break;
|
|
228
559
|
case 'reasoning_delta':
|
|
229
|
-
if (typeof ev.delta === 'string')
|
|
560
|
+
if (typeof ev.delta === 'string') {
|
|
561
|
+
addBytes(s, appendBytes(s.thinkBuf, ev.delta));
|
|
230
562
|
s.thinkBuf += ev.delta;
|
|
563
|
+
}
|
|
564
|
+
break;
|
|
565
|
+
// ── 聚合两臂(durable 重放腿:一段的权威全文)──────────────────────────────────────────
|
|
566
|
+
// 🔴 **吸收**而不是追加:活体已经流过的那一段是这一段全文的**前缀**,追加会让同一段内容
|
|
567
|
+
// 上屏两遍(缝前的事件序号去重管不到这一形——聚合帧与那串增量帧是不同的事件身份)。
|
|
568
|
+
// · 全文以当前缓冲为前缀 ⇒ 缓冲整体换成全文(活体流过 0 字节时同样成立,`''` 是任何串的前缀);
|
|
569
|
+
// 同一条聚合帧重复送达时因此是**幂等**的(全文以自己为前缀)。
|
|
570
|
+
// · 前缀对不上(理论上不该发生:同段的两次不同陈述)⇒ 退回**追加**的老语义并把两段都留下,
|
|
571
|
+
// 少渲一段比替引擎判定「哪一段才算数」更诚实。
|
|
572
|
+
// 🔴 不在这里段闭合:段边界仍由 tool_start / settle 给,与活体腿逐字同一条 —— 在这里 flush
|
|
573
|
+
// 会让「同一段的第二次重放」落成第二个 item(缓冲已空,吸收规则就没有前缀可比了)。
|
|
574
|
+
case 'text':
|
|
575
|
+
if (typeof ev.text === 'string' && ev.text.length > 0 && !replaySeen(s, ev)) {
|
|
576
|
+
const next = absorbAggregate(s.textBuf, ev.text);
|
|
577
|
+
if (next !== null) {
|
|
578
|
+
// 吸收/追加两形都可能改变缓冲长度 ⇒ 按**差额**记账(不是按帧长度加)。
|
|
579
|
+
const before = utf8Len(s.textBuf);
|
|
580
|
+
s.textBuf = next;
|
|
581
|
+
addBytes(s, utf8Len(s.textBuf) - before);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
break;
|
|
585
|
+
case 'reasoning':
|
|
586
|
+
if (typeof ev.text === 'string' && ev.text.length > 0 && !replaySeen(s, ev)) {
|
|
587
|
+
const next = absorbAggregate(s.thinkBuf, ev.text);
|
|
588
|
+
if (next !== null) {
|
|
589
|
+
const before = utf8Len(s.thinkBuf);
|
|
590
|
+
s.thinkBuf = next;
|
|
591
|
+
addBytes(s, utf8Len(s.thinkBuf) - before);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
231
594
|
break;
|
|
232
595
|
case 'tool_start': {
|
|
233
596
|
flushBuffers(s);
|
|
@@ -243,6 +606,10 @@ export function publishSubagentContentEvent(ev) {
|
|
|
243
606
|
const it = s.items[idx];
|
|
244
607
|
it.output = ev.output ?? '';
|
|
245
608
|
it.isError = ev.isError === true;
|
|
609
|
+
// 结果正文是**这一刻**才落到已在账的那条 item 上的 ⇒ 补记它的字节。
|
|
610
|
+
const add = utf8Len(it.output);
|
|
611
|
+
s.itemBytes[idx] = (s.itemBytes[idx] ?? 0) + add;
|
|
612
|
+
addBytes(s, add);
|
|
246
613
|
if (id !== undefined)
|
|
247
614
|
s.openTools.delete(id);
|
|
248
615
|
}
|
|
@@ -260,6 +627,9 @@ export function publishSubagentContentEvent(ev) {
|
|
|
260
627
|
break;
|
|
261
628
|
}
|
|
262
629
|
}
|
|
630
|
+
// 两道帽在**每一次写入之后**执行(不是定时扫):越限那一刻就丢,账本永远不会先胀起来再回落。
|
|
631
|
+
enforceTaskCap(s);
|
|
632
|
+
enforceTotalCap(key);
|
|
263
633
|
scheduleNotify(key);
|
|
264
634
|
}
|
|
265
635
|
/** 查看态 composer echo (C2 steer optimistic display) — segment-closes the buffers first so the echo
|
|
@@ -269,6 +639,8 @@ export function pushSubagentLocalEcho(taskId, text) {
|
|
|
269
639
|
const s = stateFor(taskId, tasks.get(taskId)?.parentToolCallId ?? '');
|
|
270
640
|
flushBuffers(s);
|
|
271
641
|
pushItem(s, { kind: 'echo', text });
|
|
642
|
+
enforceTaskCap(s);
|
|
643
|
+
enforceTotalCap(taskId);
|
|
272
644
|
scheduleNotify(taskId);
|
|
273
645
|
}
|
|
274
646
|
export function getSubagentContentSnapshot(taskId) {
|
|
@@ -310,6 +682,9 @@ export function planSubagentViewSlots(taskId) {
|
|
|
310
682
|
else if (it.kind === 'thinking') {
|
|
311
683
|
out.push({ kind: 'thinking', slot: `k${i}`, text: it.text });
|
|
312
684
|
}
|
|
685
|
+
else if (it.kind === 'truncated') {
|
|
686
|
+
out.push({ kind: 'truncated', slot: `x${i}`, droppedBytes: it.droppedBytes, keptFrom: it.keptFrom });
|
|
687
|
+
}
|
|
313
688
|
else {
|
|
314
689
|
out.push({ kind: 'echo', slot: `e${i}`, text: it.text });
|
|
315
690
|
}
|
|
@@ -336,7 +711,7 @@ export function settleSubagentContent(taskId) {
|
|
|
336
711
|
scheduleNotify(taskId);
|
|
337
712
|
}
|
|
338
713
|
export function clearSubagentContent(taskId) {
|
|
339
|
-
|
|
714
|
+
dropTask(taskId); // 唯一删除口:字节同拍从总账里扣掉
|
|
340
715
|
}
|
|
341
716
|
const bgFacts = new Map();
|
|
342
717
|
export function recordBgTerminalFacts(taskId, facts) {
|
|
@@ -123,6 +123,10 @@ export declare function writeProtectionDoctorDetail(reading: WriteProtectionRead
|
|
|
123
123
|
/**
|
|
124
124
|
* operator 面读数 → 一行 detail(行名是 UNTRUSTED 自由文本 ⇒ 呈前**逐条**消毒 + 封长)。
|
|
125
125
|
*
|
|
126
|
+
* 🔴 消毒与封长的顺序 = **先转义、后按转义结果封长**({@link capForDisplay},0.62.0 起本包该族的
|
|
127
|
+
* 唯一实现)。此前这三处是反的(先按原文 `slice` 再转义),一段纯控制字符的 40 字符原文转义后
|
|
128
|
+
* 能占 240 个显示字符 —— 承诺的列宽预算成了名义值的六倍。同形存量另有 SQL 姿态行一处,同批同改。
|
|
129
|
+
*
|
|
126
130
|
* 🔴 只给**行数与来源**加上「头几行的名字」,不整表倾泻:一行诊断不是一个表格,而 operator 面的
|
|
127
131
|
* 真表读法是那个端点本身。
|
|
128
132
|
* 🔴 `rows: []` 渲的是**显式无表**这句正面事实,不是「读不出」。
|