@thincoder/core 0.10.11 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/advisor/messages.mjs +1 -1
  3. package/advisor/project-context.mjs +2 -2
  4. package/agent/run-stages.mjs +33 -29
  5. package/agent/setup.mjs +3 -0
  6. package/agent/turn-loop.mjs +3 -0
  7. package/agent-tools/batch-gate.mjs +65 -49
  8. package/agent-tools/batch-lifecycle.mjs +82 -12
  9. package/agent-tools/batch-paths.mjs +10 -8
  10. package/agent-tools/batch-skeleton.mjs +9 -1
  11. package/agent-tools/batch-unit-test-gate.mjs +107 -0
  12. package/agent-tools/design-slots.mjs +72 -12
  13. package/agent-tools/design-token.mjs +18 -2
  14. package/agent-tools/manifest.mjs +1 -1
  15. package/agent-tools/review-facts.mjs +1 -1
  16. package/agent.mjs +1 -0
  17. package/client-identity.mjs +54 -0
  18. package/config.mjs +19 -13
  19. package/context.mjs +5 -5
  20. package/doc-check/index.mjs +28 -15
  21. package/doc-check/paragraph.mjs +6 -3
  22. package/embedding.mjs +2 -0
  23. package/generate-title.mjs +3 -2
  24. package/ledger-close-proof.mjs +80 -0
  25. package/ledger-cmd.mjs +64 -53
  26. package/ledger-db.mjs +60 -59
  27. package/ledger-tools.mjs +60 -23
  28. package/ledger.mjs +12 -10
  29. package/manifest-discovery.mjs +41 -26
  30. package/manifest.mjs +17 -16
  31. package/model-specs.mjs +17 -18
  32. package/package.json +1 -1
  33. package/prompts/advisor-design.md +4 -3
  34. package/prompts/common.md +11 -1
  35. package/prompts/discipline-engineering.md +16 -7
  36. package/prompts/discipline-normal.md +2 -1
  37. package/prompts/persona-coder.md +8 -1
  38. package/prompts/persona-eng-coder.md +8 -0
  39. package/prompts/persona-eng-designer.md +11 -1
  40. package/prompts/persona-engineering.md +23 -11
  41. package/prompts/persona-explore.md +6 -0
  42. package/prompts/persona-normal.md +6 -0
  43. package/prompts/persona-plan.md +6 -0
  44. package/provider/anthropic.mjs +12 -7
  45. package/provider/core.mjs +19 -3
  46. package/provider/errors.mjs +41 -0
  47. package/provider/google.mjs +10 -4
  48. package/provider/list-models.mjs +4 -3
  49. package/provider/responses.mjs +17 -7
  50. package/session-lifecycle.mjs +3 -0
  51. package/session-slot-write.mjs +21 -1
  52. package/session-slots-manifest.mjs +21 -4
  53. package/token-ttl.mjs +68 -4
  54. package/token-window.mjs +23 -2
  55. package/tool-docs/wait_for.md +15 -6
@@ -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
+ }
@@ -5,12 +5,14 @@
5
5
  */
6
6
 
7
7
  import { proxyFetch } from "../proxy.mjs"
8
+ import { withUserAgent } from "../client-identity.mjs"
8
9
  import { requestWithRetry } from "./retry.mjs"
9
10
  import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
10
11
  import { abortError, timeoutError } from "../abort-provenance.mjs"
11
12
  import { terminateBody } from "../stream-destroy.mjs"
12
13
  import { requestBudget } from "../config.mjs"
13
14
  import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
15
+ import { assertPromptFits } from "./errors.mjs"
14
16
 
15
17
  /** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
16
18
  function mapFunctionCallingConfig(choice) {
@@ -77,7 +79,7 @@ export function convertMessages(messages) {
77
79
 
78
80
  /** Build and send a Gemini chat request. Returns the same shape as core.mjs chat.
79
81
  * 2026-08-31 会诊 #6:接入 rateGate/recordRate(原实现完全绕过 TPM/RPM 闸门)。 */
80
- export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice }) {
82
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, logCtx }) {
81
83
  const systemMessages = messages.filter((m) => m.role === "system")
82
84
  const contents = convertMessages(messages)
83
85
 
@@ -118,7 +120,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
118
120
 
119
121
  // 会诊 #6:TPM/RPM 闸门 + 记账
120
122
  const { rateGate, recordRate, estimateRequestTokens } = await import("./rate.mjs")
121
- const estimated = estimateRequestTokens({ messages })
123
+ // §6.17 发送面净判:估准面补 `tools`(原只投影 messages);body 用 `maxOutputTokens`、无 `max_tokens` 键 ⇒ 减项恒 0
124
+ const estimated = estimateRequestTokens({ messages, tools })
125
+ const estPrompt = estimated - (body.max_tokens ?? 0)
126
+ assertPromptFits(provider, estPrompt, logCtx)
122
127
  await rateGate(provider, estimated, onWait, signal)
123
128
 
124
129
  // 2026-08-31:5xx/网络与 OpenAI 格式统一退避重试链(原完全无重试——Gemini 高峰
@@ -126,7 +131,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
126
131
  const response = await requestWithRetry(
127
132
  () => proxyFetch(url, {
128
133
  method: "POST",
129
- headers: { ...(provider.headers ?? {}), "Content-Type": "application/json" }, // 定制头展开(PROVIDER.md §21)——定制头在前、内置头在后:内置头胜出
134
+ headers: { ...withUserAgent(provider.headers), "Content-Type": "application/json" }, // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17);内置头在后故同名胜出
130
135
  body: JSON.stringify(body),
131
136
  // 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
132
137
  signal,
@@ -145,6 +150,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
145
150
  content: result.content,
146
151
  reasoning: result.reasoning,
147
152
  finishReason: result.finishReason ?? null, // §6.31.7 F-DA5(原三 transport 恒 null——字段值收正)
153
+ estimated: estPrompt, // §6.17 自校准采样(den 与净判同口径)
148
154
  usage: {
149
155
  prompt_tokens: usage.prompt_tokens ?? 0,
150
156
  completion_tokens: usage.completion_tokens ?? 0,
@@ -154,7 +160,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
154
160
  }
155
161
  }
156
162
 
157
- return { content: result.content, reasoning: result.reasoning, finishReason: result.finishReason ?? null, toolCalls: result.toolCalls }
163
+ return { content: result.content, reasoning: result.reasoning, finishReason: result.finishReason ?? null, estimated: estPrompt, toolCalls: result.toolCalls }
158
164
  }
159
165
 
160
166
  /** Gemini `finishReason` ⇒ 核统一 `finishReason`(§6.31.7 F-DA5——归一只喂既有分类;
@@ -14,6 +14,7 @@
14
14
  * 解析保持防御性(字段缺失即跳过该项)。候选不过滤非对话模型(embedding 等——§16.6 #14)。
15
15
  */
16
16
  import { proxyFetch } from "../proxy.mjs"
17
+ import { withUserAgent } from "../client-identity.mjs"
17
18
  import { resolveProviderSecrets } from "../env-ref.mjs"
18
19
  import { positiveInt, recordListedContext } from "../model-context.mjs"
19
20
 
@@ -74,7 +75,7 @@ function listedWindowOf(row) {
74
75
 
75
76
  async function listOpenai(provider, signal) {
76
77
  const url = `${provider.baseURL}/models`
77
- const data = await fetchJson(provider, url, { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
78
+ const data = await fetchJson(provider, url, { ...withUserAgent(provider.headers), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
78
79
  // L3 写面的收集段(PROVIDER.md §6.15):解析 `data[]` 时顺带收 `id → 窗口`——三字段按序取
79
80
  // 第一个**合法**值(R25 ∥ R26),非法相当于该字段缺席;三字段都不可得 = 该模型列表未报(不进表)。
80
81
  const ids = []
@@ -92,7 +93,7 @@ async function listOpenai(provider, signal) {
92
93
  }
93
94
 
94
95
  async function listAnthropic(provider, signal) {
95
- const headers = { ...(provider.headers ?? {}), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
96
+ const headers = { ...withUserAgent(provider.headers), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
96
97
  const base = `${provider.baseURL}/models?limit=1000`
97
98
  const out = []
98
99
  let afterId = null
@@ -108,7 +109,7 @@ async function listAnthropic(provider, signal) {
108
109
  }
109
110
 
110
111
  async function listGoogle(provider, signal) {
111
- const headers = { ...(provider.headers ?? {}) }
112
+ const headers = { ...withUserAgent(provider.headers) }
112
113
  const base = `${provider.baseURL}/models?key=${encodeURIComponent(provider.apiKey ?? "")}&pageSize=1000`
113
114
  const out = []
114
115
  let pageToken = null
@@ -13,10 +13,11 @@
13
13
  */
14
14
  import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
15
15
  import { proxyFetch } from "../proxy.mjs"
16
+ import { withUserAgent } from "../client-identity.mjs"
16
17
  import { requestWithRetry } from "./retry.mjs"
17
18
  import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
18
19
  import { buildBody, normalizeUsage } from "./responses-request.mjs"
19
- import { classifyResponsesErrorCode } from "./errors.mjs"
20
+ import { classifyResponsesErrorCode, assertPromptFits } from "./errors.mjs"
20
21
  // 同名 re-export(2026-09-29 structure-split-2 · 台账 #620——缝 = 同名再出口):请求构造面出档 `provider/responses-request.mjs`;
21
22
  // `buildBody` / `isStoreRequiredHost` / `builtinToolsFor` 三名既有导出经本档转口——消费者 import 面零改。
22
23
  export { buildBody, isStoreRequiredHost, builtinToolsFor } from "./responses-request.mjs"
@@ -223,15 +224,18 @@ function handleEvent(ev, h) {
223
224
  }
224
225
 
225
226
  /** 主入口:请求 + 链状态推进(与 core.mjs 的 chat 同形返回)。 */
226
- export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, stateful }) {
227
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, stateful, logCtx }) {
227
228
  const { body: reqBody, previousResponseId, warnings, newChain } = buildBody(provider, messages, tools, { toolChoice, stateful })
228
229
  const body = { ...reqBody, ...(previousResponseId ? { previous_response_id: previousResponseId } : {}) }
229
230
 
230
231
  // rateGate/recordRate 对齐 core(responses body 无 messages 键——按本地全量 messages 估算)
231
- const estimated = estimateRequestTokens({ messages })
232
+ // §6.17 发送面净判:估准面补 `tools`(原只投影 messages);body 用 `max_output_tokens`、无 `max_tokens` 键 ⇒ 减项恒 0
233
+ const estimated = estimateRequestTokens({ messages, tools })
234
+ const estPrompt = estimated - (body.max_tokens ?? 0)
235
+ assertPromptFits(provider, estPrompt, logCtx)
232
236
  await rateGate(provider, estimated, onWait, signal)
233
- // 定制头展开(PROVIDER.md §21):provider.headers 在前、内置头在后——同名内置头胜出(三处 fetch 共用)
234
- const headers = { ...(provider.headers ?? {}), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }
237
+ // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17):定制头在前、内置头在后——同名内置头胜出(三处 fetch 共用)
238
+ const headers = { ...withUserAgent(provider.headers), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }
235
239
 
236
240
  // round2 复验 #1(2026-08-31):retry 层对 4xx 非可重试是 throw 而非返回——
237
241
  // requestWithRetry 从不返回 400/404 响应 → 下方 isChainInvalidError 分支原来不可达(D6 死代码)。
@@ -287,11 +291,17 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
287
291
  { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
288
292
  )
289
293
  }
290
- return finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated })
294
+ return finish(provider, response, {
295
+ onToken, onReasoning, signal, newChain, warnings, estimated,
296
+ // §6.17 自校准口径:链式(`previous_response_id`)请求的 body 只带增量(`responses-request.mjs`
297
+ // `body.input = newOutputs`),而分母是本地全量估算 ⇒ 两者口径不同,本轮不采样。
298
+ estPrompt: previousResponseId ? undefined : estPrompt,
299
+ })
291
300
  }
292
301
 
293
- async function finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated }) {
302
+ async function finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated, estPrompt }) {
294
303
  const result = await parseStream(response, { onToken, onReasoning, signal })
304
+ result.estimated = estPrompt // §6.17 自校准采样(den 与净判同口径)
295
305
  recordRate(provider, estimated, result.usage)
296
306
  // 链状态推进:completed 事件里 response.id 供同一 turn 后续增量;
297
307
  // 截断/失败/无 id(部分端点不回传)→ 链作废(后续全量,正确性优先)。
@@ -322,6 +322,9 @@ export function resetSessionState(agent) {
322
322
  agent._runStartHistoryLen = 0
323
323
  agent._lastPromptTokens = null
324
324
  agent._usageAtLen = null
325
+ // §6.17 估算自校准:新会话无样本(比 = 1)——跨会话不继承旧模型的估算偏差
326
+ agent._tok = null
327
+ agent._tokRatio = 1
325
328
  agent.planMode = false
326
329
  agent.goal = null
327
330
  agent._pendingReminders = []
@@ -18,7 +18,9 @@
18
18
  *
19
19
  * token 台账字段(`engDesignTokens`)的**槽 I/O 面**不在本档——单源 = `token-ttl.mjs`
20
20
  * (settle 落盘 / 门禁回读 / 链终清理三面齐备);本档只承载其**保存面合并规则**
21
- * (`mergeEngTokensForSave`——空态保留 + 并集 + 新铸者胜,纯函数)。槽标题写面同样不在
21
+ * (`mergeEngTokensForSave`——空态保留 + 并集 + 新铸者胜,纯函数)。批次归属旁列
22
+ * (`engDesignTokenBatches`)同款分层:槽 I/O 在 `token-ttl.mjs`,本档只持其保存面合并规则
23
+ * (`mergeEngTokenBatchesForSave`)。槽标题写面同样不在
22
24
  * 本档——单源 = `session-rename.mjs` 的 `renameSlot`(同 `{ok, reason}` 契约 + mtime 门控)。
23
25
  */
24
26
 
@@ -193,6 +195,24 @@ export function mergeEngTokensForSave(incoming, existing) {
193
195
  return out
194
196
  }
195
197
 
198
+ /**
199
+ * 保存面「批次归属旁列」合并规则(DESIGN-TOKEN-SETTLEMENT §10 / KD-BG7——纯函数):
200
+ * 沿用 `mergeEngTokensForSave` 的「槽独有项保留」半边(忙时保存不得把内存快照的缺席当成删除),
201
+ * 同 key 冲突**以新写者胜**——旁列值是路径,无 mint 时刻可比,不比 `expiresAt`。
202
+ * - `incoming === undefined`(调用方未携带该字段)⇒ 保留槽值;
203
+ * - incoming 空值 ⇒ 槽有值则保留槽、槽也空 ⇒ null(写 null = 清字段);
204
+ * - 两端都有 ⇒ 并集:槽独有项保留、incoming 覆盖同 key。
205
+ * 理由:忙时保存把归属写丢 ⇒ 收口 no-op ⇒ 凭证留存,正是 §10 要消的窗口。
206
+ */
207
+ export function mergeEngTokenBatchesForSave(incoming, existing) {
208
+ if (incoming === undefined) return existing ?? null
209
+ const hasNew = incoming && typeof incoming === "object" && !Array.isArray(incoming) && Object.keys(incoming).length > 0
210
+ const slotHas = existing && typeof existing === "object" && !Array.isArray(existing) && Object.keys(existing).length > 0
211
+ if (!hasNew) return slotHas ? existing : null
212
+ if (!slotHas) return incoming
213
+ return { ...existing, ...incoming }
214
+ }
215
+
196
216
  /** 会话级偏好(§6.21)键闭集——表外键 = 整 patch 拒(写未发生)。 */
197
217
  const SLOT_PREF_KEYS = new Set(["provider", "model", "effort"])
198
218
 
@@ -100,15 +100,32 @@ export function slotMtime(cwd, slot) {
100
100
  try { return statSync(slotPath(cwd, slot)).mtimeMs } catch { return null }
101
101
  }
102
102
 
103
- export function loadManifest(cwd) {
103
+ /** 清单读(两形——DESIGN-TOKEN-SETTLEMENT §10「槽号解析层同判」· KD-BG8):缺省宽容 ∥ `strict`。
104
+ * 判据 = 「没有」与「读不到」分清:文件不存在(`ENOENT`)⇒ 默认空账本(无槽号 = 合法缺省,**两形同**);
105
+ * 在盘但读失败(`EACCES` ∥ `EISDIR`…)∥ 解析失败 ⇒ 宽容形照旧吞错回默认,**严格形抛**(携 manifest
106
+ * 路径 + 底层消息)。收口门面(`consumeBatchDesignTokens`)用严格形——manifest 损坏若被读成「无槽号」,
107
+ * 收口就落成「已收口而凭证仍在」,正是 F-D8 要消的窗口。
108
+ * 判别用 `err.code === "ENOENT"`——**不用 `existsSync`**:它把权限错也吞成「不存在」,而收口面要拒的正是那类。
109
+ * **形状非法**(可读但解析成 `null` ∥ 非对象)按「读不到」处置(fail-closed;与 `saveManifest` 的 `shape-invalid`
110
+ * 同判)——严格形抛、拒文沿用「could NOT be read」:方向只更严,措辞不另立第三类。
111
+ * 与 `readEngTokenBatchesFromSlot`(token-ttl.mjs)同一两形先例:同一读函数、同一判据、缺省宽容;
112
+ * 展示 / 尽力面一律缺省(现行调用点零改),收口门面才传 `{ strict: true }`。 */
113
+ export function loadManifest(cwd, { strict = false } = {}) {
114
+ let p = "(unresolved manifest path)"
104
115
  try {
105
- const p = manifestPath(cwd)
106
- if (!existsSync(p)) return { slots: {}, sessionId: null }
116
+ p = manifestPath(cwd)
107
117
  const m = JSON.parse(readFileSync(p, "utf8"))
108
118
  if (!m.slots) m.slots = {} // 2026-09-01 会诊 deepseek 🔵:损坏的 {} manifest 不再让调用方抛 TypeError
109
119
  if (!m.sessionId) m.sessionId = null
110
120
  return m
111
- } catch { return { slots: {}, sessionId: null } }
121
+ } catch (e) {
122
+ // 「没有」:文件不存在 = 合法缺省(新会话 / 首建前)——两形一致,严格形也不抛
123
+ if (e?.code === "ENOENT") return { slots: {}, sessionId: null }
124
+ if (strict) {
125
+ throw new Error(`loadManifest: the session manifest could NOT be read (${p}: ${e?.message ?? e}) — the slot number cannot be resolved, so "no slot" cannot be told apart from "cannot read"; the engineering closeout is refused with the record untouched (fix the manifest file or its permissions and retry).`)
126
+ }
127
+ return { slots: {}, sessionId: null }
128
+ }
112
129
  }
113
130
 
114
131
  /** 可信合并基座判据(§6.23 判据句 1 · ②):顶层非 null 非数组对象 ∧(`slots` / `slotSessions`
package/token-ttl.mjs CHANGED
@@ -20,6 +20,9 @@
20
20
  * - **D3**:**单值镜像 `_engDesignToken` 退役**——AC3 零写 + 仅一次性迁移读。本模块
21
21
  * 不再写镜像(engTokenSlotFields 只产多槽表);restoreEngTokens 是唯一迁移读点(旧
22
22
  * slot 残留 engDesignToken → 迁入 Map,此后不写不读镜像)。
23
+ * - **§10(2026-10-26)**:批次归属旁列 `engDesignTokenBatches`(designId → 批档绝对路径)与
24
+ * 槽多槽表**同点序列化 / 同拍恢复 / 同处落盘**;轻读面 = `readEngTokenBatchesFromSlot`。
25
+ * token 值形零改(仍 `<uuid>:<expiryMs>`)——旁列只承载归属,不承载授权。
23
26
  *
24
27
  * 依赖方向:本模块只 import 底层槽 I/O(session-slots.mjs 原语 + session-guard.mjs 轮转
25
28
  * 守卫)——session.mjs 同时 import 本模块(engTokenSlotFields/restoreEngTokens),形成
@@ -108,12 +111,17 @@ export function purgeExpiredDesignTokens(agent) {
108
111
  * (JSON-safe);空/缺 Map → undefined → JSON.stringify 丢 key——清过的会话写
109
112
  * NO field,不从上次 save 复活槽。
110
113
  * DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `engDesignToken` 退役——
111
- * saveSession 不再写镜像字段(AC3 零写);只产多槽表 engDesignTokens。 */
114
+ * saveSession 不再写镜像字段(AC3 零写);只产多槽表 engDesignTokens。
115
+ * 批次归属旁列 `engDesignTokenBatches`(DESIGN-TOKEN-SETTLEMENT §10)同处序列化——
116
+ * 与槽表并列、同空态语义(空表 ⇒ undefined ⇒ 字段不落盘);承载归属,不承载恢复。 */
112
117
  export function engTokenSlotFields(agent) {
113
118
  return {
114
119
  engDesignTokens: agent._engDesignTokens instanceof Map && agent._engDesignTokens.size > 0
115
120
  ? Object.fromEntries(agent._engDesignTokens)
116
121
  : undefined,
122
+ engDesignTokenBatches: agent._engDesignTokenBatches instanceof Map && agent._engDesignTokenBatches.size > 0
123
+ ? Object.fromEntries(agent._engDesignTokenBatches)
124
+ : undefined,
117
125
  }
118
126
  }
119
127
 
@@ -151,6 +159,16 @@ export function restoreEngTokens(agent, data) {
151
159
  }
152
160
  if (map.size > 0) agent._engDesignTokens = map
153
161
  else delete agent._engDesignTokens
162
+ // 批次归属旁列同拍读入(§10):值 = 批档绝对路径,无 TTL 可言(承载归属,不承载授权)。
163
+ // 槽因过期被丢而旁列行仍在 = 惰性残留:消费面按 id 反查时自然落成 no-op(不猜归属)。
164
+ const batches = new Map()
165
+ if (data.engDesignTokenBatches && typeof data.engDesignTokenBatches === "object" && !Array.isArray(data.engDesignTokenBatches)) {
166
+ for (const [id, b] of Object.entries(data.engDesignTokenBatches)) {
167
+ if (typeof b === "string" && b) batches.set(id, b)
168
+ }
169
+ }
170
+ if (batches.size > 0) agent._engDesignTokenBatches = batches
171
+ else delete agent._engDesignTokenBatches
154
172
  let droppedDesignTokens = 0
155
173
  for (const id of before) if (!map.has(id)) droppedDesignTokens++
156
174
  return { droppedDesignTokens }
@@ -159,11 +177,14 @@ export function restoreEngTokens(agent, data) {
159
177
  // ── 会话槽权威台账 I/O(DESIGN-TOKEN-SETTLEMENT D1/D2/D3——槽文件 = 权威结算台账) ──
160
178
 
161
179
  /** 当前会话槽号(读侧——无认领副作用):粘性 _slot 优先;未钉槽时读 manifest active
162
- * (loadManifest 只读——不在门禁路径触发认领写)。无槽 → null。 */
163
- function currentSlotNoReadOnly(agent) {
180
+ * (loadManifest 只读——不在门禁路径触发认领写)。无槽 → null。
181
+ * `strict` 透传 manifest 读形(§10「槽号解析层同判」):缺省宽容(manifest 不可读 ⇒ 无槽号);
182
+ * 收口面 `{strict:true}` ⇒ manifest 在盘但读失败 ∥ 解析失败即抛;manifest 不存在 ∥ `active` 缺席
183
+ * 仍是无槽号(合法缺省——「读到了,说没有」)。 */
184
+ function currentSlotNoReadOnly(agent, { strict = false } = {}) {
164
185
  const slot = agent?._slot
165
186
  if (Number.isInteger(slot)) return slot
166
- const active = loadManifest(agent?.cwd)?.active
187
+ const active = loadManifest(agent?.cwd, { strict })?.active
167
188
  return Number.isInteger(active) ? active : null
168
189
  }
169
190
 
@@ -185,6 +206,46 @@ export function readEngTokensFromSlot(agent) {
185
206
  } catch { return null }
186
207
  }
187
208
 
209
+ /** 读当前会话槽文件的批次归属旁列(DESIGN-TOKEN-SETTLEMENT §10——不做 TTL 过滤、不改盘)。
210
+ * 与 `readEngTokensFromSlot` 同形同读法(JSON.parse 轻读、零 reconcile 副作用);两者不能合成
211
+ * 一个函数——既有读者的字段名硬编码(只返回 `data.engDesignTokens`)⇒ 同形兄弟函数。
212
+ * **两形(§10「读形(收口面严格 ∥ 清点面宽容)」)**:`strict:false`(缺省 · 清点面)读不到 ⇒ `null`,
213
+ * 批列照旧如实降级 `—`(F-SL1);`strict:true`(收口面)读不到 ⇒ **抛**(close 被拒、档零字节变、可重试)。
214
+ * 判据 = **「没有」与「读不到」分清**:无槽号 ∥ 文件不存在(`ENOENT`)∥ 无该字段 ⇒ 空集(合法缺省,
215
+ * 两形同);其余 I/O 错(`EACCES` ∥ `EISDIR` ∥ `EIO`…)∥ `JSON.parse` 失败 ⇒ 宽容形 `null` / 严格形抛。
216
+ * **槽号解析层同判(manifest 层)**:槽号来自 `currentSlotNoReadOnly(agent, { strict })`——`_slot` 未钉时
217
+ * 读 manifest;strict 形把 manifest 的「读不到」原样抛出(拒因点名 manifest,不被本函数重包成旁列读失败)。
218
+ * 不用 `existsSync` 兜底——它把权限错也吞成「不存在」,正是收口面必须拒的那一类(「无法判定归属 =
219
+ * 无法证明清账」;§10「严格读的边界」)。
220
+ * 返回 {designId: <批档绝对路径>} 对象或 null(无槽/无文件/无该字段;宽容形的不可读亦 `null`)。 */
221
+ export function readEngTokenBatchesFromSlot(agent, { strict = false } = {}) {
222
+ // 槽号解析在 try 之外:manifest 层的「读不到」自带 manifest 坐标(§10「槽号解析层同判」),
223
+ // 不在这里被重包成「旁列读不到」——两层拒因各自点名。宽容形此步不抛(不可读 ⇒ 无槽号 ⇒ null)。
224
+ const slot = currentSlotNoReadOnly(agent, { strict })
225
+ if (slot == null) return null
226
+ let p = "(unresolved slot path)"
227
+ try {
228
+ p = slotPath(agent.cwd, slot)
229
+ let raw
230
+ try {
231
+ raw = readFileSync(p, "utf8")
232
+ } catch (e) {
233
+ if (e?.code === "ENOENT") return null // 「没有」:文件不存在(新会话 / 尚无槽)——合法缺省
234
+ throw e // 「读不到」:其余 I/O 错交外层按读形处置
235
+ }
236
+ const data = JSON.parse(raw)
237
+ return (data && typeof data === "object" && !Array.isArray(data.engDesignTokenBatches)
238
+ && data.engDesignTokenBatches && typeof data.engDesignTokenBatches === "object")
239
+ ? data.engDesignTokenBatches
240
+ : null
241
+ } catch (e) {
242
+ if (strict) {
243
+ throw new Error(`readEngTokenBatchesFromSlot: the session slot file's batch-attribution side column could NOT be read (${p}: ${e?.message ?? e}) — the closeout consume cannot tell which slots belong to this batch, so the closeout is refused with the record untouched; fix the slot file or its permissions and retry.`)
244
+ }
245
+ return null
246
+ }
247
+ }
248
+
188
249
  /** 内存 miss 时从权威槽 reconcile(D2 spawn 门 + D3 dispatch 写门同源):把槽文件中
189
250
  * **未过期**项并进内存 Map(TTL 过滤保留——expired 不并入,fail-closed;格式/畸形串
190
251
  * 与 restoreEngTokens 同语义读回——门禁格式拒);槽无活项 → 内存原样。返回判定用 Map。
@@ -281,6 +342,9 @@ export function persistEngTokens(agent) {
281
342
  const fields = engTokenSlotFields(agent)
282
343
  if (fields.engDesignTokens !== undefined) data.engDesignTokens = fields.engDesignTokens
283
344
  else delete data.engDesignTokens
345
+ // 批次归属旁列逐字段复制同处(§10「保存 / 合并路径」第二处):空表 ⇒ 字段删(不从上次落盘复活)。
346
+ if (fields.engDesignTokenBatches !== undefined) data.engDesignTokenBatches = fields.engDesignTokenBatches
347
+ else delete data.engDesignTokenBatches
284
348
  // 单值镜像字段永不复写(D3 零写)——残留 engDesignToken 随本次写一并清(更快退役,
285
349
  // 杜绝 stale 镜像 + 空 Map 组合在下次 restore 二次迁移复活死 token 的边角)。
286
350
  delete data.engDesignToken
package/token-window.mjs CHANGED
@@ -138,11 +138,13 @@ export function contextUsage(agent, extras = {}) {
138
138
  const overhead = system + tools
139
139
  const historyTokens = estimateTokens(history)
140
140
  const measured = agent?._lastPromptTokens != null
141
+ // §6.3 / §6.17 估算自校准:估算值乘 `_tokRatio`;`_lastPromptTokens` 是服务端真值,不乘。
142
+ const ratio = agent?._tokRatio ?? 1
141
143
  return {
142
144
  basis: measured ? "measured" : "estimated",
143
145
  total: measured
144
- ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
145
- : historyTokens + overhead,
146
+ ? agent._lastPromptTokens + Math.round(estimateTokens(history.slice(agent._usageAtLen ?? history.length)) * ratio)
147
+ : Math.round((historyTokens + overhead) * ratio),
146
148
  history: historyTokens,
147
149
  system,
148
150
  tools,
@@ -153,6 +155,25 @@ export function contextUsage(agent, extras = {}) {
153
155
  }
154
156
  }
155
157
 
158
+ /**
159
+ * 估算自校准采样(§6.17「估算自校准」)——池化比:`num += usage.prompt_tokens`、`den += estPrompt`
160
+ * (分子分母同取输入侧口径;`estPrompt` 由发送面随 result 带回,与净判同一个值)。
161
+ * `key = format:model` 变 ⇒ 归零重采(切模型自清,不接四个刷新点);`_tok` / `_tokRatio` 只由本函数写,
162
+ * `/new` 与 run 装配复位。无样本 ⇒ 比 = 1(估算维持原口径)。
163
+ * 作用面 = `contextUsage` 两支承(§6.3);实测基线 `_lastPromptTokens` 不乘。
164
+ */
165
+ export function recordTokenSample(agent, provider, estPrompt, usage) {
166
+ const prompt = Number(usage?.prompt_tokens)
167
+ if (!agent || !(prompt > 0) || !(estPrompt > 0)) return
168
+ const key = `${provider?.format ?? "openai"}:${provider?.model ?? ""}`
169
+ if (agent._tok?.key !== key) agent._tok = { key, num: 0, den: 0 }
170
+ agent._tok.num += prompt
171
+ agent._tok.den += estPrompt
172
+ // 下界 = 1:已知偏差方向是估算偏低;比 < 1 会把全部估算支缩小、重开「该压没压」原始故障,
173
+ // 保守方向不取(仅一侧校准,零新魔数)。
174
+ agent._tokRatio = Math.max(1, agent._tok.num / agent._tok.den)
175
+ }
176
+
156
177
  /**
157
178
  * 状态行百分比(§6.16.4「与状态行 ctx% 的单源关系」)——与 CLI 状态行(`render-frame.mjs` 式)与 VSC `ctxPercentForHistory` **同式**;
158
179
  * stats 报出的就是用户看到的那个百分比(不新增第三口径);空历史 ⇒ 0(状态行的 `ctxPct > 0` 显示门在端侧)。
@@ -1,15 +1,24 @@
1
- Wait until a condition becomes true — polls the condition every interval_ms and returns as soon as it holds or the timeout_ms ceiling passes. Use it ONLY for genuinely asynchronous waits: an async subagent/consult still settling, a file appearing, a port opening. Synchronous tools (advisor, blocking subagent spawn) return when done — waiting after them is NOT needed and wastes time.
1
+ Wait until a condition becomes true — polls every interval_ms and returns as soon as it holds or timeout_ms passes.
2
+
3
+ **Use it only for states that cannot announce themselves**: a file appearing, a port opening, a background bash or browser task finishing. Nothing delivers those into the session, so a blocking wait is the only way to learn about them.
4
+
5
+ **Never use it to wait for a subagent or a review report.** Those arrive on their own when the session is idle — the act of waiting for one is simply ending the turn.
6
+
7
+ `wait_for "subagent id:N done"` and `wait_for "advisor settled"` park the session for the whole budget, poll hundreds of times, and learn nothing you would not have been handed anyway.
8
+
9
+ Measured in practice: two such waits in one session burned ~4.5 minutes and 210 polls on reports that were already on their way. The conditions stay supported (a background task genuinely may need joining), but reaching for them on a child is the classic misuse.
2
10
 
3
11
  Parameters:
4
12
  - condition (required): a semantic condition expression:
5
- - `advisor settled` — an in-flight advisor review session has finished (async advisor-role children settled)
6
- - `subagent id:N done` — async subagent N (the id your async spawn ack returned) has settled
7
- - `consult done` — every consult_start session has drained
13
+ - `advisor settled` — an in-flight advisor review session has finished — **discouraged**: the report is delivered automatically; end the turn instead
14
+ - `subagent id:N done` — async subagent N (the id your async spawn ack returned) has settled — **discouraged**, same reason
15
+ - `consult done` — every consult_start session has drained — the verdict text is delivered automatically; wait only when a synchronous caller cannot proceed without it
8
16
  - `bash id:N done` — background bash task N (the id from the bash async ack, `bash#N`) has finished — settled, or already left the pool (nothing left to wait on)
9
17
  - `browser id:N done` — a background browser task N (the id from the ack, `browser#N`) has settled — finished, or already left the pool (nothing left to wait on)
10
18
  - `file exists:path` — the file at path exists (path relative to cwd)
11
19
  - `port open:N` — something is listening on 127.0.0.1 port N
12
- - The agent-internal conditions (advisor / subagent / consult / bash) apply to ASYNC sessions only — a synchronous call already completed before it returned and has nothing to wait for. An unknown condition is an explicit error (`wait_for: unsupported condition "..."` — the supported forms are listed above), never a silent wait.
20
+ - The agent-internal conditions (advisor / subagent / consult / bash) apply to ASYNC sessions only — a synchronous call already completed before it returned and has nothing to wait for.
21
+ - An unknown condition is an explicit error (`wait_for: unsupported condition "..."` — the supported forms are listed above), never a silent wait.
13
22
  - interval_ms: poll interval (default 1000, floor 100 — polling never busy-spins)
14
23
  - timeout_ms: overall ceiling (default 30000; config.json `agent.waitForTimeoutMs` overrides the default; hard cap 600000 like the execute tool)
15
24
 
@@ -21,4 +30,4 @@ Returns:
21
30
  Notes:
22
31
  - Read-only and non-destructive — it only observes (agent pools, the filesystem, a local port probe).
23
32
  - Blocking by design — call it ALONE in a turn, not batched with calls that depend on its result.
24
- - Do not use it to wait after synchronous tools (advisor / blocking subagent spawn / verify return only when done).
33
+ - This is the one blocking call an agent usually should not reach for. Before calling it, ask: **will this state announce itself to me anyway?** If yes (a subagent report, a review digest, a timer), end the turn and let it arrive.