@thincoder/core 0.10.12 → 0.10.13

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.
@@ -15,6 +15,9 @@
15
15
  */
16
16
  import { readFileSync } from "node:fs"
17
17
 
18
+ /** 段判据类名单源(报面与统计同取此名——改报面忘改过滤的字面漂移,2026-10-26 实发过一次)。 */
19
+ export const DOC_CLASS = Object.freeze({ para: "文字太长,难于阅读", line: "行太长" })
20
+
18
21
  /** 表格行谓词(markdown 表格行结构性不可折行——不计入段,与锚检查同源)。 */
19
22
  export const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(String(l))
20
23
 
@@ -62,7 +65,7 @@ export function inCodeSpan(line, idx) {
62
65
 
63
66
  /** 段判据(阈值 = 调用方内置常量 `paragraphMax` ∥ `lineMax`;口径逐字 = §3.18 条目 Q):
64
67
  * · 文档面:相邻非空正文行合并成一段,段长 = 组成行 `[...line].length` 之和 > `paragraphMax` ⇔ 命中
65
- * (`clazz: "段太长"`,定位 = 段首行号)。正文行 = 非空 ∧ 非表格行 ∧ 非标题 ∧ 非列表项 ∧ 非引用行
68
+ * (`clazz: "文字太长,难于阅读"`,定位 = 段首行号)。正文行 = 非空 ∧ 非表格行 ∧ 非标题 ∧ 非列表项 ∧ 非引用行
66
69
  * ∧ 非围栏(标记行与块内整行)∧ 非区带内;其余行类不计入、且把段断开。
67
70
  * · 提示词面(`isPromptFace`):逐行裸判——`[...line].length` > `lineMax` ⇔ 命中(`clazz: "行太长"`,
68
71
  * 无结构豁免)。
@@ -79,7 +82,7 @@ export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files
79
82
  if (isPromptFace(f)) {
80
83
  text.split("\n").forEach((l, i) => {
81
84
  const len = [...l].length
82
- if (len > lineMax) hits.push({ file: f, line: i + 1, len, clazz: "行太长" })
85
+ if (len > lineMax) hits.push({ file: f, line: i + 1, len, clazz: DOC_CLASS.line })
83
86
  })
84
87
  continue
85
88
  }
@@ -88,7 +91,7 @@ export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files
88
91
  let para = null // 在写的段 —— { start, len };非正文行 ⇒ 断开
89
92
  const lines = text.split("\n")
90
93
  const flush = () => {
91
- if (para !== null && para.len > paragraphMax) hits.push({ file: f, line: para.start, len: para.len, clazz: "段太长" })
94
+ if (para !== null && para.len > paragraphMax) hits.push({ file: f, line: para.start, len: para.len, clazz: DOC_CLASS.para })
92
95
  para = null
93
96
  }
94
97
  lines.forEach((l, i) => {
package/embedding.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { RETRYABLE_STATUS } from "./provider/index.mjs"
9
9
  import { resolveEnvRefs } from "./env-ref.mjs"
10
+ import { withUserAgent } from "./client-identity.mjs"
10
11
  // #859(MEMORY.md §6.3 ∥ §7 D-MEM31):嵌入路径发送前净化孤立 UTF-16 代理——sanitize 单源复用
11
12
  import { sanitizeLoneSurrogates } from "./escape.mjs"
12
13
  const MAX_RETRIES = 3
@@ -111,6 +112,7 @@ async function requestWithRetry(embedder, input, signal) {
111
112
  response = await fetch(`${embedder.baseURL}/embeddings`, {
112
113
  method: "POST",
113
114
  headers: {
115
+ ...withUserAgent(), // 产品标识头(embedding 无定制头字段——PROVIDER.md §6.17)
114
116
  "Content-Type": "application/json",
115
117
  Authorization: `Bearer ${embedder.apiKey}`,
116
118
  },
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { proxyFetch } from "./proxy.mjs"
12
+ import { withUserAgent } from "./client-identity.mjs"
12
13
  import { resolveProviderSecrets } from "./env-ref.mjs"
13
14
  // D7(2026-09-21 块标题行对齐批——标题链单源):首条真实 user 消息谓词收核单源
14
15
  // (`isRealUserMsg`——纯函数零依赖,无环)。
@@ -97,8 +98,8 @@ export async function generateTitle(userContent, provider) {
97
98
  body = JSON.stringify(bodyObj)
98
99
  extract = (data) => data.choices?.[0]?.message?.content
99
100
  }
100
- // 定制头展开(PROVIDER.md §6.17)——定制头在前、内置头在后:内置头胜出;三格式各自内置头。
101
- const headers = { ...(provider.headers ?? {}), "Content-Type": "application/json" }
101
+ // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17)——定制头在前、内置头在后:内置头胜出;三格式各自内置头。
102
+ const headers = { ...withUserAgent(provider.headers), "Content-Type": "application/json" }
102
103
  if (provider.format === "anthropic") {
103
104
  headers["x-api-key"] = provider.apiKey
104
105
  headers["anthropic-version"] = "2023-06-01"
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ledger-close-proof.mjs — 台账核销机检单源(F-LX4 · 2026-10-26 批 §2;台账 #1807)。
3
+ *
4
+ * 判据(需求档 §② 功能点 11 / AC-M2-34):凡写 `已核销` 的语句,写库前判三件——① 行的 `task_book`
5
+ * 所指批档在盘 ② 该档 §1 状态行 = `已收口` ③ 该批档在 design token 槽的批次归属里有记录;缺一即拒、
6
+ * **一次报全缺项**、行零改。**判据只有目标值一条**——闸挂在写这个值的两处逐行共函数
7
+ * (`ledger-cmd.mjs` 的 `applyRowUpdate` / `applyRowClose`)之前,不判来源态。
8
+ *
9
+ * 住本档的理由(拆分件——先例 = `ledger-tools.mjs` 自 `ledger-cmd.mjs` 拆出):`ledger-cmd.mjs` 收尾
10
+ * 446 行,机检本体 + 三形拒因入册即越单档 500 顾问线(设计 §五「实落超 500 ⇒ 主动拆分」)。两枚指针
11
+ * 值谓词(`filePartOf`——设计档 §6.1 口径 1 单源 ∥ `namesPointer`)随迁本档,`ledger-cmd` 反向 import
12
+ * 取用——方向单向、无静态环。
13
+ *
14
+ * 只读:③ 只读归属旁列(designId → 批档路径),**从不读 token 值那一面**(`_engDesignTokens` /
15
+ * `readEngTokensFromSlot` 不入本档)——拒因文案里只有行 id ∥ `task_book` 值 ∥ 批档路径。
16
+ */
17
+ import { readFileSync } from "node:fs"
18
+
19
+ import { resolveDeclaredRef } from "./declaration.mjs"
20
+ import { batchPathKey, unionBatches } from "./agent-tools/design-slots.mjs"
21
+ import { isBatchRecord, readBatchStatusLine } from "./agent-tools/batch-skeleton.mjs"
22
+ import { loadManifest } from "./session-slots.mjs"
23
+
24
+ /** 指针 ∥ 批档值 → 文件部分(首个 `§` 前子串,trim——设计档 §6.1 口径 1 单源;`task_book` 与
25
+ * `batch` 同形同口径)。 */
26
+ export const filePartOf = (v) => (v == null ? "" : String(v).split("§")[0].trim())
27
+
28
+ /** 本笔指名谓词(设计档 §6.1 口径 3):显式空值(`null` / 空串 / 全空白) ≡ **未指名**——臂② 不触。 */
29
+ export const namesPointer = (v) => v != null && String(v).trim() !== ""
30
+
31
+ /** 会话槽号(与 `token-ttl.mjs` `currentSlotNoReadOnly` 同一取值式:`_slot` 优先、未钉槽读 manifest
32
+ * `active`)——只用于 ③ 拒因分辨两种成因,不读任何 token 值面。 */
33
+ function slotNoOf(agent) {
34
+ if (Number.isInteger(agent?._slot)) return agent._slot
35
+ const active = loadManifest(agent?.cwd)?.active
36
+ return Number.isInteger(active) ? active : null
37
+ }
38
+
39
+ /** ③ 归属并集一次读入(严格形):`fnName` 在场 ⇒ 读失败即抛(必写 `已核销` 的批量径 ⇒ BEGIN 之前整批拒、
40
+ * 不进 skipped);缺省 ⇒ 返回错误对象(单行径 ∥ 纯字段补丁逐行报 ③)。 */
41
+ export function readCloseOutBatches(agent, fnName) {
42
+ try { return unionBatches(agent, { strict: true }) } catch (e) {
43
+ if (!fnName) return e
44
+ throw new Error(`${fnName}:核销机检未过(整批零行改——会话级读失败,与行无关)\n ③ 读不到 token 槽归属(${e?.message ?? e}):核销被拒、行零改;修好槽文件或权限后重试`)
45
+ }
46
+ }
47
+
48
+ /** 核销机检(F-LX4 · 需求档 §② 功能点 11 / AC-M2-34):写 `已核销` 前判三件——① `task_book` 所指档在盘
49
+ * ② 该档 §1 = `已收口` ③ 该批档在 design token 槽的批次归属里有记录;**判据只有目标值一条**(两处写
50
+ * 这个值的 SQL 之前同挂),三件各判、**一次报全缺项**、行零改(只判不写)。③ = 值扫(`batchPathKey`
51
+ * 归一),只读归属旁列、从不读 token 值面。`batches` = 批量径预读(`Error` = 预读失败);缺省 ⇒ 此处读。 */
52
+ export function assertCloseOutProof(fnName, { agent, base, row, batches }) {
53
+ const missing = [] // 每项 = 一条拒因行(首字 = 缺项号)
54
+ let abs = null
55
+ if (!namesPointer(row?.task_book)) {
56
+ missing.push(" ① 行无 task_book 指针:追认核销须先挂批档(update 写 task_book),再跑全链")
57
+ } else {
58
+ const hit = resolveDeclaredRef(base, filePartOf(row.task_book))
59
+ if (!hit.ok) missing.push(` ① 批档不在盘(task_book = ${row.task_book}):先补指针,或恢复该档`)
60
+ else {
61
+ abs = hit.abs
62
+ const src = readFileSync(abs, "utf8")
63
+ if (!isBatchRecord(src)) missing.push(` ② 指针所指档不是批档(${abs} 无 §1 段):先把 task_book 改指批档`)
64
+ else {
65
+ const state = readBatchStatusLine(src)
66
+ if (state !== "closed") missing.push(` ② 批档未收口(${abs} §1 = ${state}):该批须先跑全链收口`)
67
+ }
68
+ }
69
+ }
70
+ if (abs !== null) {
71
+ let map = batches instanceof Map || batches instanceof Error ? batches : readCloseOutBatches(agent)
72
+ if (map instanceof Error) missing.push(` ③ 读不到 token 槽归属(${map?.message ?? map}):核销被拒、行零改;修好槽文件或权限后重试`)
73
+ else if (![...map].some(([, b]) => typeof b === "string" && batchPathKey(b) === batchPathKey(abs))) {
74
+ missing.push(slotNoOf(agent) == null
75
+ ? " ③ 本会话无槽号(manifest 无 active):核销须在持有该批归属的会话里跑"
76
+ : " ③ 本会话槽内无该批档的归属:先确认是否换了会话——在跑该批设计评审的会话里核销;\n 确无归属 ⇒ 那次评审的 documents 未带该批档,须重跑")
77
+ }
78
+ }
79
+ if (missing.length > 0) throw new Error(`${fnName}:核销机检未过(行 ${row?.id} 零改)——缺:${missing.map((t) => t.trim()[0]).join(" ")}\n${missing.join("\n")}`)
80
+ }
package/ledger-cmd.mjs CHANGED
@@ -15,6 +15,10 @@
15
15
  * 核销标记门(LIGHT-CHANNEL.md §2.5 · 台账 #1780):写入 `已核销` 的三径(close 单行 ∥ close 批量 ∥
16
16
  * update 迁移——均经两逐行共函数)判**结果行标题**携 `ROUND_MARKER` ⇒ 拒;判据只认标题标记
17
17
  * (不查批档 ∥ 不查评审实跑),撤回(→ 已废弃)不拦。
18
+ * 核销机检(F-LX4 · 2026-10-26 批 §2):同一两逐行共函数在写 `已核销` 前判三件(批档在盘 ∥ §1 =
19
+ * 已收口 ∥ 该批档在 design token 槽的批次归属有记录),缺一即拒、一次报全缺项、行零改;判据只有
20
+ * 目标值一条。本体住拆分件 `ledger-close-proof.mjs`(同 `ledger-tools.mjs` 先例);标记门在前
21
+ * (零成本字符串判先报),两门互不替代。
18
22
  *
19
23
  * 接线(设计档 §2.2 命令面接线):族出口 = `ledger.mjs`(re-export 两源——KD-M2-3);查询命令 ←
20
24
  * tools/index.mjs(assembleBuiltinTools 全角色面);写命令 ← agent/family-tools.mjs(assembleFamilyTools
@@ -24,6 +28,7 @@
24
28
  import { resolve } from "node:path"
25
29
  import { AGING_DAYS, ALLOWED_MIGRATIONS, EVIDENCE_SOFT_LIMIT, nowIso, openLedger, PENDING_STATUSES, resolveLedgerRoot, ROUND_MARKER } from "./ledger-db.mjs"
26
30
  import { resolveDeclaredRef } from "./declaration.mjs"
31
+ import { assertCloseOutProof, filePartOf, namesPointer, readCloseOutBatches } from "./ledger-close-proof.mjs"
27
32
 
28
33
  /** 逐行老化判真(LEDGER.md §13.4 · 2026-10-07 批 ledger-tool):未决四态(`PENDING_STATUSES`)∧
29
34
  * 行龄 > `AGING_DAYS` 天;行龄源 = `updated_at ?? created_at`(§5 同源);时间戳缺 / 不可解析
@@ -36,10 +41,6 @@ function isRowAged(row, now) {
36
41
  return Number.isFinite(ms) && (now - ms) / 86400000 > AGING_DAYS
37
42
  }
38
43
 
39
- /** 指针 ∥ 批档值 → 文件部分(首个 `§` 前子串,trim——设计档 §6.1 口径 1 单源;`task_book` 与
40
- * `batch` 同形同口径)。 */
41
- const filePartOf = (v) => (v == null ? "" : String(v).split("§")[0].trim())
42
-
43
44
  /** 解析基准(设计档 §6.1 口径 2 同一子表达式——与台账库键同源):`resolveLedgerRoot(cwd, boundary)?.root
44
45
  * ?? resolve(cwd ?? ".")`。`boundary` = 会话锚(与 `openLedger` 同一枚上界——同一笔动作的判据不能两界)。 */
45
46
  const baseOf = (cwd, boundary) => resolveLedgerRoot(cwd, boundary)?.root ?? resolve(cwd ?? ".")
@@ -136,9 +137,6 @@ export function ledgerEvidenceOversize({ cwd, boundary, task_book = null } = {})
136
137
  return ledgerCount({ cwd, boundary, task_book }).evidenceOversize
137
138
  }
138
139
 
139
- /** 本笔指名谓词(设计档 §6.1 口径 3):显式空值(`null` / 空串 / 全空白) ≡ **未指名**——臂② 不触。 */
140
- const namesPointer = (v) => v != null && String(v).trim() !== ""
141
-
142
140
  /** 写门·指针点名(设计档 §6.1 两臂 · AC-M2-10 ∥ AC-M2-29):判**本笔写入面**——两臂之外零判。
143
141
  * 臂①(点名非空):结果行 `status ∈ {在途, 待核销}` ∨ 结果行 `trigger = 归批` ⇒ `task_book` 非空
144
142
  * (缺 / 空串 / 全空白 ⇒ 拒「必填」);**不判在盘**(列值事实,不随盘面翻转——DDL 咬合 CHECK 为该半幅防线)。
@@ -214,11 +212,11 @@ function assertEvidenceValue(fnName, evidence) {
214
212
  }
215
213
 
216
214
  /** 单行落笔·update(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):迁移表判 → 核销标记门
217
- * (目标 = `已核销` ∧ 结果标题携标记 ⇒ 拒——判序 = LIGHT-CHANNEL.md §2.5)→ executor / evidence 值计算
218
- * (含两拒面)→ 写门 → UPDATE(判序 = §13.9 逐字未变)。`to` = `patch.status ?? 行现态`
215
+ * (目标 = `已核销` ∧ 结果标题携标记 ⇒ 拒——判序 = LIGHT-CHANNEL.md §2.5)→ **核销机检**(目标 = `已核销`
216
+ * ⇒ `assertCloseOutProof`)→ executor / evidence 值计算(含两拒面)→ 写门 → UPDATE(判序 = §13.9 逐字未变)。`to` = `patch.status ?? 行现态`
219
217
  * ——批量形纯字段补丁(不携 `status`)时 `to === row.status` ⇒ 迁移表不判、`status` 零写(§3.3)。
220
218
  * `resume_when` = 普通可空字段补丁(`patch.resume_when ?? 行值`——§3.4)。 */
221
- function applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary) {
219
+ function applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary, proof = {}) {
222
220
  const to = patch.status ?? row.status
223
221
  if (to !== row.status && !(ALLOWED_MIGRATIONS[row.status] ?? []).includes(to)) {
224
222
  throw new Error(`ledgerUpdate:迁移 ${row.status} → ${to} 不在允许迁移表`)
@@ -227,6 +225,8 @@ function applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary) {
227
225
  if (to === "已核销" && String(patch.title ?? row.title ?? "").includes(ROUND_MARKER)) {
228
226
  throw new Error(`ledgerUpdate:行标题仍携「${ROUND_MARKER}」——收尾链未跑完,不许核销(先跑收尾全链,再由父侧清标记)`)
229
227
  }
228
+ // 核销机检(F-LX4):目标值 = `已核销` ⇒ 写库前三件校验(批档在盘 ∥ §1 已收口 ∥ 槽内有归属)
229
+ if (to === "已核销") assertCloseOutProof("ledgerUpdate", { agent: proof.agent, base: proof.base ?? baseOf(cwd, boundary), row, batches: proof.batches })
230
230
  const nextExecutor = resolveExecutorTarget(row, to, patch, executorSessionId)
231
231
  const nextEvidence = evidenceValue("ledgerUpdate", row.evidence, patch.evidence, patch.evidenceReplace)
232
232
  assertEvidenceValue("ledgerUpdate", patch.evidence)
@@ -249,27 +249,30 @@ function applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary) {
249
249
  * 纯函数可测;工具层动态 import getSessionId() 供值,零新静态边)。
250
250
  * `evidence` 传值 = 追加缺省、`patch.evidenceReplace` = 布尔覆盖旗(§13.9——值计算 / 两拒面 = `evidenceValue`
251
251
  * / `assertEvidenceValue`;判位 = 本语义段,随 executor 目标值同拍)。**单行形**(`id`)——逐行落笔 = `applyRowUpdate`。 */
252
- export function ledgerUpdate({ cwd, id, patch, executorSessionId, boundary }) {
252
+ export function ledgerUpdate({ cwd, id, patch, executorSessionId, boundary, agent }) {
253
253
  const db = openLedger(cwd, { create: true, boundary })
254
254
  try {
255
255
  const row = db.prepare("SELECT * FROM items WHERE id = ?").get(id)
256
256
  if (!row) throw new Error(`ledgerUpdate:行 ${id} 不存在`)
257
- const { id: rowId } = applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary)
257
+ const { id: rowId } = applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary, { agent, base: baseOf(cwd, boundary) })
258
258
  return { id: rowId }
259
259
  } finally { db.close() }
260
260
  }
261
261
 
262
262
  /** 单行落笔·close(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):源态判(核销仅三源——
263
263
  * 在途不可跳)→ 核销标记门(LIGHT-CHANNEL.md §2.5——`已核销` ∧ 标题携标记 ⇒ 拒;撤回不拦)→
264
- * 值计算(拒面① 旗独传判于此)→ 追认口 `evidence` 门 → 拒面② → UPDATE。
264
+ * **核销机检**(`已核销` ⇒ `assertCloseOutProof`)→ 值计算(拒面① 旗独传判于此)→ 追认口 `evidence`
265
+ * 门 → 拒面② → UPDATE。
265
266
  * 撤回(任意态 → 已废弃——含在途直撤)同步 `executor = NULL`(LEDGER.md §3.1 ④);核销两源(勾销 /
266
267
  * 追认)`executor` 零触碰(非在途出边)。 */
267
- function applyRowClose(db, row, status, evidence, evidenceReplace) {
268
+ function applyRowClose(db, row, status, evidence, evidenceReplace, proof = {}) {
268
269
  if (status === "已核销" && !["待讨论", "待设计", "待核销"].includes(row.status)) throw new Error(`ledgerClose:核销仅限 待讨论 / 待设计 / 待核销(现态 ${row.status})`)
269
270
  // 核销标记门(LIGHT-CHANNEL.md §2.5):勾销 ∥ 追认同判;撤回(→ 已废弃)不入本门
270
271
  if (status === "已核销" && String(row.title ?? "").includes(ROUND_MARKER)) {
271
272
  throw new Error(`ledgerClose:行标题仍携「${ROUND_MARKER}」——收尾链未跑完,不许核销(先跑收尾全链,再由父侧清标记)`)
272
273
  }
274
+ // 核销机检(F-LX4):判序 = 标记门之后(零成本字符串判先报)、值计算之前
275
+ if (status === "已核销") assertCloseOutProof("ledgerClose", { agent: proof.agent, base: proof.base, row, batches: proof.batches })
273
276
  const nextEvidence = evidenceValue("ledgerClose", row.evidence, evidence, evidenceReplace)
274
277
  if (status === "已核销" && ["待讨论", "待设计"].includes(row.status) && (nextEvidence == null || String(nextEvidence).trim() === "")) throw new Error(`ledgerClose:追认核销须带 evidence(现态 ${row.status})`)
275
278
  assertEvidenceValue("ledgerClose", evidence) // 判序:拒面②(新值空白)落追认门之后 → UPDATE
@@ -283,10 +286,10 @@ function applyRowClose(db, row, status, evidence, evidenceReplace) {
283
286
  * 可选 `evidence` 参:传值 = 追加缺省(§13.9——旧空直写 / 旧非空拼 `旧|新`)、`evidenceReplace: true`
284
287
  * 同传 = 整段替换;勾销 / 追认 / 撤回三径同携(写值落行,内容不判);两拒面(旗独传 @值计算 ∥
285
288
  * 新值空白 @追认门后)见 `evidenceValue` / `assertEvidenceValue`。
286
- * 判序(同函数体同层,§13.9):目标集判 → 行取 → 源态判 → 值计算 → 追认门(原样——判**结果值**缺 /
287
- * 全空白 ⇒ 拒,文案逐字不变)→ 拒面②(新值空白)→ UPDATE(LEDGER.md §3 收口两源)。**单行形**
288
- * (`id`)——逐行落笔 = `applyRowClose`。 */
289
- export function ledgerClose({ cwd, id, status, evidence, evidenceReplace, boundary }) {
289
+ * 判序(同函数体同层,§13.9):目标集判 → 行取 → 源态判 → **核销机检**(`已核销` 才触)→ 值计算 →
290
+ * 追认门(原样——判**结果值**缺 / 全空白 ⇒ 拒,文案逐字不变)→ 拒面②(新值空白)→ UPDATE(LEDGER.md §3
291
+ * 收口两源)。**单行形**(`id`)——逐行落笔 = `applyRowClose`。 */
292
+ export function ledgerClose({ cwd, id, status, evidence, evidenceReplace, boundary, agent }) {
290
293
  if (status !== "已核销" && status !== "已废弃") throw new Error(`ledgerClose:目标态 ${status} ∉ {已核销, 已废弃}`)
291
294
  const db = openLedger(cwd, { create: true, boundary })
292
295
  try {
@@ -294,7 +297,7 @@ export function ledgerClose({ cwd, id, status, evidence, evidenceReplace, bounda
294
297
  try {
295
298
  const row = db.prepare("SELECT * FROM items WHERE id = ?").get(id)
296
299
  if (!row) throw new Error(`ledgerClose:行 ${id} 不存在`)
297
- const out = applyRowClose(db, row, status, evidence, evidenceReplace)
300
+ const out = applyRowClose(db, row, status, evidence, evidenceReplace, { agent, base: baseOf(cwd, boundary) })
298
301
  db.exec("COMMIT")
299
302
  return out
300
303
  } catch (e) { db.exec("ROLLBACK"); throw e }
@@ -330,16 +333,20 @@ function resolveBatchRows(db, base, abs) {
330
333
  * 并抛(零行改)——故「部分落地」只由逐行门拒产生,「中途失败」不留半批。
331
334
  * `BEGIN IMMEDIATE`:写锁一次先把(逐行不升级锁 ⇒ 事务级失败在首行之前即失败)。
332
335
  * @returns {{batch: string, status: string|null, applied: number[], skipped: {id: number, from: string, why: string}[]}} */
333
- export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId, boundary }) {
336
+ export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId, boundary, agent }) {
334
337
  const db = openLedger(cwd, { create: true, boundary })
335
338
  try {
336
339
  const base = baseOf(cwd, boundary)
337
340
  const abs = assertBatchArg("ledgerUpdate", base, batch)
341
+ // 核销机检 ③:迁移径(结果态 = `已核销`)在 BEGIN 之前一次读入、行间复用(严格读抛 = 会话级 ⇒ 整批拒);
342
+ // 纯字段补丁径也读一次(不抛):闸只对归档行触(存量已知接受),读失败向每行报 ③ 形——不改业务结果
343
+ // (那些行本就被拦),但会以逐行拒因呈现环境问题(父侧已知项,待裁)。
344
+ const proof = { agent, base, batches: patch.status === "已核销" ? readCloseOutBatches(agent, "ledgerUpdate") : patch.status == null ? readCloseOutBatches(agent) : undefined }
338
345
  db.exec("BEGIN IMMEDIATE")
339
346
  try {
340
347
  const applied = [], skipped = []
341
348
  for (const row of resolveBatchRows(db, base, abs)) {
342
- try { applied.push(applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary).id) }
349
+ try { applied.push(applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary, proof).id) }
343
350
  catch (e) { skipped.push({ id: Number(row.id), from: row.status, why: String(e?.message ?? e) }) }
344
351
  }
345
352
  db.exec("COMMIT")
@@ -355,12 +362,14 @@ export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId, b
355
362
  * (`evidenceValue` 同源)。回执 `status` 恒 `已核销`(批量形缺省 = 勾销单值;携则守卫已收窄为
356
363
  * `{已核销}`——§3.2 `whenPresent`)。事务语义同 `ledgerUpdateBatch`。
357
364
  * @returns {{batch: string, status: string, applied: number[], skipped: {id: number, from: string, why: string}[]}} */
358
- export function ledgerCloseBatch({ cwd, batch, evidence, evidenceReplace, boundary }) {
365
+ export function ledgerCloseBatch({ cwd, batch, evidence, evidenceReplace, boundary, agent }) {
359
366
  const status = "已核销"
360
367
  const db = openLedger(cwd, { create: true, boundary })
361
368
  try {
362
369
  const base = baseOf(cwd, boundary)
363
370
  const abs = assertBatchArg("ledgerClose", base, batch)
371
+ // 核销机检 ③:本径恒写 `已核销` ⇒ 归属并集在 BEGIN 之前一次读入(严格读抛 = 会话级 ⇒ 整批拒、不进 skipped)
372
+ const proof = { agent, base, batches: readCloseOutBatches(agent, "ledgerClose") }
364
373
  db.exec("BEGIN IMMEDIATE")
365
374
  try {
366
375
  const applied = [], skipped = []
@@ -371,7 +380,7 @@ export function ledgerCloseBatch({ cwd, batch, evidence, evidenceReplace, bounda
371
380
  skipped.push({ id: Number(row.id), from: row.status, why })
372
381
  continue
373
382
  }
374
- try { applied.push(applyRowClose(db, row, status, evidence, evidenceReplace).id) }
383
+ try { applied.push(applyRowClose(db, row, status, evidence, evidenceReplace, proof).id) }
375
384
  catch (e) { skipped.push({ id: Number(row.id), from: row.status, why: String(e?.message ?? e) }) }
376
385
  }
377
386
  db.exec("COMMIT")
package/ledger-tools.mjs CHANGED
@@ -305,14 +305,14 @@ function makeLedgerTool(actions, { readonly = false, writeFace = false } = {}) {
305
305
  executorSessionId = getSessionId()
306
306
  }
307
307
  // 批级批量(§3.3)——`batch` 与 `id` 恰携其一(守卫 P7 已先裁)
308
- if (batch != null) return JSON.stringify({ ...ledgerUpdateBatch({ cwd: target, batch, patch, executorSessionId, boundary: anchor }) })
309
- return JSON.stringify({ ...ledgerUpdate({ cwd: target, id, patch, executorSessionId, boundary: anchor }) })
308
+ if (batch != null) return JSON.stringify({ ...ledgerUpdateBatch({ cwd: target, batch, patch, executorSessionId, boundary: anchor, agent: ctx?.agent }) })
309
+ return JSON.stringify({ ...ledgerUpdate({ cwd: target, id, patch, executorSessionId, boundary: anchor, agent: ctx?.agent }) })
310
310
  }
311
311
  case "close": {
312
312
  const target = startOf(ctx, anchor, rest.cwd, rest.batch, rest.task_book)
313
313
  // 批级批量结算(§3.3——勾销径)
314
- if (rest.batch != null) return JSON.stringify({ ...ledgerCloseBatch({ cwd: target, batch: rest.batch, evidence: rest.evidence, evidenceReplace: rest.evidenceReplace, boundary: anchor }) })
315
- return JSON.stringify({ ...ledgerClose({ cwd: target, id: rest.id, status: rest.status, evidence: rest.evidence, evidenceReplace: rest.evidenceReplace, boundary: anchor }) })
314
+ if (rest.batch != null) return JSON.stringify({ ...ledgerCloseBatch({ cwd: target, batch: rest.batch, evidence: rest.evidence, evidenceReplace: rest.evidenceReplace, boundary: anchor, agent: ctx?.agent }) })
315
+ return JSON.stringify({ ...ledgerClose({ cwd: target, id: rest.id, status: rest.status, evidence: rest.evidence, evidenceReplace: rest.evidenceReplace, boundary: anchor, agent: ctx?.agent }) })
316
316
  }
317
317
  case "query": {
318
318
  const target = startOf(ctx, anchor, rest.cwd, rest.task_book)
package/model-specs.mjs CHANGED
@@ -235,11 +235,12 @@ const MODEL_SPECS = [
235
235
  ["doubao-seed-2-0-lite-260428", { context: 256_000, maxOutput: 131_072, thinking: true, thinkApi: "effort", reasoningEffortEnum: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], multimodal: true }],
236
236
  // doubao-seed-2-1 三档(火山方舟 · 2026-09-24 bench 名单批 · 三行同形):thinking / thinkApi / 枚举 = **实测**
237
237
  // (裸请求 rc 在场;`reasoning_effort:"none"` → rc=0;七值全 200、乱值 400 泛化 ⇒ **受理级**——等级低于 2.0 行的
238
- // 「真校验」,同值集不混级);maxOutput 524_288 = **受理级**(262 144 / 524 288 均受理;**上限未证**);
239
- // context = **族沿用 2.0 行**(官方口径未逐名取证);**视觉面未探**(不声明——如实标级,不冒充实测)。
240
- ["doubao-seed-2-1-pro-260915", { context: 256_000, maxOutput: 524_288, thinking: true, thinkApi: "effort", reasoningEffortEnum: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }],
241
- ["doubao-seed-2-1-turbo-260628", { context: 256_000, maxOutput: 524_288, thinking: true, thinkApi: "effort", reasoningEffortEnum: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }],
242
- ["doubao-seed-2-1-lite-260915", { context: 256_000, maxOutput: 524_288, thinking: true, thinkApi: "effort", reasoningEffortEnum: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }],
238
+ // 「真校验」,同值集不混级);context / maxOutput = **官方模型列表口径**(2026-10-26 实查:pro / lite 上下文 1024k、
239
+ // turbo 256k;三行最大回答 256k);旧值 context 256_000 / maxOutput 524_288 在档内无任何受理读数取证面,已退场。
240
+ // **视觉面未探**(不声明——如实标级,不冒充实测)。
241
+ ["doubao-seed-2-1-pro-260915", { context: 1_024_000, maxOutput: 256_000, thinking: true, thinkApi: "effort", reasoningEffortEnum: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }],
242
+ ["doubao-seed-2-1-turbo-260628", { context: 256_000, maxOutput: 256_000, thinking: true, thinkApi: "effort", reasoningEffortEnum: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }],
243
+ ["doubao-seed-2-1-lite-260915", { context: 1_024_000, maxOutput: 256_000, thinking: true, thinkApi: "effort", reasoningEffortEnum: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }],
243
244
  // step-3.7-flash(StepFun · 2026-09-25 批 §14.2 #2-6 · §1.7.1 父侧交付):**尺寸行**——context 262_144(多源 256K =
244
245
  // 网络转述级;渠道未激活——400 "product is not activated");maxOutput 未取到 ⇒ 零口径——取兜底值 32_000(与建行前
245
246
  // 生效值同值);行键 = 裸名(渠道名录形 stepfun/step-3.7-flash——命名空间剥离同命中)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thincoder/core",
3
- "version": "0.10.12",
3
+ "version": "0.10.13",
4
4
  "description": "ThinCoder shared core — shared mechanism modules + shared prompt content (CLI ↔ VS Code extension).",
5
5
  "keywords": [
6
6
  "ai",
@@ -246,6 +246,8 @@ The round ledger row's title carries 「收尾链待跑」 — that marker is th
246
246
  **Cross-session pickup**: at session start (on the same beat as "Re-check deferred batches first") ⇒ scan for unclosed light-channel rounds (the round row unsettled ∥ the batch record not closed out).
247
247
  Found ⇒ raise the pickup reminder first, carrying the same four elements (round ∥ pen count ∥ steps owed ∥ one word runs it); the user's one word ⇒ take over and run the full closeout chain.
248
248
  Closeout-chain segment map: §2 design formalization · §3 independent review · §4 approval · §6 closeout settlement; the round row settles only after the full chain passes (pen rows may settle with the closeout).
249
+ **Why the closeout chain cannot be skipped**: a light-channel pen changes code only — the design docs keep describing the code as it was before the pen, and no other step in the round pulls them back in sync.
250
+ The chain is that step: the design seat writes the code's current state back into the design docs, an independent review checks it, and the approval closes it; skipped, every later reader follows a description of code that no longer exists.
249
251
 
250
252
  **A pen answers the three questions before it moves**: where the function lives now (a coordinate ∥ a doc section) · what it is called (the existing name, verbatim) · who uses it.
251
253
  **The answer joins the pen's §1 entry in one of three shapes**:
@@ -6,8 +6,10 @@
6
6
 
7
7
  import { specForModel, requestBudget } from "../config.mjs"
8
8
  import { proxyFetch } from "../proxy.mjs"
9
+ import { withUserAgent } from "../client-identity.mjs"
9
10
  import { requestWithRetry } from "./retry.mjs"
10
11
  import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
12
+ import { assertPromptFits } from "./errors.mjs"
11
13
  import { abortError } from "../abort-provenance.mjs"
12
14
 
13
15
  const ANTHROPIC_VERSION = "2023-06-01"
@@ -153,7 +155,7 @@ export function convertMessages(messages) {
153
155
  * 2026-08-31 会诊 #6:接入 rateGate/recordRate + 429 Retry-After 单次重试
154
156
  * (原实现完全绕过 TPM/RPM 闸门与记账——用户配了 tpm 以为受控实际不受控)。
155
157
  * 注:5xx/网络退避重试未与 OpenAI 格式对齐(三 transport 共用那步工作量大,见报告)。 */
156
- export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, parallelToolCalls }) {
158
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, parallelToolCalls, logCtx }) {
157
159
  // Extract system message(s) — Anthropic uses top-level `system` field
158
160
  const systemMessages = []
159
161
  const chatMessages = []
@@ -194,7 +196,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
194
196
  // 2026-09-01:FETCH_TIMEOUT_MS 常量退役(绝对墙钟废除)——anthropic/responses 经 core.mjs 的
195
197
  // effectiveFetchTimeoutMs 共用;响应头阶段 600s 默认,body 阶段 idle 超时。
196
198
  const headers = {
197
- ...(provider.headers ?? {}), // 定制头展开(PROVIDER.md §21):定制头在前、内置头在后——同名内置头胜出
199
+ ...withUserAgent(provider.headers), // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17);内置头在后故同名胜出
198
200
  "Content-Type": "application/json",
199
201
  "x-api-key": provider.apiKey,
200
202
  "anthropic-version": ANTHROPIC_VERSION,
@@ -204,11 +206,13 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
204
206
  if (signal?.aborted) throw abortError(signal, "provider", "transport-anthropic")
205
207
 
206
208
  // 会诊 #6:TPM/RPM 闸门 + 记账(rate.mjs 与 OpenAI 格式共用同一窗口)
207
- const { rateGate, recordRate } = await import("./rate.mjs")
208
- const estimated = await (async () => {
209
- const { estimateRequestTokens } = await import("./rate.mjs")
210
- return estimateRequestTokens(body)
211
- })()
209
+ const { rateGate, recordRate, estimateRequestTokens } = await import("./rate.mjs")
210
+ // §6.17 发送面净判:`estPrompt` = 输入侧**全量**(X 无 `max_tokens` 键 ⇒ 减项恒 0)——取调用方的
211
+ // messages(含 system)而不是 body.messages:anthropic 的 system 是顶层字段(`body.system`),
212
+ // 走 body 会把整块 system 漏在判据外。`estimated`(含 max_tokens)仍供 rateGate / recordRate。
213
+ const estPrompt = estimateRequestTokens({ messages, tools })
214
+ const estimated = estimateRequestTokens(body)
215
+ assertPromptFits(provider, estPrompt, logCtx)
212
216
  await rateGate(provider, estimated, onWait, signal)
213
217
 
214
218
  // 2026-08-31:4xx/5xx/网络与 OpenAI 格式统一退避重试链(原仅 429 Retry-After 单次重试,
@@ -230,6 +234,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
230
234
  const result = await parseAnthropicStream(response, { onToken, onReasoning, signal })
231
235
  recordRate(provider, estimated, result.usage)
232
236
  const finished = finishAnthropic(result)
237
+ finished.estimated = estPrompt // §6.17 自校准采样:den 与净判同口径
233
238
  // §6.27 告警投递:R7 的畸形参数告警并入既有 `_warnings` 机读线(消费面 agent/run-stages.mjs:44-45)
234
239
  finished._warnings = [...(finished._warnings ?? []), ...warnings]
235
240
  return finished
package/provider/core.mjs CHANGED
@@ -8,6 +8,7 @@ import { providerSpec, requestBudget, resolveEnableThinking } from "../config.mj
8
8
  import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
9
9
  import { resolveProviderSecrets } from "../env-ref.mjs"
10
10
  import { proxyFetch } from "../proxy.mjs"
11
+ import { withUserAgent } from "../client-identity.mjs"
11
12
  import { escapeMessages, stripLocalMessageFields } from "../escape.mjs"
12
13
  import { logEvent, errText, classifyErr, headText } from "../log.mjs"
13
14
  import { abortError, annotateAbort, deathLine } from "../abort-provenance.mjs"
@@ -20,7 +21,7 @@ import {
20
21
  estimateRequestTokens, rateGate, recordRate,
21
22
  } from "./rate.mjs"
22
23
  // 2026-09-05 module-split:错误分类/流规则族迁 provider/errors.mjs(core.mjs 557 > 500 硬限)
23
- import { parseRetryAfter, isNonRetryableError, betaBaseURL, compileStreamRules, assertProviderModel } from "./errors.mjs"
24
+ import { parseRetryAfter, isNonRetryableError, betaBaseURL, compileStreamRules, assertProviderModel, assertPromptFits, markContextOverflow } from "./errors.mjs"
24
25
  // 测试 import 面(provider-stream/stream-rules)——core 曾直接 export 这两个
25
26
  export { parseRetryAfter, compileStreamRules } from "./errors.mjs"
26
27
 
@@ -157,6 +158,8 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
157
158
  messages,
158
159
  tools: tools?.length ? normalizeTools(tools) : null,
159
160
  onToken, onReasoning, onWait, signal, toolChoice,
161
+ // §6.17 豁免面:内部阶段标签(compress / distill / 续写)需传到发送面净判
162
+ logCtx,
160
163
  })
161
164
  return result
162
165
  }
@@ -167,6 +170,8 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
167
170
  messages,
168
171
  tools: tools?.length ? normalizeTools(tools) : null,
169
172
  onToken, onReasoning, onWait, signal, toolChoice,
173
+ // §6.17 豁免面:内部阶段标签(compress / distill / 续写)需传到发送面净判
174
+ logCtx,
170
175
  })
171
176
  return result
172
177
  }
@@ -180,6 +185,7 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
180
185
  messages,
181
186
  tools,
182
187
  onToken, onReasoning, onWait, signal, toolChoice,
188
+ logCtx, // §6.17 豁免面(compress / distill / 续写)
183
189
  })
184
190
  }
185
191
 
@@ -250,10 +256,15 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
250
256
  if (parallelToolCalls === true) body.parallel_tool_calls = true
251
257
 
252
258
  const estimated = estimateRequestTokens(body)
259
+ // §6.17 发送面净判(第二道):`estPrompt` = 输入侧估算——不减掉 `max_tokens` 会把输出预算白扣一份
260
+ const estPrompt = estimated - (body.max_tokens ?? 0)
261
+ assertPromptFits(provider, estPrompt, logCtx)
253
262
  await rateGate(provider, estimated, onWait, signal)
254
263
 
255
264
  const response = await requestWithRetry(provider, body, signal, onWait)
256
265
  const result = await readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns, idleMs: effectiveBodyIdleMs(provider) })
266
+ // §6.17 自校准采样:同一个 estPrompt 随 result 返回(den 与净判同口径)
267
+ result.estimated = estPrompt
257
268
  recordRate(provider, estimated, result.usage)
258
269
 
259
270
  // Stream rule triggered, user interrupted, or network partial — return immediately.
@@ -286,6 +297,7 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
286
297
  }
287
298
 
288
299
  if (!spec.partialMode && !spec.prefixMode) return result
300
+ let continuationRuns = 0
289
301
  for (let n = 0; result.finishReason === "length" && result.content && n < MAX_CONTINUATIONS; n++) {
290
302
  let continued
291
303
  try {
@@ -312,6 +324,7 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
312
324
  result.content += continued.content
313
325
  result.reasoning += continued.reasoning ?? ""
314
326
  mergeRetryToolCalls(result, continued.toolCalls)
327
+ continuationRuns++
315
328
  result.finishReason = continued.finishReason
316
329
  if (continued.usage) {
317
330
  const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
@@ -324,6 +337,9 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
324
337
  }
325
338
  }
326
339
  }
340
+ // §6.17 自校准口径:续写链把 `usage.prompt_tokens` 累加了 N 份,而 `estimated` 只有一份输入侧估算
341
+ // ⇒ 这一轮不采样(否则池化比被抬成 ~N 倍,全部估算支虚高、提前压缩)
342
+ if (continuationRuns > 0) result.estimated = undefined
327
343
  return result
328
344
  }
329
345
 
@@ -398,7 +414,7 @@ async function requestWithRetry(provider, body, signal, onWait) {
398
414
  const opts = {
399
415
  method: "POST",
400
416
  headers: {
401
- ...(provider.headers ?? {}), // custom per-provider headers (desktop proposal ④: X-Device-Id etc.)
417
+ ...withUserAgent(provider.headers), // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17)
402
418
  "Content-Type": "application/json",
403
419
  Authorization: `Bearer ${provider.apiKey}`,
404
420
  },
@@ -446,7 +462,7 @@ async function requestWithRetry(provider, body, signal, onWait) {
446
462
  message += ` [auth diag: baseURL=${host} key=${masked} status=${response.status}]`
447
463
  }
448
464
  lastStatus = response.status
449
- if (isNonRetryableError(response.status, text)) throw new Error(message)
465
+ if (isNonRetryableError(response.status, text)) throw markContextOverflow(new Error(message), response.status, text)
450
466
  if (response.status === 429) {
451
467
  const waitMs = parseRetryAfter(response.headers.get("retry-after"), rateLimitHits)
452
468
  rateLimitHits++
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { RETRYABLE_STATUS, RATE_LIMIT_BACKOFF_MS } from "./rate.mjs"
11
+ import { requestBudget } from "../config.mjs"
11
12
 
12
13
  /** Parse Retry-After: 秒数 or HTTP-date;上限 300s(会诊 #11)— 异常头不得让 CLI 睡数小时。
13
14
  * header 缺失/非法时退回指数退避表(rateLimitHits 计数取档)。 */
@@ -132,3 +133,43 @@ export function classifyResponsesErrorCode(code) {
132
133
  return null
133
134
  }
134
135
  }
136
+
137
+ /** 越窗报文特征(§6.17 HTTP 400 分类——三个渠道的逐字形态;词表就这一处)。 */
138
+ const CONTEXT_OVERFLOW_PHRASES = ["maximum context length", "context_length_exceeded", "prompt is too long"]
139
+
140
+ /** 越窗 typed 错误(§6.17):三实数逐字带出(prompt / max_tokens / 窗口,另附 limit);kind / retryable
141
+ * 复用同族既有词表(`classifyResponsesErrorCode("context_length_exceeded")`),零新词。 */
142
+ export function contextOverflowError(provider, prompt, budget, remedy) {
143
+ const cls = classifyResponsesErrorCode("context_length_exceeded")
144
+ const err = new Error(
145
+ `context overflow: prompt ~${prompt} tokens · max_tokens ${budget.maxTokens ?? "not sent"}` +
146
+ ` · window ${budget.window} (limit ${budget.limit}) — ${remedy} [model ${provider?.model ?? "unknown"}]`,
147
+ )
148
+ err.kind = cls.kind
149
+ err.retryable = cls.retryable
150
+ return err
151
+ }
152
+
153
+ /** 发送面净判(§6.17 第二道,四个装配面同形一次调用):`estPrompt` = 输入侧估算
154
+ * (= `estimateRequestTokens(X) − (body.max_tokens ?? 0)`,四面同一定义——不减会把输出预算白扣一份)。
155
+ * 内部阶段豁免:`compress` / `distill` / 续写本身就是越线后用来把上下文降下来的那次调用,
156
+ * 判它 = 判掉止血动作本身(标签已存在,零新增机制)。 */
157
+ export function assertPromptFits(provider, estPrompt, logCtx) {
158
+ const stage = logCtx?.stage
159
+ if (stage === "compress" || stage === "distill" || logCtx?.isContinuation === true) return
160
+ const budget = requestBudget(provider)
161
+ if (!(estPrompt > budget.limit)) return
162
+ throw contextOverflowError(provider, estPrompt, budget, "the request was not sent; reduce the conversation (/compact or prune) before retrying")
163
+ }
164
+
165
+ /** HTTP 400 越窗报文 ⇒ typed 错误(§6.17);未命中 ⇒ 原样返回入参错误(不发明分类)。 */
166
+ export function markContextOverflow(error, status, text) {
167
+ if (status !== 400) return error
168
+ const lower = String(text ?? "").toLowerCase()
169
+ if (!CONTEXT_OVERFLOW_PHRASES.some((p) => lower.includes(p))) return error
170
+ const cls = classifyResponsesErrorCode("context_length_exceeded")
171
+ error.kind = cls.kind
172
+ error.retryable = cls.retryable
173
+ if (cls.hint) error.message += `(${cls.hint})`
174
+ return error
175
+ }