@sema-agent/client-core 0.68.1 → 0.68.2

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
@@ -49,6 +49,75 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.68.2(2026-09-15)
53
+
54
+ > 主题:**归层** —— 三件「本包一份、端各一份」的东西收成一份。都不是新能力,只是把同一句话
55
+ > 从几层里收回一层;wire 面零改动、既有行为逐字未变(判决归一、**落账不归一**)。
56
+ > 接入面详见 `docs/INTEGRATION-CLIENTS.md` **§35**(逐键处置表 §35z)。
57
+
58
+ ### Added
59
+
60
+ - **`resolveTextSegmentAuthority(content, { committedPrefix, openSegment })`**(L-318)——
61
+ `text_end` 权威段替换的**六形判决单源**,纯函数、零宿主形。0.68.1 落的那份长在
62
+ `adapt/textStream.replaceAnswerSegment` 里(服务交互车道的双平面),而**自己缝 delta 成帧**的
63
+ 车道(`-p` / web / desktop 座位层)拿不到那只段缓冲 ⇒ cli 1.0.114 在壳里逐形又写了一遍。
64
+ 现在两边吃同一只:`form` 三支(`untouched` / `replace_tail` / `withhold`)+ 三位事实
65
+ (`diverged` / `committedPrefixLen` / `committedPrefixDiverged`)+ 尾段切片。
66
+ - **`createTextSegmentLedger()`**(L-318)—— print 形车道的**段账状态机**:已出门前缀跨工具卡
67
+ **累加**、三处清账(durable 取代 / 模型轮收口 / 终局 flush)、被扣段的终局**规范化后逐字**判重。
68
+ - **`memoryCaptureDeclarationField(declared)` / `MEMORY_CAPTURE_OFF`**(L-316)—— 会话级隐私声明
69
+ 的**字面唯一铸点**:端交意图位(布尔),wire 值由本层铸。`false` ⇒ **整键缺席**(缺席就是
70
+ 「照常采集」的唯一写法)。坏拼写仍由 `buildTaskRequest` **响亮拒**(静默删键会让 opt-out 无声丢失)。
71
+ - **`engineUrl` / `probeHealth`(+ 型 `ProbeHealthResult`)**(L-61 件④)—— SDK 纯工具面的
72
+ **原样转口**(同一函数引用,零包装)。只为取这两件而直连 `@sema-agent/sdk` 的端从此有「经包」的路。
73
+ - **`AgentClient` / `isApprovalRequestFrameV1` / `isApprovalRevokeFrameV1` + 一小撮 wire 型面**
74
+ (L-61 存量清零;clay 令 C-R43「所有欠账绝不延期,宁可红」)—— `src/sdkWireTransit.ts`,
75
+ **原样转口**。🔴 **改口记明**:`AgentClient` 在本档早先的处置表里是 `declined`(「转口一个客户端类
76
+ = 本包为它的连接姿势/鉴权头/重试语义背书」)。那条顾虑仍然成立,但它指向的是**设计问题**
77
+ (端该不该直接 new 一个客户端),而把它与**归层问题**(这条 import 该不该经本包)绑在一起,
78
+ 代价是归层被一个未排期的设计件无限期挡住。⇒ 两件分开:归层现在做,设计件照旧欠着 ——
79
+ **新码一律走 `makeEngineWireClient`,本转口口只给存量用**(写在模块头注,不靠人记得)。
80
+ 型面**只收端上存量实际用到的那些名字**,不是 SDK 全量镜像(射程与权威归属见 §35e)。
81
+
82
+ - **`SEMA_SEGMENT_ID_KEY`**(CC-01)—— committed assistant **文本**转录行顶层的段身份键 `_sema_segment_id`,本包在 `adapt()` 出口盖(durable 整条与流式分段两腿同盖;思考块 / 只带 tool_use 的行 / chrome 事件一个字节不动);`text_segment_end` 事件新带**必在** `segmentId`(轮换前的那个,与它盖过的行同值)。语义:一个身份覆盖「上一次段收口 → 本次段收口」之间过境的全部 committed 文本行;同流重放同身份。此前由壳在自己的过境口自铸(先壳后包),本包 diff 门对壳树当场红 —— 铸点归包,壳只读。接入面 §35f。
83
+ - **`projectMcpPanel(body)` / `mcpPanelLastLegDetail(view, { reachable })`**(CC-03)—— `GET /v1/sessions/:id/mcp` 面板体的防御读视图 + `lastLegMcp` 一行措辞唯一铸点(server ≥7.77.0 可选键;缺席 = 键不铸、在场读不懂 = `lastLegMcpUnreadable: true`;`mcp[]` 与 `wiring_manifest` 第三段同一只读器;禁与 `servers[]` 对账)。`projectMcpSection` 随之转公面。接入面 §35g。
84
+
85
+ ### Changed
86
+
87
+ - `adapt/textStream.replaceAnswerSegment` 的六形判据改调判决单源;**落账一行未动**
88
+ (handOffOnly / 活体尾巴重算 / 封存 / 终答补差账同步)。两条车道的账本按不同边界切
89
+ (transcript 消息 vs CC 帧),归一落账 = 改掉其中一条的既有行为,刻意不做(§35z 第 3 行 declined)。
90
+
91
+ - `TextSegmentEndChromeEvent` 新增必在键 `segmentId`(additive;既有三键与义务一字未变);臂表义务文案点名「两把钥匙」。
92
+ - diff 门:`_sema_segment_id` 与 `uuid` 同族按结构别名归一(壳侧随机铸 vs 包侧确定性派生;壳退成只读后自然零触发)。
93
+
94
+ ### Guards
95
+
96
+ - 新门 `scripts/run-segment-authority-single-source-test.mjs`(55 条):六形正控 + 「锚在
97
+ `startsWith` 不在『有没有 flush 过』」负控 + UTF-16 单位(对非 ASCII 样本,其 UTF-8 字节数不同)
98
+ + 与真 `textStream` 的同源钉 + 段账三形 + 字面铸点端到端 + 两个转口口都按**同一函数引用**断言(不是「能调通」)+ 审批帧谓词与 SDK 自己那一只对同一帧同答案(两向都验);⑥ 段用 **TS parser
99
+ 剥注释**后做**第二算式反钉**(行为面对「有没有第二份真相」零判别力 —— 两份独立实现对同一输入
100
+ 本来就会给同一答案)。
101
+ ⚠️ 与 0.68.1 的 `run-text-segment-authority-test.mjs`(守**交互车道的行为**)**分工不同,别合并**。
102
+ - `run-client-core-portability-test.mjs` 的两个棘轮按既有姿势逐件记账上调:A 层闭包 25→26
103
+ (`adapt/textSegmentAuthority.ts`,零 import 纯叶)、index 闭包 159→162(再加 `engineHttpTools.ts`
104
+ 与 `sdkWireTransit.ts`;`EXPECTED_PACKAGES_INDEX` **一字不动** —— SDK 本来就在集合里,且 SDK 的
105
+ `dist/` 全树实测零 `node:` 内建)。
106
+
107
+ ### 已知局限(本版新增)
108
+
109
+ - **中途翻转 verb** `POST /v1/runs/:id/memory/capture-optout`(L-286②)仍是包侧缺口:七种 typed
110
+ 结局的处置表是三端公共判定、归本包,本批只收了**声明形**的铸点。
111
+ - 🔴 **`@sema-agent/core` 的值级面仍然没有「经包」的路**(cli `core-value` 桶 4 条):core 的 barrel
112
+ 值级拉 `node:crypto`/`node:fs`/`node:path`,而本包的可移植门是外部包**等值集** + `--platform=browser`
113
+ 真打包 ⇒ 值级 re-export 会把整台引擎焊进 web/desktop 产物。唯一可用形是**端口注入**
114
+ (`hitl/editedRuleTextPrecheck.ts` 先例),但它要求宿主有一个**可登记的装配根** —— 那是端侧决策,
115
+ 本包单方面解不了。逐条需要的端口签名见 cli 车 ZF 回执。
116
+ - **`memoryCapture` 的求值时刻属端、而且不能随便挑**:它是**时效键**(会话级、单向、不可逆),
117
+ 端必须在「决定这一发提交到哪条会话」的**同一拍**求值。把它提前到请求构造那一刻会在等待窗
118
+ (就绪门 / 退避 / 后台请求跨 `/resume`)里把 A 的声明写到 B 头上 —— 本批异源对抗复审 r2 [high]
119
+ 在 cli 上实复现过。本包只负责「值由谁铸」,**不**替端决定「什么时候铸」;§35d 已把这一条写进用法。
120
+
52
121
  ## 0.68.1(2026-09-15)
53
122
 
54
123
  🔴 **内容批**(零上游换钉:devDep / peer 地板一行未动)。逐件的铸点读法 / 缺席语义 / 三端升级必读三条 /
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.68.1
38
+ **Version:** 0.68.2
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -292,6 +292,7 @@ public-surface guard checks that last one).
292
292
  | `scripts/run-client-core-diff-test.mjs` | Differential equivalence against the CLI reference bridge + replay-id invariant + ledger round-trip |
293
293
  | `scripts/run-seat-contract-keys-test.mjs` | The seat IPC contract: verb list ↔ SPEC ↔ types, element-wise |
294
294
  | `scripts/run-approval-frame-keys-test.mjs` | The tool-approval frame key mirror, element-wise against the SDK's runtime anchor (one carve-out: AHEAD_OF_ANCHOR entries — keys the server already emits but the SDK anchor has not caught up to — may lead by one generation; the gate turns red the day the SDK catches up, forcing the entry's removal — the register is occupied again — this time by the rule-store-unreadable key the engine now defines, carrying both the release that minted it and the byte coordinates that prove it, so the lead is a dated record rather than an exemption; its predecessor left the register the other way, by being retired upstream rather than by the anchor catching up) |
295
+ | `scripts/run-segment-authority-single-source-test.mjs` | The authoritative-segment replacement verdict, single-sourced. `text_end.content` and the `text_delta` stream stopped being byte-identical the day the engine started redacting the former through the same filter as the result, so every consumer now has to decide six ways what to do with the segment it has half-emitted — and until this release that decision existed **twice**: once here for the transcript lane, once in the shell for the print lane, hot-fixed a version apart. The verdict is now one pure function both lanes call, and the guard pins it on the quantity that actually decides the outcome: whether the authoritative text still *starts with* the bytes that already left, not whether a flush has happened — the latter is a precondition, and anchoring on it withholds a perfectly ordinary answer. Each of the six forms is checked with its counter-case, the prefix length is pinned to UTF-16 code units against a non-ASCII sample whose UTF-8 byte count differs (slicing by bytes leaves the very thing being redacted on screen), and the withheld-segment ledger is compared by normalised equality rather than substring, because a short redaction marker quoted in an unrelated later answer would otherwise suppress that answer entirely. The same file pins the session-level memory-capture declaration to one mint point — the wire value is a single-member closed set, and a consumer that spells it wrong gets a loud refusal rather than a silently dropped privacy request — and pins the SDK URL/health transit to be the **same function reference**, since wrapping it would discard the one guarantee the transit exists for. A last section strips comments with the TypeScript parser and asserts the second expression has not grown back |
295
296
  | `scripts/run-print-bash-iserror-test.mjs` | The print lane's Bash `is_error` authority (structured over regex) |
296
297
  | `scripts/run-bash-benign-exit-interpretation-test.mjs` | Benign non-zero Bash exits (`returnCodeInterpretation`) stay non-errors across all three derivation arms, and the annotation transits to the card |
297
298
  | `scripts/run-sdk-floor-test.mjs` | The SDK version floor — and, more to the point, that the *installed* type declarations still carry the keys this package reads |
@@ -305,6 +306,7 @@ public-surface guard checks that last one).
305
306
  | `scripts/run-gate-vocabulary-test.mjs` | The two gate vocabularies — who denied a call (`DeniedBy`, nine words) and who asked about it (`AskOrigin`, eleven) — together with the one place their sentences are minted, so the same denial does not read three different ways across three clients. The tables are copies, not opinions: the gate parses the members straight out of the installed SDK's declarations and reconciles them against the package's tables in both directions, so a word added upstream (nobody renders it, the user sees a bare code) and a word only the package believes in (a branch that can never fire) both fail. Every word must carry its own literal sentence and no two may collide, including the sibling pairs the upstream deliberately split apart — an organization store and a personal rule store being unreadable send you to different people, and the two tighten origins exist precisely to name which layer of engine logic asked. The two fallbacks are pinned distinct because the sets differ in kind: one is genuinely closed on the wire (an out-of-set record is withheld by the engine, so reading one means the record is damaged) while the other is genuinely open (the server only checks for a non-empty string, so an unknown word just means the client is older than the engine) Alongside them sits an **uplift anchor** rather than a third table: the reason a call was decided the way it was is a distinct semantic face from who denied it and who asked, one upstream has not mirrored into the SDK at all, and one whose newest member — a shell command allowed because it only reads — has no sentence anywhere yet. Minting the union here would create the second drifting source the day upstream publishes it, so the guard instead asserts the **absence** from both ends: the SDK declarations carry no such union near that word, and the installed engine’s own list does not carry the word either. The engine end fires first, on the batch that raises the dependency, which is exactly when the ownership question should be answered; the SDK end fires when the mirror lands. Either red is the work order to mint the sentence, never a reason to delete the anchor |
306
307
  | `scripts/run-engine-identity-test.mjs` | The engine generation anchors on `/health` (`pid`, `instanceId`, `startedAt`; engine >=7.67.0). `/health` is the one unauthenticated door and its heartbeat is always green, so "another host restarted the shared engine" used to be discoverable only by having some authenticated request hit a 401 first — a path that misreads a restart as a network fault. The reader narrows each anchor independently (one malformed field never hides the other two) and always hands back a reading object rather than an absence, because the caller is asking which anchors answered, not whether there was a response. The comparison is a three-word verdict, not a boolean: `unknown` when the two readings share no comparable anchor at all — an empty intersection means nothing could be compared, never that nothing changed — and the boolean convenience is pinned so that only `true` is an assertion. Any comparable anchor differing decides `changed`, so a reading whose `startedAt` matches while its `instanceId` does not cannot be waved through as the same life; precedence only decides which anchor gets named in the diagnosis |
307
308
  | `scripts/run-posture-knob-projection-test.mjs` | The three deployment knobs on the operator face (`serverGates.durableApproval` / `streamAskWindowMs` / `sessionAutoTitle`, engine >=7.67.0), each read as a value **plus who set it plus one operator-facing pointer** rather than a bare value — a bare boolean cannot answer why this particular machine is on this setting or how to pin it back, and a default that flips with the deployment shape is invisible without that. A worker too old to report readings still sends a bare boolean; the reader folds it into the same shell so consumers keep one branch, but raises a `legacy` bit, answers `undefined` from the machine-readable source accessor, and mints a sentence that contains no source word at all — claiming a source nobody reported is worse than admitting the worker cannot say. The other two knobs are honestly absent on such a worker rather than defaulted, a malformed side knob drops only itself while the anchor knob drops the whole reading, and the four sentences are pinned literally distinct so an operator can tell "not observed" from "not reported" from a real value. The last leg reads the installed SDK's `openapi.yaml` and `types.d.ts` directly, including a pin that exactly one knob on this face is numeric — the premise the millisecond-to-prose rendering rests on |
309
+ | `scripts/run-mcp-panel-projection-test.mjs` | The `GET /v1/sessions/:id/mcp` panel reader (`projectMcpPanel`; server >=7.77.0 adds the optional `lastLegMcp` key) and the single wording mint for its "last leg" line. Absence of `lastLegMcp` is one literal sentence that never blames the engine version (a new session, a leg outside the retention window, a leg without a manifest and an older engine all look the same on the wire); a key that is present but unreadable is a different sentence plus a `lastLegMcpUnreadable: true` mark, never folded into absence. The `mcp[]` roster goes through the same reader as the live `wiring_manifest` third section, so a replayed roster and a live one have one shape. The two faces of the panel (`servers[]` and the last-leg roster) may legitimately differ, so the view carries no agreement flag and none of the five sentences mentions `servers`. Required keys are pinned to the SDK `openapi.yaml` component bytes |
308
310
  | `scripts/run-read-face-posture-projection-test.mjs` | The operator-face `readFace: ReadFacePosture` reader (server >=7.65.0). Three ways of "can't say" are pinned to three different, literal sentences, and none of them may read as "nothing is pinned" — that statement belongs to exactly one case, `face: null`, which is a positive fact reported by the engine, not an absence: not having read an operator response yet, having read one from an engine too old to report the key, and the engine actually saying nothing is pinned are three different next steps for an operator and must not collapse into each other. `source` is read as an open set (the server's closed four words plus an escape hatch) rather than narrowed to an enum, so a new word added upstream is not silently turned into a bad reading. The free-text `note` is sanitized and length-capped before it is ever rendered. A companion pure function flags disagreement between this face and the tenant-facing `capabilities.readFace` — silent only when the two actually agree, honest-absent when either side cannot be read at all, never asserting agreement as a fact. The gate's last leg reads the installed SDK's own `openapi.yaml` directly rather than restating the schema in prose, so the package's leniency cannot quietly drift from the real contract |
309
311
  | `scripts/run-display-cap-order-test.mjs` | The order in which untrusted text is sanitised and length-capped, across every mint point that puts an engine- or database-supplied string on a screen. The sanitiser rewrites each invisible character as a six-character escape, so capping the **raw** string first and escaping afterwards hands the screen six times the width that was budgeted — a forty-character allowance becomes two hundred and forty. The guard does not hardcode that allowance, because each mint point wraps its field in different fixed prose and the prose moves: it anchors on the deciding quantity instead, feeding one benign and one control-character input of the same length through the same mint and requiring the second not to come out longer. That criterion is immune to wording changes and stays sensitive to the expansion, and it is `<=` rather than `==` on purpose — a correct escape-then-cap backs the cut off a partially-consumed escape token, so the control-character line is legitimately the shorter of the two, and demanding equality would score that avoidance as a regression. Each mint is bracketed by two positive controls (the input really reaches the screen; the cap really engages) and the expansion predicate is shown to turn red against a deliberately cap-then-escape reference, so an all-green run cannot mean the guard simply measured nothing. The shared mint point is checked directly for the two avoidances it owes — never splitting an escape token in half, which would leave something on screen that looks like the beginning of a complete answer, and never splitting a legal surrogate pair, which would manufacture the very lone surrogate the sanitiser exists to catch |
310
312
  | `scripts/run-seat-task-request-origin-test.mjs` | Where every field of the seat lane's send-message payload comes from, and whether it actually lands anywhere. The seat payload is a closed interface this package mints itself, and most of its fields are meant to ride verbatim onto the engine's request body — two facts nothing used to connect, so both directions could drift in silence. A seat field could be named after a request position that does not exist, in which case a client writes to it, the wire carries it, the engine ignores the whole key, and the screen shows a switch that does nothing; conversely a new request position could arrive with no seat to sit in, which is **structural** absence — the closed set *is* the carrier, so a decision missing from it has nowhere to be put at all, the same shape logged when the effort dial had no seat. The guard turns each field's origin into data: either it names the request position it forwards to, or it is declared seat-local with a written reason, and the two are mutually exclusive. Forwarding claims are then checked against the **installed** SDK's type declarations, parsed rather than restated — a hand-copied list of position names would only ever prove that two transcriptions agree. The parser is held to reading top-level positions only, since a nested option object's inner keys would otherwise be mistaken for positions of the request itself, and it proves that discrimination on synthetic input before any verdict is given. The two subagent fields carry a standing regression pin, and the retention window's inner keys are read from the declaration the same way, so a seat that offers a tunable window cannot offer one the wire has no room for |
@@ -363,7 +365,7 @@ public-surface guard checks that last one).
363
365
  | `scripts/run-cost-reconcile-projection-test.mjs` | The **end-of-run cost reconciliation** reaching consumers at all. The engine splits a run's spend on the wire — the task's own cost, which deliberately excludes delegated sub-agents, the delegated total itself, and the within-task compaction subtotal that sits inside the own figure — and states two reconciliation identities for them. The package used to project none of it, so a cost view could only ever see one number and under-reported both delegated and compaction spend. Both structures are now projected onto the result as superset fields in the wire's integer micro-currency unit, read key by key, with unreadable keys dropped individually, an entirely unreadable structure omitted rather than emitted empty, and unknown categories passed through since the vocabulary belongs upstream. The delegated cost stays **absent when it was never priced**, never a fabricated zero. The same reader also feeds a terminal chrome arm carrying the three parts plus the reconciled total, so the two faces can never compute different answers; the reconciled total is minted only when both sides are known, and otherwise a discriminator bit says which side is unknown. **The reference field for total cost keeps its meaning** — it remains the task's own spend and the delegated total is not folded into it — because that is a shape the wider ecosystem reads; the reconciled figure is offered beside it, not in place of it. A frame that carries no stats emits no arm at all, and the existing rule that in-stream per-turn usage is not published for sub-flows is pinned unchanged, since delegated spend arrives once, at the end. The bit that says those figures are a lower bound is **per stream**, not per context: the emit context belongs to the caller and may be reused across streams, so a gap observed on one run is no evidence at all about the next one — the observation is held for the duration of one stream and handed to both projection faces by value, and the guard drives a reused context both sequentially and concurrently to prove neither direction leaks |
364
366
  | `scripts/run-task-progress-terminal-projection-test.mjs` | The one tick that says a delegated child **finished**. The engine fires exactly one final beat carrying a terminal face, and says in the same breath why it exists — so a consumer sees the row finish instead of watching it vanish after the last running beat — but the package's projection whitelist had no seat for that field and its adapter still carried the older premise in a comment, so the terminal beat arrived byte-identical to another running one: the panel row stayed up waiting for a defensive sweep (which only ever settles rows bound to a card still open this turn) or for a separate notification frame. The status now rides through as an **open set** with the vocabulary left upstream, while the question *which words are terminal* is answered by a closed pair on the adapter side — an unrecognised new word takes the running path, because guessing it terminal ends a row that is still working whereas one extra running beat merely renders late. A terminal beat settles the row directly under the lane proof its binding gives it (not the main lane a notification would use, and not by card id, since the engine is naming a child rather than closing a card), freezes the inline group-row twin in the same beat so a later sweep cannot reset the real tool count, clears the session-resident ledger, and fires the stop hook only for a child whose start really fired. It does not mark the row live or emit a second progress beat, and it shares the settled-row ledger with the other two settle legs so a replay or a double-delivery cannot produce a second end. Three things are pinned **unchanged**: a running beat, an absent status (older engines never send the field, and reading absence as terminal would make every child row disappear on its first beat), and the workflow lane gate, which still runs before any of this |
365
367
  | `scripts/run-assistant-arm-identity-test.mjs` | The identity keys on an assistant row, and an explicit account of the two that are **deliberately not** there. What the renderer received was a bare role-and-content object, so a dozen consumer sites downstream were each estimating what the message envelope should have told them. The id is taken from the engine's own event id rather than minted locally, because it has to be **the same value** on the live leg and on a durable replay — a freshly minted one would make a replayed message look new to a host's dedup and to rewind — and when the wire carries none the key is simply absent rather than filled with a random stand-in wearing an identity it does not have; it is also kept distinct from the envelope's own local render key, which is a different identity. The model name comes from what the host pinned when it opened the stream (the request was the host's to build) and is never guessed, since a wrong model name is worse than none once a billing or capability face looks it up. Usage and stop reason are **not** minted on this arm, and the reason is frame order rather than effort: content arms arrive before the turn's closing frame, so at the moment the arm is emitted the engine has not yet said what the round cost — anything put there would be an estimate, which is the very thing this work exists to remove — and synthesising a follow-up assistant update when the real figure lands is also refused, because that shape does not exist upstream and would place a message in the transcript the engine never sent. Their real values leave through the turn's own neutral arm as two superset keys, the usage one reusing the **same single mint point** the footer rollup already folds so the two faces cannot diverge, and the stop reason passed through verbatim as an open set — the machine signal for *was this turn cut short*, previously blind on both the stream and the trace. The existing behaviours beside them are pinned too: no arm at all when usage is wholly absent, and the sub-flow cut-out that keeps a child's turn from driving the leader's face |
366
- | `scripts/run-text-segment-authority-test.mjs` | The **authoritative segment replacement** on `text_end` (L-310, server >=7.75.3). `text_end.content` now goes through the same redactor as `result` and the ledger while `text_delta` stays verbatim, so the two **may differ** — an answer that quoted a credential used to be committed to the local transcript in its unredacted form, because the arm only forwarded the boundary signal. Six timing shapes are pinned, two of which an adversarial review reproduced against the installed engine's real bytes and which the first design got wrong in both directions: a second boundary in the same turn (the per-block case on one provider lane) used to make the first segment's prose vanish, and a boundary that arrives *after* the tool card (the other lane emits it at finalize) used to be read as "this package never handled that segment" and reported nothing at all. Three additive keys, all never-false; the two shapes that look alike are told apart by the second one, because the host's action in them is the opposite. The end-to-end legs drive the real pipeline without hand-inserting a segment commit — doing so is exactly what hid the first defect. A second review round then found two combination timings on top of the first fix — a tool card followed by *more* deltas in the same segment, and a byte count that had been documented as a message count — and both are pinned here too. A third round caught a length that the prose called bytes while the code returned UTF-16 units — harmless in ASCII, and on CJK text enough to leave the credential on screen — plus a backfill ledger that had to be kept in step, so the terminal frame does not re-render the segment a second time — kept in step only where the whole stretch sits in one message, because those ledgers are per-message and a fourth round showed that writing across them charges one message's prose to another. A fifth round settled the whole class into one invariant the guard now checks against the previous release's behaviour: this package only rewrites bytes it is still holding in the current message — once a segment has crossed a package-side boundary it emits the three keys and changes nothing else |
368
+ | `scripts/run-text-segment-authority-test.mjs` | The **authoritative segment replacement** on `text_end` (L-310, server >=7.75.3). `text_end.content` now goes through the same redactor as `result` and the ledger while `text_delta` stays verbatim, so the two **may differ** — an answer that quoted a credential used to be committed to the local transcript in its unredacted form, because the arm only forwarded the boundary signal. Six timing shapes are pinned, two of which an adversarial review reproduced against the installed engine's real bytes and which the first design got wrong in both directions: a second boundary in the same turn (the per-block case on one provider lane) used to make the first segment's prose vanish, and a boundary that arrives *after* the tool card (the other lane emits it at finalize) used to be read as "this package never handled that segment" and reported nothing at all. Three additive keys, all never-false; the two shapes that look alike are told apart by the second one, because the host's action in them is the opposite. The end-to-end legs drive the real pipeline without hand-inserting a segment commit — doing so is exactly what hid the first defect. A second review round then found two combination timings on top of the first fix — a tool card followed by *more* deltas in the same segment, and a byte count that had been documented as a message count — and both are pinned here too. A third round caught a length that the prose called bytes while the code returned UTF-16 units — harmless in ASCII, and on CJK text enough to leave the credential on screen — plus a backfill ledger that had to be kept in step, so the terminal frame does not re-render the segment a second time — kept in step only where the whole stretch sits in one message, because those ledgers are per-message and a fourth round showed that writing across them charges one message's prose to another. A fifth round settled the whole class into one invariant the guard now checks against the previous release's behaviour: this package only rewrites bytes it is still holding in the current message — once a segment has crossed a package-side boundary it emits the three keys and changes nothing else **0.68.2 (CC-01):** the segment identity is now minted here, not by the host: every committed assistant text row carries a top-level `_sema_segment_id` (stamped once at the `adapt()` exit, so the durable whole-message leg and the streamed-segment leg are covered alike; thinking blocks, tool_use-tailed rows and chrome events are left byte-for-byte), `text_segment_end` carries the same value as `segmentId` before rotating, subagent boundaries never rotate, and a replayed stream yields the same identities. Three mutations (no rotation / no stamping / stamping tool_use rows) each turn the guard red |
367
369
  | `scripts/run-gate-negative-controls-test.mjs` | Whether the registry-shaped guards among the 74 suites above actually turn red when the material they check really breaks — a census had found 16 of them clean enough to rehearse safely (closed sets, mirrors, baselines, floors, a type-shape ratchet) without touching any judgement code. Each is exercised by tampering a disk copy of the real material, spawning the guard's own unmodified script, asserting it exits non-zero and names the disease, then restoring the file byte-for-byte. Seven guards of the same shape and 51 behaviour/projection suites are catalogued rather than rehearsed this round — see `docs/GATE-NEGATIVE-CONTROLS.md` for the full table, the reasons, and a one-minute manual replay recipe for each blind one. The suite cross-checks its own case count against that document's row counts in both directions, so a case quietly dropped from the array without the document following is itself an undeclared blind guard. The backup that makes the restore possible is taken by **exclusive create**: checking for it and then copying are otherwise two steps, and two instances can pass the check together — the later one overwrites the only clean copy with material the earlier one has already tampered, and the rehearsal that promises to leave no trace leaves a permanently corrupted file instead. That interleaving is rehearsed too, in a throwaway directory of its own |
368
370
 
369
371
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
@@ -465,11 +465,15 @@ const textSegmentEndArm = function* (m, { text }) {
465
465
  kind: 'text_segment_end',
466
466
  laneProof: MAIN,
467
467
  content,
468
+ // CC-01:帧上带的是**轮换前**的段身份 —— 与本段已过境的 committed 行同值(归属的第二把钥匙)。
469
+ segmentId: text.segmentId(),
468
470
  ...(r.diverged ? { diverged: true } : {}),
469
471
  ...(r.committedPrefixDiverged ? { committedPrefixDiverged: true } : {}),
470
472
  ...(r.committedPrefixLen > 0 ? { committedPrefixLen: r.committedPrefixLen } : {}),
471
473
  ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
472
474
  });
475
+ // CC-01:段界到此为止 —— 事件已交到宿主手上之后才轮换(宿主按帧上的身份认行时,行上盖的还是同一个)。
476
+ text.rotateSegmentIdentity(m);
473
477
  };
474
478
  const promptSuggestionsArm = function* (m) {
475
479
  // T57 第五处断闸(#47 矩阵 #5 同族):子流(parentToolCallId 标)的建议绝不骑主 composer。
@@ -0,0 +1,157 @@
1
+ /**
2
+ * adapt/textSegmentAuthority.ts — `text_end.content` **权威段替换**的判决单源(L-318,0.68.2)。
3
+ *
4
+ * ── 为什么有这个文件(病形一句话)────────────────────────────────────────────────────────────
5
+ * 0.68.1 把权威替换落在 {@link import('./textStream.js').TextStream.replaceAnswerSegment} 里,
6
+ * 那一只服务的是**交互车道**(transcript / chrome 双平面)。而 `-p` 车道走的是另一条链
7
+ * (`eventToSdkMessage` 的内部臂 → 宿主自己的 CC stdout 投影器),它拿不到那只段缓冲 ⇒ cli 1.0.114
8
+ * 在壳里**又实现了一遍同样的六形**(B-122 热修)。同一条语义两份实现 = 上游改口时两边各走各的:
9
+ * 这正是「壳持有第二份真相」那一族病。
10
+ *
11
+ * ⇒ 本模块把**判决**(六形里真正决定结果的那三件事实 + 尾段)抽成一只**纯函数**,两条车道同吃:
12
+ * · 交互车道:`textStream.replaceAnswerSegment` 调它,再按本包的双平面语义落账;
13
+ * · print 车道:宿主(cli `PrintStreamProjector`)调它 + {@link createTextSegmentLedger},
14
+ * 只剩「把尾段写进 CC content block」这一步装配动作留在端上(多端改造设计稿 §8-5:
15
+ * stream-json 帧序/SSE 重铸属端,判定属包)。
16
+ *
17
+ * 🔴 **本模块零宿主形**:进出全是 `string` / `number` / `boolean` —— 没有 CC content block、没有
18
+ * transcript 消息、没有帧。可移植门(`--platform=browser` 真打包)因此一个字节都不受影响。
19
+ *
20
+ * ── 判决与落账刻意分家(读这一段再改本文件)──────────────────────────────────────────────────
21
+ * 两条车道的**账本形状不同**,而且那个差别是有理由的,不许在这里归一:
22
+ * · 交互车道的已提交前缀分两本(`previousSegmentCommitted` / `segmentCommitted`),因为它按
23
+ * **transcript 消息**划界;段跨过消息边界、且已提交那截自己也过期时它**只发信号不动字节**
24
+ * (`handOffOnly`,0.68.1 轮五 finding② 的基线对照结论);
25
+ * · print 车道只有一本(工具卡边界带走的字节跨卡累加),没有那一形 —— 它的 stdout 是只进不退
26
+ * 的流,`(b2)` 恒走「一个字节都不再交」。
27
+ * ⇒ 本模块只交**判决**;「判决出来之后各自怎么落账」仍归各自那一层。把落账也归一 = 改掉其中一条
28
+ * 车道的既有行为,那是行为面改动,不是归层。
29
+ */
30
+ /** 六形归到三种**处置**(宿主按 {@link TextSegmentAuthority.form} 分支,不按「有没有 flush 过」分支)。 */
31
+ export type TextSegmentAuthorityForm =
32
+ /** (d)/(f):本层一个字节都没经手这一段 ⇒ 什么都不做(零替换、零披露)。 */
33
+ 'untouched'
34
+ /** (a)/(b1)/(c)/(e)-对得上:未提交的尾段整只换成 {@link TextSegmentAuthority.tail}。 */
35
+ | 'replace_tail'
36
+ /** (b2):已提交前缀自己也过期 ⇒ **一个字节都不再交**,由宿主按前缀长度换掉那一截。 */
37
+ | 'withhold';
38
+ /**
39
+ * 一次权威替换的判决 —— **四件事实**,一个字节的正文都不复述(`tail` 是权威全文的切片,不是复述)。
40
+ *
41
+ * 🔴 三位事实互不替代(L-310 设计定谳,0.68.1 头注逐字保留):
42
+ * · {@link diverged} 回答「活体/已发那一份对不对」⇒ 要不要重渲;
43
+ * · {@link committedPrefixDiverged} 回答「**已经撤不回**的那一截是不是过期的」⇒ 是换尾巴还是
44
+ * 一个字节都不交。两位合成一个办不到:b1/b2 两形的 `diverged` 与 `committedPrefixLen` 完全同形,
45
+ * 而消费动作正相反;
46
+ * · {@link committedPrefixLen} 是**定位量**。
47
+ */
48
+ export interface TextSegmentAuthority {
49
+ readonly form: TextSegmentAuthorityForm;
50
+ /** 本层缝出来的那一段(已提交前缀 + 还押着的段缓冲)与权威全文**不逐字节相等**。 */
51
+ readonly diverged: boolean;
52
+ /**
53
+ * 本段在替换发生前**已经撤不回**的长度。
54
+ *
55
+ * 🔴 **单位 = JS 字符串长度(UTF-16 代码单元),不是 UTF-8 字节,也不是「几条消息/几条帧」**
56
+ * (0.68.1 轮三 finding① / 轮二 finding② 两条订正的合并结论)。按 UTF-8 字节去截会在任何
57
+ * 非 ASCII 正文上截错位置;按「消息」去丢会把已经定稿的上一段一起删掉。
58
+ * 🔴 它按**替换前的拼文**计长,**不是** `content` 上的偏移量。
59
+ */
60
+ readonly committedPrefixLen: number;
61
+ /** 已提交的那一截**自己**与权威全文对不上(= 凭据落在已经出门的那一截里)。 */
62
+ readonly committedPrefixDiverged: boolean;
63
+ /**
64
+ * `replace_tail` 形下,未提交部分该换成的那一截 = `content.slice(committedPrefixLen)`。
65
+ *
66
+ * 🔴 另外两形下恒为空串,**而且不许拿它当「没有尾段」的判据** —— `withhold` 形下权威全文整份
67
+ * 都在帧上,由宿主换掉前缀那一截;`untouched` 形下本层压根没有可替换的对象。分支看
68
+ * {@link form},不看这一位的长度。
69
+ */
70
+ readonly tail: string;
71
+ }
72
+ /** {@link resolveTextSegmentAuthority} 的输入 —— 调用层把自己那两截字节交进来。 */
73
+ export interface TextSegmentAuthorityInput {
74
+ /**
75
+ * 本段**已经撤不回**的那一截(交互车道 = 两本前缀账相加;print 车道 = 跨工具卡累加的已发前缀)。
76
+ *
77
+ * 🔴 **必须是相加后的那一份,不是二选一**(0.68.1 轮二 finding① 实抓):openai 车道的
78
+ * `text_end` 在 `finalize()` 里才 push,而工具卡先执行 —— 一段散文可以跨过两张卡,每一张
79
+ * 都带走一截。少算任何一截,`content.startsWith(前缀)` 这个判据就会在**没分歧**的常态上
80
+ * 误判成分歧。
81
+ */
82
+ readonly committedPrefix: string;
83
+ /** 本段**还押在调用层手里、可以改**的那一截(段缓冲 / 未封存的块尾)。 */
84
+ readonly openSegment: string;
85
+ }
86
+ /**
87
+ * 权威段替换的**六形判决**(两条车道共用的那一份;`content` 是引擎报的段权威全文)。
88
+ *
89
+ * 六形与它们各自的判据锚:
90
+ * (a) 常态:整段还押在调用层手里 ⇒ `replace_tail`,`committedPrefixLen === 0`;
91
+ * (b1) 半段已出门、且 `content.startsWith(前缀)` ⇒ `replace_tail`,只换未出门那一截;
92
+ * (b2) 半段已出门、**前缀自己也过期** ⇒ `withhold`。🔴 判据锚 = `startsWith`,**不是**「有没有
93
+ * flush/提交过」—— 后者是前置条件,前者才是真正决定结果的那个量;
94
+ * (c) 一 turn 多段(anthropic 车道逐 `content_block_stop` 发 `text_end`):本函数只交尾段,
95
+ * 「换完即封存、下一段另起」由调用层落账 —— 不封存的话第二帧会把第一段正文一起覆盖;
96
+ * (d) 子流(`parentToolCallId` 在场):**根本不该走到这里**,断闸在调用层的臂上(拿子代的段边界
97
+ * 去改 leader 的缓冲 = 跨 lane 状态破坏)。本函数不认识 lane 位,也刻意不去认识它;
98
+ * (e) 迟到的段边界(openai 车道:工具卡先执行、`text_end` 在 `finalize()` 才到)⇒ 由调用层把
99
+ * 两本账相加后交进 {@link TextSegmentAuthorityInput.committedPrefix},照 (b1)/(b2) 分流;
100
+ * (f) 调用层一个字节都没经手这一段(两截皆空)⇒ `untouched`。替换的语义是「换掉本层自己缝合
101
+ * 出来的那一段」,不是「凭空铸一段」:durable 整块 `text` 腿之后再来一帧 `text_end`,塞进
102
+ * 空缓冲就会在收口**再铸一条一模一样的**(0.68.1 `#323-g` 正控守的就是这一形)。
103
+ *
104
+ * @param content 该段的权威全文(UNTRUSTED、仅展示,与 `text_delta` 同契约 —— 渲染,绝不回喂模型)。
105
+ */
106
+ export declare function resolveTextSegmentAuthority(content: string, input: TextSegmentAuthorityInput): TextSegmentAuthority;
107
+ /**
108
+ * print 形车道的**段账**(L-318;交互车道不吃这一只 —— 它的账按 transcript 消息分两本,见文件头注)。
109
+ *
110
+ * 归包的理由与 `buildTaskRequest` / `taskNotificationToPrintFrame` 同一条(多端改造设计稿 §8-5):
111
+ * 这是一台**状态机**(什么时候前缀作废、什么时候段封顶、扣下的段怎么在终局判重),属三端公共判定;
112
+ * 「把尾段写进哪一个 content block」才是端的装配动作。
113
+ *
114
+ * 🔴 本台账**不持有正文块**:它只记「已经出门的那一截」「被扣下的那些段」。块对象身份锚
115
+ * (封存点属于哪一个块)必须留在端上 —— 那是 CC 帧形的事,包里没有那个概念。
116
+ */
117
+ export interface TextSegmentLedger {
118
+ /**
119
+ * 一截字节**从这一拍起出门了**(print 车道 = 叙述搭上 tool_use 那条 assistant 帧发走)。
120
+ * 🔴 **累加,不是覆盖**:一段散文可以跨过两张卡,每张带走一截(见
121
+ * {@link TextSegmentAuthorityInput.committedPrefix} 的 (e) 形注)。
122
+ */
123
+ noteFlushed(text: string): void;
124
+ /**
125
+ * 段账整只作废。三个调用点,语义同为「这一段到此为止,已发前缀对下一段无意义」:
126
+ * · **durable 整块 `text` 臂**:那一帧本身就是这一段的唯一载体(server 对 durable 行在写口与
127
+ * 读口各脱一次,它本身就是权威形),本层攒的段被它取代;
128
+ * · **模型轮收口**(`turn_end`):一个引擎段不可能跨模型轮(每次 API 调用有自己的 content
129
+ * block)⇒ 「这一段的 `text_end` 此后不会再来了」是事实,不是猜。不清账的后果实测过:
130
+ * 引擎省掉 `text_end` 又继续下一轮时,上一轮的已发前缀会让下一轮**正常**的 `text_end`
131
+ * 误入 (b2),新终答被整段清掉还附一条错的披露行;
132
+ * · **终局 flush**:流到此为止。
133
+ */
134
+ closeSegmentAccount(): void;
135
+ /**
136
+ * 引擎明说这一段写完了 ⇒ 出判决 + 收账(`withhold` 形同批登记被扣段,供
137
+ * {@link TextSegmentLedger.repeatsWithheldSegment} 在终局判重)。
138
+ *
139
+ * @param openSegment 端此刻还押在手里、可以改的那一截(未封存的块尾)。
140
+ */
141
+ applyTextEnd(content: string, openSegment: string): TextSegmentAuthority;
142
+ /**
143
+ * 终局兜底臂的让位判据 —— 「这一发要交的,**就是**刚才被扣的那一段吗」。
144
+ *
145
+ * 🔴 判据 = 规范化(`trim`)后**逐字相等**,不是子串关系(0.68.1 轮二两条实复现反例):
146
+ * ① 被扣段短到只剩一个脱敏标记时,后续**另一段**独立终答只要提到同一个标记就被整条挡掉
147
+ * (`includes` 误挡);② 被扣段以换行结尾时,引擎的 `assembleResult` 会 **trim** 最终正文
148
+ * ⇒ `includes` 不匹配,权威全文被兜底臂完整再发一次(漏挡)。
149
+ * 🔴 **登记的是一张表不是一个值**:一条 run 里可以有多段先后落 (b2)(多轮工具循环),
150
+ * 只记最后一段会让更早那段的重发漏挡。
151
+ */
152
+ repeatsWithheldSegment(candidate: string): boolean;
153
+ /** 当前已出门前缀的长度(诊断/断言读位;分支判决一律走 {@link applyTextEnd} 的回执)。 */
154
+ readonly committedPrefixLength: number;
155
+ }
156
+ /** 建一本 print 形段账(一条流一本;跨流复用会把上一条流的已发前缀算进这一条)。 */
157
+ export declare function createTextSegmentLedger(): TextSegmentLedger;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * adapt/textSegmentAuthority.ts — `text_end.content` **权威段替换**的判决单源(L-318,0.68.2)。
3
+ *
4
+ * ── 为什么有这个文件(病形一句话)────────────────────────────────────────────────────────────
5
+ * 0.68.1 把权威替换落在 {@link import('./textStream.js').TextStream.replaceAnswerSegment} 里,
6
+ * 那一只服务的是**交互车道**(transcript / chrome 双平面)。而 `-p` 车道走的是另一条链
7
+ * (`eventToSdkMessage` 的内部臂 → 宿主自己的 CC stdout 投影器),它拿不到那只段缓冲 ⇒ cli 1.0.114
8
+ * 在壳里**又实现了一遍同样的六形**(B-122 热修)。同一条语义两份实现 = 上游改口时两边各走各的:
9
+ * 这正是「壳持有第二份真相」那一族病。
10
+ *
11
+ * ⇒ 本模块把**判决**(六形里真正决定结果的那三件事实 + 尾段)抽成一只**纯函数**,两条车道同吃:
12
+ * · 交互车道:`textStream.replaceAnswerSegment` 调它,再按本包的双平面语义落账;
13
+ * · print 车道:宿主(cli `PrintStreamProjector`)调它 + {@link createTextSegmentLedger},
14
+ * 只剩「把尾段写进 CC content block」这一步装配动作留在端上(多端改造设计稿 §8-5:
15
+ * stream-json 帧序/SSE 重铸属端,判定属包)。
16
+ *
17
+ * 🔴 **本模块零宿主形**:进出全是 `string` / `number` / `boolean` —— 没有 CC content block、没有
18
+ * transcript 消息、没有帧。可移植门(`--platform=browser` 真打包)因此一个字节都不受影响。
19
+ *
20
+ * ── 判决与落账刻意分家(读这一段再改本文件)──────────────────────────────────────────────────
21
+ * 两条车道的**账本形状不同**,而且那个差别是有理由的,不许在这里归一:
22
+ * · 交互车道的已提交前缀分两本(`previousSegmentCommitted` / `segmentCommitted`),因为它按
23
+ * **transcript 消息**划界;段跨过消息边界、且已提交那截自己也过期时它**只发信号不动字节**
24
+ * (`handOffOnly`,0.68.1 轮五 finding② 的基线对照结论);
25
+ * · print 车道只有一本(工具卡边界带走的字节跨卡累加),没有那一形 —— 它的 stdout 是只进不退
26
+ * 的流,`(b2)` 恒走「一个字节都不再交」。
27
+ * ⇒ 本模块只交**判决**;「判决出来之后各自怎么落账」仍归各自那一层。把落账也归一 = 改掉其中一条
28
+ * 车道的既有行为,那是行为面改动,不是归层。
29
+ */
30
+ /**
31
+ * 权威段替换的**六形判决**(两条车道共用的那一份;`content` 是引擎报的段权威全文)。
32
+ *
33
+ * 六形与它们各自的判据锚:
34
+ * (a) 常态:整段还押在调用层手里 ⇒ `replace_tail`,`committedPrefixLen === 0`;
35
+ * (b1) 半段已出门、且 `content.startsWith(前缀)` ⇒ `replace_tail`,只换未出门那一截;
36
+ * (b2) 半段已出门、**前缀自己也过期** ⇒ `withhold`。🔴 判据锚 = `startsWith`,**不是**「有没有
37
+ * flush/提交过」—— 后者是前置条件,前者才是真正决定结果的那个量;
38
+ * (c) 一 turn 多段(anthropic 车道逐 `content_block_stop` 发 `text_end`):本函数只交尾段,
39
+ * 「换完即封存、下一段另起」由调用层落账 —— 不封存的话第二帧会把第一段正文一起覆盖;
40
+ * (d) 子流(`parentToolCallId` 在场):**根本不该走到这里**,断闸在调用层的臂上(拿子代的段边界
41
+ * 去改 leader 的缓冲 = 跨 lane 状态破坏)。本函数不认识 lane 位,也刻意不去认识它;
42
+ * (e) 迟到的段边界(openai 车道:工具卡先执行、`text_end` 在 `finalize()` 才到)⇒ 由调用层把
43
+ * 两本账相加后交进 {@link TextSegmentAuthorityInput.committedPrefix},照 (b1)/(b2) 分流;
44
+ * (f) 调用层一个字节都没经手这一段(两截皆空)⇒ `untouched`。替换的语义是「换掉本层自己缝合
45
+ * 出来的那一段」,不是「凭空铸一段」:durable 整块 `text` 腿之后再来一帧 `text_end`,塞进
46
+ * 空缓冲就会在收口**再铸一条一模一样的**(0.68.1 `#323-g` 正控守的就是这一形)。
47
+ *
48
+ * @param content 该段的权威全文(UNTRUSTED、仅展示,与 `text_delta` 同契约 —— 渲染,绝不回喂模型)。
49
+ */
50
+ export function resolveTextSegmentAuthority(content, input) {
51
+ const committedPrefix = input.committedPrefix;
52
+ const openSegment = input.openSegment;
53
+ // (f) 两截皆空 ⇒ 什么都不做。三位全报否(never-false 律:缺席 = 否定)。
54
+ if (committedPrefix.length === 0 && openSegment.length === 0) {
55
+ return {
56
+ form: 'untouched',
57
+ diverged: false,
58
+ committedPrefixLen: 0,
59
+ committedPrefixDiverged: false,
60
+ tail: '',
61
+ };
62
+ }
63
+ const committedPrefixLen = committedPrefix.length;
64
+ const diverged = committedPrefix + openSegment !== content;
65
+ const committedPrefixDiverged = committedPrefixLen > 0 && !content.startsWith(committedPrefix);
66
+ if (committedPrefixDiverged) {
67
+ // (b2):已出门那截撤不回,而切片拼不出 `content` —— 再补一条就是同一段话上屏两遍。
68
+ return { form: 'withhold', diverged, committedPrefixLen, committedPrefixDiverged: true, tail: '' };
69
+ }
70
+ return {
71
+ form: 'replace_tail',
72
+ diverged,
73
+ committedPrefixLen,
74
+ committedPrefixDiverged: false,
75
+ tail: content.slice(committedPrefixLen),
76
+ };
77
+ }
78
+ /** 建一本 print 形段账(一条流一本;跨流复用会把上一条流的已发前缀算进这一条)。 */
79
+ export function createTextSegmentLedger() {
80
+ let committedPrefix = '';
81
+ const withheld = [];
82
+ return {
83
+ noteFlushed(text) {
84
+ committedPrefix += text;
85
+ },
86
+ closeSegmentAccount() {
87
+ committedPrefix = '';
88
+ },
89
+ applyTextEnd(content, openSegment) {
90
+ const verdict = resolveTextSegmentAuthority(content, { committedPrefix, openSegment });
91
+ if (verdict.form === 'withhold')
92
+ withheld.push(content);
93
+ // 🔴 `untouched` 形也清账:两截皆空本来就没有账可留,清它是幂等的;而漏清会把一条
94
+ // 「本层零经手」的段边界留成下一段的前缀(那正是 (b2) 误判的来源)。
95
+ committedPrefix = '';
96
+ return verdict;
97
+ },
98
+ repeatsWithheldSegment(candidate) {
99
+ const norm = candidate.trim();
100
+ if (norm.length === 0)
101
+ return false;
102
+ return withheld.some(w => w.trim() === norm);
103
+ },
104
+ get committedPrefixLength() {
105
+ return committedPrefix.length;
106
+ },
107
+ };
108
+ }
@@ -28,6 +28,31 @@
28
28
  */
29
29
  import type { AdapterContext, AdapterOutput } from '../seam.js';
30
30
  import { type Frame, type IdOf } from './ids.js';
31
+ /**
32
+ * **段身份键**(CC-01,0.68.2)—— 打在 committed assistant **文本**转录行**顶层**的 `_sema_` 超集键。
33
+ *
34
+ * ── 为什么有它(归层修,不是新功能)──────────────────────────────────────────────────────────
35
+ * `text_segment_end` 到达时若已提交前缀自己也过期(`committedPrefixDiverged`),宿主要在**自己持有的**
36
+ * 转录里找出「这一段交出去的那一行」把它换掉。按字节相等去找会被**同后缀的独立行**冒充(实撞过);
37
+ * 按 uuid 找只证明「是这一行」,不证明「是这一段的」。⇒ 归属要两把钥匙:行身份(`uuid`)∧ **段身份**。
38
+ * 段身份此前由壳在自己的过境口铸(先壳后包);段的边界(开场 / 收口 / 轮换)本来就只有本模块知道,
39
+ * 铸点归这里,壳只读。
40
+ *
41
+ * ── 语义(一句话)────────────────────────────────────────────────────────────────────────────
42
+ * **一个身份覆盖「上一次段收口 → 本次段收口」之间过境的全部 committed 文本行**:流开场一个初值;
43
+ * idle-flush 提交的半段与终态提交的同段共享一个身份(归属正要这个);`text_segment_end` 臂处理完之后
44
+ * 轮换(帧上带的是轮换**前**的那一个,与它盖过的行同值);思考→回答边界**不**轮换;子流(带
45
+ * `parentToolCallId`)的段边界不轮换(它们不产 leader 事件,见臂头注)。
46
+ *
47
+ * ── 铸法与形 ──────────────────────────────────────────────────────────────────────────────
48
+ * 派生自本窗口的**第一帧**锚 + 窗口序号(`idOf(anchor, 'segment<n>')`)⇒ **同流重放同身份**
49
+ * (与 committed 行的 uuid 同一条确定性纪律;宿主的双跑对拍门靠它)。🔴 它是**转录格式**上的键,
50
+ * 不是 wire 键:只出现在宿主落盘的转录行上,不进 provider 请求(顶层自铸键不外溢,与
51
+ * `_sema_degraded` 同律)。🔴 **只盖尾块是 `text` 的 assistant 行**:只带 `tool_use` 的行永远不可能是
52
+ * 替换目标,盖了只是往转录里加噪;思考块同理。缺席 = 「这条不属于任何引擎段」,不是「属于某个未知段」。
53
+ * 🔴 **只加不减**:非本形消息一个字节不动。
54
+ */
55
+ export declare const SEMA_SEGMENT_ID_KEY = "_sema_segment_id";
31
56
  /**
32
57
  * {@link TextStream.replaceAnswerSegment} 的回执 —— **三件事实**,一个字节的正文都不复述。
33
58
  *
@@ -207,6 +232,22 @@ export interface TextStream {
207
232
  * 🔴 它**不动** `emittedAssistantText`:那一位问的是「这一轮产没产过正文」,与消息边界无关。
208
233
  */
209
234
  beginAssistantMessage(): void;
235
+ /**
236
+ * 当前**段身份**(语义与铸法见 {@link SEMA_SEGMENT_ID_KEY} 头注)。同一窗口内多次读**同值**;
237
+ * 首次读时窗口还没有帧锚(durable 整条消息先于任何增量到达)⇒ 退到 `ctx.uuid()` 并缓存。
238
+ */
239
+ segmentId(): string;
240
+ /**
241
+ * **段界到此为止**:换新段身份(下一窗口以 `anchor` 为帧锚)。调用点恰一处 = `text_segment_end`
242
+ * 臂处理完之后(帧上带的是轮换前的那一个)。🔴 不在这里之外的任何地方轮换。
243
+ */
244
+ rotateSegmentIdentity(anchor: Frame): void;
245
+ /**
246
+ * **出口单点**:committed assistant **文本**行盖当前段身份({@link SEMA_SEGMENT_ID_KEY});
247
+ * 非本形(chrome 事件 / 思考块 / 只带 tool_use 的行 / 非 assistant)**原样返回,一个字节不动**。
248
+ * 调用点恰一处 = `adapt()` 的出口(durable 整条与流式分段两条腿都从那里过)。
249
+ */
250
+ stampSegmentIdentity(out: AdapterOutput): AdapterOutput;
210
251
  /** D8 收口日志读位(`answer` 本体不出模块)。 */
211
252
  readonly answerLength: number;
212
253
  }