@thincoder/core 0.10.1 → 0.10.3
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 +46 -0
- package/advisor/citations.mjs +13 -3
- package/advisor/convergence.mjs +1 -1
- package/advisor/messages.mjs +3 -3
- package/agent/dispatch-run.mjs +29 -12
- package/agent/record-results.mjs +6 -1
- package/agent/spawn-child.mjs +2 -1
- package/agent/subagent-event.mjs +71 -0
- package/agent/suspension.mjs +35 -9
- package/agent/turn-loop.mjs +6 -0
- package/agent-tools/advisor-async.mjs +11 -1
- package/agent-tools/advisor-settle.mjs +7 -9
- package/agent-tools/batch-lifecycle.mjs +3 -2
- package/agent-tools/batch-read.mjs +36 -0
- package/agent-tools/batch-skeleton.mjs +28 -0
- package/agent-tools/batch.mjs +5 -3
- package/agent-tools/design-token.mjs +56 -16
- package/agent-tools/subagent-async.mjs +6 -1
- package/agent-tools/subagent-panel.mjs +119 -36
- package/agent-tools/subagent.mjs +4 -2
- package/agent-tools/verify.mjs +5 -0
- package/auto-think.mjs +26 -2
- package/config-presets.mjs +8 -0
- package/config.mjs +42 -8
- package/declaration.mjs +26 -2
- package/embedding.mjs +35 -3
- package/ledger-cmd.mjs +13 -21
- package/ledger-db.mjs +34 -1
- package/ledger-migrate.mjs +4 -3
- package/ledger-read.mjs +94 -0
- package/ledger-surface.mjs +15 -46
- package/ledger.mjs +36 -62
- package/manifest-schema.mjs +13 -3
- package/manifest.mjs +27 -8
- package/mcp/transport-http.mjs +52 -4
- package/mcp/transport-stdio.mjs +33 -8
- package/mcp.mjs +13 -5
- package/memory/code-search.mjs +5 -3
- package/memory/core.mjs +6 -4
- package/memory/docs.mjs +5 -3
- package/memory/schema.mjs +2 -0
- package/model-ref.mjs +96 -3
- package/package.json +1 -1
- package/prompts/advisor-design.md +3 -2
- package/prompts/advisor-round1.md +1 -0
- package/prompts/advisor-round2.md +8 -3
- package/prompts/advisor-round3.md +7 -2
- package/prompts/discipline-normal.md +2 -1
- package/prompts/persona-engineering.md +3 -1
- package/provider/core.mjs +10 -4
- package/provider/google.mjs +18 -2
- package/provider/normalize.mjs +57 -0
- package/provider/sse.mjs +45 -13
- package/proxy.mjs +13 -3
- package/session-lifecycle.mjs +21 -12
- package/session.mjs +13 -2
- package/stream-destroy.mjs +62 -0
- package/tool-docs/verify.md +1 -1
- package/tools/edit-batch.mjs +7 -2
- package/undo-stack.mjs +23 -9
|
@@ -76,6 +76,7 @@ Batch record, dispatch task books, verification conclusions, review firing, requ
|
|
|
76
76
|
- **Parent does not ghost-write**: you do NOT write the design doc — anything needing change goes **via a fix round** to eng-designer; three exception categories: ① your own write domain (batch record §1/§4/§6 · ledger · requirement docs) ② purely mechanical form corrections (line folding / pointer form / counts) ③ **small edits** (single-line / table-level · no new semantics · verifiable one by one).
|
|
77
77
|
**All three must be marked** ("parent direct execution" + revertable). **Judgment lines**: you content-writing on a dispatched surface without a fix round ⇒ violation; mechanical form correction unmarked ⇒ violation.
|
|
78
78
|
- **Close three states (no "promises")**: each round's close allows **only three states** — ① **Do** (the action **was fired THIS round**: tool call / edit landed — the report only describes **what happened this round**); ② **Wait** (real dependency: waiting for the user's nod / a subagent's return — **must state what you're waiting for**); ③ **Stop** (anomaly / pending judgment — **state the stop point**). **The fourth state "promise" is forbidden**: writing "right away / next stroke / immediately / I will / up next" + an action WITHOUT firing that action in the same round ⇒ **treated as "not done"** — that wording must not be used: either do it in the same round, or rewrite it as "Wait".
|
|
79
|
+
- **Every claim carries its basis**: at close, each judgment / conclusion / reading must carry its basis — one of: a judgment source (what it stands on), a `file:line` coordinate, or an explicit `unverified`; a claim with none of the three must not be output as a conclusion; a basis present must be checkable, one absent must be marked — no format mandated, existing documents not retrofitted.
|
|
79
80
|
- **Debts go on the list**: undispatched / unfinished items of your own ⇒ **immediately written into batch record §6 unresolved (or a ledger row)** — debts **must be visible**, never living only in report prose, a code comment, or an inline note waiting for the user to chase.
|
|
80
81
|
- **Fix in place first**: one-off small-form items (wording / coordinates / indexes / cleanup) from reviews or sweeps get fixed on the spot — never booked. **Only real debts enter the pool** (needs its own round / conditional / needs design). Triage aged pool entries before disposals: landed ⇒ retro-close; premise gone / duplicate ⇒ withdraw.
|
|
81
82
|
- **Drain first (auto mode)**: while subagents are in flight, **clear your own queue in parallel** (verification / closure / settlement / mechanical corrections) — "waiting" **only holds for real dependencies**; parking doable work on "waiting" ⇒ violation.
|
|
@@ -105,13 +106,14 @@ Batch record, dispatch task books, verification conclusions, review firing, requ
|
|
|
105
106
|
No "pre-existing" cop-out: "it was already broken" is never a reason to skip a fix — you own the whole design/code, and when a defect appeared does not decide whether it should be fixed.
|
|
106
107
|
Findings beyond the approved design's scope: surface them or propose a design update — never silently ignore.
|
|
107
108
|
A 🔴 you neither fix nor surface blocks convergence.
|
|
108
|
-
`Deferred` fits 🟡/🔵 improvements
|
|
109
|
+
`Deferred` fits 🟡/🔵 improvements, a 🔴 needing the user's decision first, or a reasoned acknowledged-not-fixed 🔴 (the dispute window closes it from round 3 on; a zero-reason refusal is never accepted) — never a way to silently drop a real defect; unresolved 🔴 must be surfaced to the user.
|
|
109
110
|
- **Fix round ⇄ user approval timing** (post-review): after review passes you adjudicate one by one (adjudication table) — fixes your adjudication demands (design-doc revisions / implementation repairs)
|
|
110
111
|
**land via a fix round and pass your verification BEFORE you may request user approval**; while a fix round is in flight you must NOT request approval — in-flight status is reported only, and the report carries no approval request.
|
|
111
112
|
**Fix-round boundary**: only fixes directly derived from review findings and your adjudication — **no smuggled new semantics/scope**; smuggling = new content,
|
|
112
113
|
to be explicitly laid before the user for a separate decision, never default-approved along with the approval request.
|
|
113
114
|
In the approval request, the adjudication table's `Dispatched` rows must have converged to `Fixed` one by one (with landing evidence: file:line or design-doc section).
|
|
114
115
|
- Round decay: round 2 verifies the prior table + flags obvious new issues; round 3+ strictly verifies only the prior table (no new-issue hunting). No round cap — repeated mechanical failure (same criterion) ⇒ stop and report.
|
|
116
|
+
Every non-fix must carry a reason (a technical rebuttal with evidence, or an explicit acknowledged-not-fixed record); the reviewer may push back on insufficient reasons through round 2, and from round 3 accepts reasoned non-fixes — a zero-reason refusal is never accepted and stays unresolved.
|
|
115
117
|
When the advisor reports all clear (no 🔴 remaining), run `verify`.
|
|
116
118
|
- Async: **advisor calls are async by default at the top level.** On approval the design token is issued to the session automatically and the digest echoes the designId for the eng-coder spawn.
|
|
117
119
|
|
package/provider/core.mjs
CHANGED
|
@@ -126,6 +126,8 @@ async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWai
|
|
|
126
126
|
// window/clamping logic below reads the overridden value where it matters.
|
|
127
127
|
const spec = providerSpec(provider)
|
|
128
128
|
messages = stripImagesForTextModel(messages, spec)
|
|
129
|
+
// #853 D-PR32:历史图片累计字节预算——发送前对消息副本累计驱逐(最老先弃;history 本体零触)。
|
|
130
|
+
messages = capHistoryImages(messages)
|
|
129
131
|
// SESSION.md §6.9 T-S3: local-only message fields (ts/transient) never reach the wire.
|
|
130
132
|
// Stripped BEFORE format dispatch — anthropic/responses transports pass whole message
|
|
131
133
|
// objects through verbatim (only the OpenAI path ran escapeMessages). Copy-on-write:
|
|
@@ -330,7 +332,7 @@ export function buildContinuationMessages(messages, result, spec) {
|
|
|
330
332
|
// Pre-send payload normalization lives in normalize.mjs (2026-08-31 extract,
|
|
331
333
|
// TODO #2); re-exported so provider/index.mjs and tool-pairing.test.mjs keep
|
|
332
334
|
// their import paths.
|
|
333
|
-
import { stripImagesForTextModel, normalizeToolPairing } from "./normalize.mjs"
|
|
335
|
+
import { stripImagesForTextModel, normalizeToolPairing, capHistoryImages } from "./normalize.mjs"
|
|
334
336
|
export { stripImagesForTextModel, normalizeToolPairing }
|
|
335
337
|
/** Merge tool calls from a retry/continuation into the accumulated result.
|
|
336
338
|
* 2026-08-31 会诊 #7/#17:readSSE 输出的 tc 已 finalize(无 index 字段),
|
|
@@ -392,9 +394,9 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
392
394
|
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
393
395
|
_bodyIdleMs: FETCH_BODY_IDLE_MS,
|
|
394
396
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
397
|
+
// #878 D-PX8:请求调用点收口——恒走 `proxyFetch`(无 proxyUri 即直连分支,内部 abort 通道单点建设;
|
|
398
|
+
// 原 call-site 分叉删除)。
|
|
399
|
+
response = await proxyFetch(url, opts, provider.proxyUri)
|
|
398
400
|
} catch (error) {
|
|
399
401
|
// AGENT-LOOP-SUBAGENT.md §6.12 站点 #2(第 24 批):fetch 拒否面补标来源(undici 拒否的 AbortError 现场无 reason)
|
|
400
402
|
if (error.name === "AbortError") throw annotateAbort(error, signal, "provider", "request")
|
|
@@ -406,6 +408,10 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
406
408
|
|
|
407
409
|
const text = await response.text().catch(() => "")
|
|
408
410
|
let message = `LLM API error ${response.status}: ${text}`
|
|
411
|
+
// #853 D-PR32:413 可操作化——长会话图片累积的高频形态,从裸 HTTP 码变为可执行指引。
|
|
412
|
+
if (response.status === 413) {
|
|
413
|
+
message += " — request body too large (HTTP 413): run /compact to compress the conversation history, or remove historical images."
|
|
414
|
+
}
|
|
409
415
|
// 401 双平台提示 + 诊断回显(2026-08-31 会诊 #15):
|
|
410
416
|
// Kimi 双平台 key 不互通的提示保留;通用加 baseURL host + key 前 6 位掩码,
|
|
411
417
|
// 帮用户快速分辨"配错平台还是配错账号"。
|
package/provider/google.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { proxyFetch } from "../proxy.mjs"
|
|
|
8
8
|
import { requestWithRetry } from "./retry.mjs"
|
|
9
9
|
import { effectiveFetchTimeoutMs } from "./core.mjs"
|
|
10
10
|
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
11
|
+
import { terminateBody } from "../stream-destroy.mjs"
|
|
11
12
|
|
|
12
13
|
/** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
|
|
13
14
|
function mapFunctionCallingConfig(choice) {
|
|
@@ -197,10 +198,14 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
|
197
198
|
// 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续 120s 无新 chunk 判死
|
|
198
199
|
const READ_IDLE_MS = 120_000
|
|
199
200
|
let idleTimer = null
|
|
201
|
+
let idleError = null
|
|
200
202
|
const armIdle = () => {
|
|
201
203
|
if (idleTimer) clearTimeout(idleTimer)
|
|
202
204
|
idleTimer = setTimeout(() => {
|
|
203
|
-
|
|
205
|
+
// #878:断流单点分流——直连 fetch(web 流)走 `IDLE_ABORT` 内部 abort 通道;
|
|
206
|
+
// proxy 形回落 destroyBody(原契约)。相位串 = 本腿专属 google-sse-idle。
|
|
207
|
+
idleError = timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "google-sse-idle")
|
|
208
|
+
try { terminateBody(response, idleError) } catch { /* already gone */ }
|
|
204
209
|
}, READ_IDLE_MS)
|
|
205
210
|
idleTimer.unref?.()
|
|
206
211
|
}
|
|
@@ -238,6 +243,16 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
|
238
243
|
result.interruptMessage = signal.reason.message
|
|
239
244
|
return result
|
|
240
245
|
}
|
|
246
|
+
// #878:看门狗终结两径归一(idleFired)——无内容 ⇒ idle 超时错误;有内容 ⇒ partial
|
|
247
|
+
//(已流出内容保留,不整轮报废)。非 idle 错误照旧走下方 hasPartial 径(原语义零变)。
|
|
248
|
+
if (idleError) {
|
|
249
|
+
if (result.content || result.reasoning || result.toolCalls.length) {
|
|
250
|
+
result.partial = true
|
|
251
|
+
result.networkError = idleError.message ?? String(idleError)
|
|
252
|
+
return result
|
|
253
|
+
}
|
|
254
|
+
throw idleError
|
|
255
|
+
}
|
|
241
256
|
if (hasPartial(e)) {
|
|
242
257
|
result.partial = true
|
|
243
258
|
result.networkError = e.message ?? String(e)
|
|
@@ -251,7 +266,8 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
|
251
266
|
return result
|
|
252
267
|
}
|
|
253
268
|
|
|
254
|
-
/** google.mjs 无 hasChoices
|
|
269
|
+
/** google.mjs 无 hasChoices 追踪——非 idle 网络错误按文案判 partial(同 sse.mjs 语义的简化版);
|
|
270
|
+
* idle 径两分(有内容 ⇒ partial ∥ 无内容 ⇒ 超时错误)归 catch(#878)。 */
|
|
255
271
|
function hasPartial(e) {
|
|
256
272
|
return /ECONNRESET|terminated|idle timeout|network/i.test(e?.message ?? "")
|
|
257
273
|
}
|
package/provider/normalize.mjs
CHANGED
|
@@ -10,6 +10,63 @@
|
|
|
10
10
|
|
|
11
11
|
const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
|
|
12
12
|
|
|
13
|
+
/** 历史图片累计字节预算(D-PR32——40MB 级请求体的保守下界,留两张大图余量;可调)。 */
|
|
14
|
+
export const IMAGE_HISTORY_BYTES_BUDGET = 32 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
/** 图片驱逐占位文本(D-PR32 逐字)。 */
|
|
17
|
+
const IMAGE_BUDGET_PLACEHOLDER = "[image omitted — dropped to keep the request under the history image budget]"
|
|
18
|
+
|
|
19
|
+
/** 单 part 的 base64 载荷长度(仅 data URL;非 data 引用不过请求体、零计账)。 */
|
|
20
|
+
function imagePartBytes(p) {
|
|
21
|
+
if (p?.type !== "image_url") return 0
|
|
22
|
+
const url = p.image_url?.url
|
|
23
|
+
if (typeof url !== "string" || !url.startsWith("data:")) return 0
|
|
24
|
+
const comma = url.indexOf(",")
|
|
25
|
+
return comma >= 0 ? url.length - comma - 1 : 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 历史图片累计字节预算(D-PR32):全部 image part 的 base64 载荷和超 budgetBytes 时,
|
|
30
|
+
* 从**最老**图起替换为占位文本,直到剩余 ≤ 预算。纯函数 + 拷贝语义(未改动时返回原引用;
|
|
31
|
+
* 改动时仅重建涉及的消息对象)——发送前对消息副本用;注入面对 `history` 本体回写(同函数同预算)。
|
|
32
|
+
* @param {Array} messages
|
|
33
|
+
* @param {number} [budgetBytes]
|
|
34
|
+
*/
|
|
35
|
+
export function capHistoryImages(messages, budgetBytes = IMAGE_HISTORY_BYTES_BUDGET) {
|
|
36
|
+
if (!Array.isArray(messages)) return messages
|
|
37
|
+
const hits = []
|
|
38
|
+
let total = 0
|
|
39
|
+
for (let mi = 0; mi < messages.length; mi++) {
|
|
40
|
+
const parts = messages[mi]?.content
|
|
41
|
+
if (!Array.isArray(parts)) continue
|
|
42
|
+
for (let pi = 0; pi < parts.length; pi++) {
|
|
43
|
+
const bytes = imagePartBytes(parts[pi])
|
|
44
|
+
if (bytes <= 0) continue
|
|
45
|
+
total += bytes
|
|
46
|
+
hits.push({ mi, pi, bytes })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (total <= budgetBytes || hits.length === 0) return messages
|
|
50
|
+
const drop = new Set()
|
|
51
|
+
let remaining = total
|
|
52
|
+
for (const hit of hits) {
|
|
53
|
+
if (remaining <= budgetBytes) break
|
|
54
|
+
drop.add(`${hit.mi}:${hit.pi}`)
|
|
55
|
+
remaining -= hit.bytes
|
|
56
|
+
}
|
|
57
|
+
return messages.map((m, mi) => {
|
|
58
|
+
const parts = m?.content
|
|
59
|
+
if (!Array.isArray(parts)) return m
|
|
60
|
+
let changed = false
|
|
61
|
+
const next = parts.map((p, pi) => {
|
|
62
|
+
if (!drop.has(`${mi}:${pi}`)) return p
|
|
63
|
+
changed = true
|
|
64
|
+
return { type: "text", text: IMAGE_BUDGET_PLACEHOLDER }
|
|
65
|
+
})
|
|
66
|
+
return changed ? { ...m, content: next } : m
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
13
70
|
export function stripImagesForTextModel(messages, spec) {
|
|
14
71
|
let changed = false
|
|
15
72
|
const out = messages.map((m) => {
|
package/provider/sse.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Extracted from core.mjs. Parses Server-Sent Events for LLM chat responses.
|
|
4
4
|
*/
|
|
5
5
|
import { abortError, timeoutError } from "../abort-provenance.mjs"
|
|
6
|
+
import { terminateBody } from "../stream-destroy.mjs"
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Normalize provider cache fields into DeepSeek-style prompt_cache_hit/miss_tokens.
|
|
@@ -20,29 +21,43 @@ export function normalizeUsageCache(u) {
|
|
|
20
21
|
}
|
|
21
22
|
return u
|
|
22
23
|
}
|
|
24
|
+
/** 帧形状分派用前缀补差(D-PR31):全同 ⇒ ""(零再增);已收为前缀 ⇒ 只补差量;
|
|
25
|
+
* 无前缀关系 ⇒ 整段按增量追加(不吞真实内容——无法判定时不丢文本)。 */
|
|
26
|
+
function snapshotSuffix(accumulated, text) {
|
|
27
|
+
if (text === accumulated) return ""
|
|
28
|
+
if (text.startsWith(accumulated)) return text.slice(accumulated.length)
|
|
29
|
+
return text
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
/** Defensive tool-call merge (PROVIDER.md §10): skip null/malformed elements and count them;
|
|
24
|
-
* merge slots by index / id / name / tail, accumulate arguments.
|
|
25
|
-
|
|
33
|
+
* merge slots by index / id / name / tail, accumulate arguments.
|
|
34
|
+
* `snapshot: true`(D-PR31——`choice.message` 完整快照帧):已有 slot(index / id 命中)的
|
|
35
|
+
* arguments **覆盖**(非 +=)——修快照帧二次拼接;新 slot 照常建、其余路径零变。 */
|
|
36
|
+
function mergeToolCalls(result, delta, { snapshot = false } = {}) {
|
|
26
37
|
for (const tc of delta.tool_calls ?? []) {
|
|
27
38
|
if (!tc || typeof tc !== "object") { result.droppedToolCalls++; continue }
|
|
28
39
|
let slot
|
|
40
|
+
let existed = false
|
|
29
41
|
if (Number.isInteger(tc.index) && tc.index >= 0) {
|
|
42
|
+
existed = result.toolCalls[tc.index] != null
|
|
30
43
|
slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
|
|
31
44
|
} else if (tc.id) {
|
|
32
45
|
slot = result.toolCalls.find((s) => s && s.id === tc.id)
|
|
46
|
+
existed = !!slot
|
|
33
47
|
if (!slot) { slot = { id: tc.id, name: "", arguments: "" }; result.toolCalls.push(slot) }
|
|
34
48
|
} else if (tc.function?.name) {
|
|
35
49
|
slot = { id: "", name: "", arguments: "" }
|
|
36
50
|
result.toolCalls.push(slot)
|
|
37
51
|
} else {
|
|
38
52
|
slot = result.toolCalls[result.toolCalls.length - 1]
|
|
53
|
+
existed = !!slot
|
|
39
54
|
if (!slot) { result.droppedToolCalls++; continue }
|
|
40
55
|
}
|
|
41
56
|
if (tc.id && !slot.id) slot.id = tc.id
|
|
42
57
|
if (tc.function?.name && !slot.name) slot.name = tc.function.name
|
|
43
58
|
const arg = tc.function?.arguments
|
|
44
|
-
if (typeof arg === "string") slot.arguments
|
|
45
|
-
else if (arg != null) slot.arguments
|
|
59
|
+
if (typeof arg === "string") slot.arguments = snapshot && existed ? arg : slot.arguments + arg
|
|
60
|
+
else if (arg != null) slot.arguments = snapshot && existed ? JSON.stringify(arg) : slot.arguments + JSON.stringify(arg)
|
|
46
61
|
}
|
|
47
62
|
}
|
|
48
63
|
|
|
@@ -135,19 +150,29 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
135
150
|
hasChoices = true
|
|
136
151
|
if (choice.finish_reason) result.finishReason = choice.finish_reason
|
|
137
152
|
|
|
138
|
-
|
|
153
|
+
// 帧形状分派(D-PR31):`delta == null && message != null` = 完整快照帧(MiniMax v2 类端点)——
|
|
154
|
+
// content / reasoning 前缀补差、tool_calls 覆盖;`delta` 在场 = 纯增量语义照旧
|
|
155
|
+
//(真增量文本天然可重复——不做去重)。
|
|
156
|
+
const snapshot = choice.delta == null && choice.message != null
|
|
157
|
+
const delta = snapshot ? choice.message : (choice.delta ?? {})
|
|
139
158
|
// reasoning 方言:DeepSeek/Kimi/GLM 用 reasoning_content,OpenAI o 系和部分
|
|
140
159
|
// 路由器用 reasoning —— 两个都认(2026-08-31 会诊 #9)
|
|
141
160
|
const rDelta = delta.reasoning_content ?? delta.reasoning
|
|
142
161
|
if (rDelta) {
|
|
143
|
-
result.reasoning
|
|
144
|
-
|
|
162
|
+
const add = snapshot ? snapshotSuffix(result.reasoning, rDelta) : rDelta
|
|
163
|
+
if (add) {
|
|
164
|
+
result.reasoning += add
|
|
165
|
+
onReasoning?.(add)
|
|
166
|
+
}
|
|
145
167
|
}
|
|
146
168
|
if (delta.content) {
|
|
147
|
-
result.content
|
|
148
|
-
|
|
169
|
+
const add = snapshot ? snapshotSuffix(result.content, delta.content) : delta.content
|
|
170
|
+
if (add) {
|
|
171
|
+
result.content += add
|
|
172
|
+
onToken?.(add)
|
|
173
|
+
}
|
|
149
174
|
}
|
|
150
|
-
mergeToolCalls(result, delta)
|
|
175
|
+
mergeToolCalls(result, delta, { snapshot })
|
|
151
176
|
}
|
|
152
177
|
|
|
153
178
|
const processLines = (lines) => {
|
|
@@ -172,10 +197,14 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
172
197
|
// 直连 fetch 与 proxyFetch 统一走这里(proxy 的 _bodyIdleMs 语义与之等价,双保险)。
|
|
173
198
|
const READ_IDLE_MS = 120_000
|
|
174
199
|
let idleTimer = null
|
|
200
|
+
let idleError = null
|
|
175
201
|
const armIdle = () => {
|
|
176
202
|
if (idleTimer) clearTimeout(idleTimer)
|
|
177
203
|
idleTimer = setTimeout(() => {
|
|
178
|
-
|
|
204
|
+
// #878:断流单点分流——直连 fetch(web 流)走 `IDLE_ABORT` 内部 abort 通道,
|
|
205
|
+
// proxy 形回落 destroyBody(原契约)。
|
|
206
|
+
idleError = timeoutError(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`, "provider", "sse-idle")
|
|
207
|
+
try { terminateBody(response, idleError) } catch { /* already gone */ }
|
|
179
208
|
}, READ_IDLE_MS)
|
|
180
209
|
idleTimer.unref?.()
|
|
181
210
|
}
|
|
@@ -224,20 +253,23 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
224
253
|
if (idleTimer) clearTimeout(idleTimer)
|
|
225
254
|
return result
|
|
226
255
|
}
|
|
256
|
+
// #878:看门狗终结归类归一(abort 通道下底层错误形不定)——无内容 ⇒ idle 超时错误;
|
|
257
|
+
// 有部分内容 ⇒ partial + networkError(原径语义保持)。
|
|
258
|
+
const err = idleError ?? e
|
|
227
259
|
// 2026-08-31 会诊 #2(流中断丢全部已收内容):网络级失败(ECONNRESET / 半截 EOF /
|
|
228
260
|
// proxy 断连)时若已解析出内容,把 partial 交回上层而不是整轮报废重试。
|
|
229
261
|
// 标记 partial:true + networkError(上层可决定续写/重试/展示部分结果)。
|
|
230
262
|
if (hasChoices && (result.content || result.toolCalls.length)) {
|
|
231
263
|
finalizeToolCalls(result)
|
|
232
264
|
result.partial = true
|
|
233
|
-
result.networkError =
|
|
265
|
+
result.networkError = err.message ?? String(err)
|
|
234
266
|
const existing = result._warnings ??= []
|
|
235
267
|
if (!existing.some((w) => w.name === "network-partial")) {
|
|
236
268
|
existing.push({ name: "network-partial", message: `stream interrupted by network error after partial output: ${result.networkError}` })
|
|
237
269
|
}
|
|
238
270
|
return result
|
|
239
271
|
}
|
|
240
|
-
throw
|
|
272
|
+
throw err
|
|
241
273
|
}
|
|
242
274
|
|
|
243
275
|
if (idleTimer) clearTimeout(idleTimer)
|
package/proxy.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { connect as tlsConnect } from "node:tls";
|
|
|
7
7
|
import { PassThrough } from "node:stream";
|
|
8
8
|
import { URL } from "node:url";
|
|
9
9
|
import { abortError, timeoutError } from "./abort-provenance.mjs";
|
|
10
|
+
import { IDLE_ABORT, destroyBody } from "./stream-destroy.mjs";
|
|
10
11
|
|
|
11
12
|
const FETCH_TIMEOUT = 15_000
|
|
12
13
|
|
|
@@ -91,12 +92,12 @@ export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIME
|
|
|
91
92
|
function fail(err) {
|
|
92
93
|
cleanup()
|
|
93
94
|
if (!settled) { settled = true; reject(err) }
|
|
94
|
-
else body
|
|
95
|
+
else destroyBody(body, err)
|
|
95
96
|
}
|
|
96
97
|
/** body 阶段空闲看门狗:每次数据到达重置;无数据超时 → 断流(流式消费方抛错) */
|
|
97
98
|
function armIdle() {
|
|
98
99
|
clearTimeout(idleTimer)
|
|
99
|
-
if (bodyIdleMs > 0) idleTimer = setTimeout(() => body
|
|
100
|
+
if (bodyIdleMs > 0) idleTimer = setTimeout(() => destroyBody(body, timeoutError("Response body timeout (idle)", "provider", "proxy-body-idle")), bodyIdleMs)
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
sock.on("data", (d) => {
|
|
@@ -261,9 +262,18 @@ function tcpConnectProxy(proxyUri, signal, timeout) {
|
|
|
261
262
|
/**
|
|
262
263
|
* Generic fetch with proxy support.
|
|
263
264
|
* No proxy → native fetch. Proxy + HTTPS → CONNECT tunnel. Proxy + HTTP → 经典代理转发(绝对 URI 请求行)。
|
|
265
|
+
* 直连分支 = 内部 abort 通道建设点(#878 D-PX8):建 `AbortController` + signal 合成(`AbortSignal.any`,
|
|
266
|
+
* 无 user signal 时单独用)后经 `IDLE_ABORT` 挂 response——web `ReadableStream` 的读侧看门狗
|
|
267
|
+
* (`readSSE` ∥ `parseGeminiStream`)经 `terminateBody` 恢复有效;proxy 两分支不挂(自有 `_bodyIdleMs`)。
|
|
264
268
|
*/
|
|
265
269
|
export async function proxyFetch(urlStr, opts, proxyUri) {
|
|
266
|
-
if (!proxyUri)
|
|
270
|
+
if (!proxyUri) {
|
|
271
|
+
const idleAbort = new AbortController()
|
|
272
|
+
const signal = opts?.signal ? AbortSignal.any([opts.signal, idleAbort.signal]) : idleAbort.signal
|
|
273
|
+
const response = await globalThis.fetch(urlStr, { ...opts, signal })
|
|
274
|
+
response[IDLE_ABORT] = idleAbort
|
|
275
|
+
return response
|
|
276
|
+
}
|
|
267
277
|
const target = new URL(urlStr)
|
|
268
278
|
if (target.protocol === "https:") return tunnelHttps(urlStr, opts, proxyUri)
|
|
269
279
|
// http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
|
package/session-lifecycle.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
import { existsSync } from "node:fs"
|
|
21
21
|
import { resolveCompactThreshold } from "./config.mjs"
|
|
22
|
+
// 槽面判据单源(#841 KD-841-2:槽渠道 key 门 + 模型面序——PROVIDER.md §6.22)。
|
|
23
|
+
import { resolveProviderPlan } from "./model-ref.mjs"
|
|
22
24
|
import {
|
|
23
25
|
slotPath, loadManifest, saveManifest, slotDigest, writeSessionFile, getSessionId,
|
|
24
26
|
writeEndMarker, sessionEnd, ownerPid, ownerPids,
|
|
@@ -158,18 +160,25 @@ export function applySession(agent, data, opts = {}) {
|
|
|
158
160
|
} else {
|
|
159
161
|
unbindRecordStore(agent)
|
|
160
162
|
}
|
|
161
|
-
// ── MODEL-MERGE-SESSION 恢复(F-4
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
// ── MODEL-MERGE-SESSION 恢复(F-4——两支;#841:槽面判据单源 = 统一解析第 1 步)──
|
|
164
|
+
// 槽渠道(`activeProvider` 在册 ∧ 持 key)⇒ 槽值设;槽无 key ∥ 不在册 ⇒ 跳过(落 config 链——
|
|
165
|
+
// 不把不可运行渠道钉进运行态;**跳过不写槽**——原槽值保留(D-S3 / KD-841-2))。判定 + 模型面
|
|
166
|
+
// 同源 = `resolveProviderPlan`(PROVIDER.md §6.22——零第二判据)。
|
|
167
|
+
const slotPlan = resolveProviderPlan({
|
|
168
|
+
providers: agent.providers,
|
|
169
|
+
defaultModel: agent.config?.defaultModel,
|
|
170
|
+
slot: { provider: data.activeProvider ?? null, model: data.activeModel ?? null },
|
|
171
|
+
})
|
|
172
|
+
if (slotPlan.source === "slot") {
|
|
173
|
+
const slotProvider = agent.providers?.find((pr) => pr.name === slotPlan.channel)
|
|
174
|
+
// ① 槽 provider 存在 → 按槽值设(不看 config):双字段恒非空。
|
|
175
|
+
// 模型面序(#841):槽模型 → `defaultModel` 属本渠道 ⇒ 其模型段 → 渠道默认单值
|
|
176
|
+
// (`providers[].model`);三档皆无 ⇒ `null`(不落 model 键——下游沿既有「model 缺失」处置)。
|
|
177
|
+
// F-2d(MODEL-400-FIX)径保留:空串 / 非串槽模型同样兜(#638 非串 = 未登记)——slotPlan.model
|
|
178
|
+
// 已按同归一出值(单源,无第二判据)。
|
|
168
179
|
const prevName = agent.activeProvider
|
|
169
180
|
const prevModel = agent.activeModel ?? null
|
|
170
|
-
|
|
171
|
-
// 模型——类型脏值不入两状态字段(镜下方阈值重算门 ∕ 档位块 ∕ VSC `panel-turn-stages.mjs:53` 同形门)。
|
|
172
|
-
const slotModel = (typeof data.activeModel === "string" && data.activeModel) ? data.activeModel : slotProvider.model
|
|
181
|
+
const slotModel = slotPlan.model
|
|
173
182
|
const switched = prevName !== slotProvider.name || prevModel !== slotModel
|
|
174
183
|
agent.activeProvider = slotProvider.name
|
|
175
184
|
agent.activeModel = slotModel
|
|
@@ -200,8 +209,8 @@ export function applySession(agent, data, opts = {}) {
|
|
|
200
209
|
}
|
|
201
210
|
return switched
|
|
202
211
|
}
|
|
203
|
-
// ②
|
|
204
|
-
//
|
|
212
|
+
// ② 槽无 key ∥ 不在册 → D-S3 保留——静默保持现状(落 config 链:config 统一解析产物已是
|
|
213
|
+
// `agent.provider`;不报错不纠正——两方都无效由调用侧复验 validateProvider 弹重选)
|
|
205
214
|
return false
|
|
206
215
|
}
|
|
207
216
|
|
package/session.mjs
CHANGED
|
@@ -106,8 +106,11 @@ function digestFromStore(fields, counters, mtimeMs) {
|
|
|
106
106
|
* Restore now always rebuilds from history (lazy, see startup.mjs).
|
|
107
107
|
* 2026-08-31 会诊 F1:slot 粘性——首次认领后缓存 agent._slot,永不重跑 ensureActive
|
|
108
108
|
* (原实现每次保存重推,manifest active 被并发方翻动时会话静默迁移)。
|
|
109
|
-
* 返回轮转的 .bak 路径或 null。
|
|
110
|
-
|
|
109
|
+
* 返回轮转的 .bak 路径或 null。
|
|
110
|
+
* **`opts.prefsSeedOnly`(加性 · 2026-10-04 解锁批 · R2——唯一传者 = 桌面两落盘点)**:回合关联落盘
|
|
111
|
+
* **不携会话级三键**(`provider` / `model` / `effort`)——写前读槽:复合在场(`activeProvider` 非空串 ∧
|
|
112
|
+
* `activeModel` 真值)⇒ 槽值赢 ∥ 缺 ⇒ 记忆值播种;`effort` 单键在场(值可 `null`)⇒ 槽值保留。 */
|
|
113
|
+
export function saveSession(agent, opts = {}) {
|
|
111
114
|
// _fullHistory is written at the source via pushReal — no flush needed here.
|
|
112
115
|
// history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
|
|
113
116
|
// contextHistory = machine context (possibly compacted) so CLI resume keeps the token savings
|
|
@@ -168,6 +171,14 @@ export function saveSession(agent) {
|
|
|
168
171
|
// 原值——`"off"` 记号原样写盘,不回填为具体档);缺之 ⇒ 下一次回合保存把槽写面结果整对象抹除。
|
|
169
172
|
// `undefined` = 本会话未携带(老槽无该键 / 未经 `applySession`)⇒ 键不落盘(禁回填)。
|
|
170
173
|
if (agent._slotEffort !== undefined) fields.effort = agent._slotEffort
|
|
174
|
+
// R2(2026-10-04 解锁批 · 加性 `prefsSeedOnly`):回合关联落盘**不携三键**——写前读槽(守卫后:轮转现场不参与判据),
|
|
175
|
+
// 复合在场(`activeProvider` 非空串 ∧ `activeModel` 真值)⇒ 槽值赢(防起跑快照覆写忙期新选定)∥ 缺 ⇒ 记忆值播种。
|
|
176
|
+
if (opts.prefsSeedOnly === true) {
|
|
177
|
+
const onDisk = loadSlotFile(agent.cwd, slot) ?? {}
|
|
178
|
+
const compositeOn = typeof onDisk.activeProvider === "string" && onDisk.activeProvider !== "" && Boolean(onDisk.activeModel)
|
|
179
|
+
if (compositeOn) Object.assign(fields, { activeProvider: onDisk.activeProvider, activeModel: onDisk.activeModel })
|
|
180
|
+
if (Object.hasOwn(onDisk, "effort")) fields.effort = onDisk.effort ?? null
|
|
181
|
+
}
|
|
171
182
|
// 绑定态 → 流式投影(段原文拼接——VSC 兼容面逐字同形);未绑定 → 既有全量物化写
|
|
172
183
|
if (agent._recordStore) saveProjectedSlot(agent, p, fields, contextHistory)
|
|
173
184
|
else writeSessionFile(p, { ...fields, history: legacyHistory(agent), contextHistory })
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream-destroy.mjs — response body 终止守卫(单一权威源——PROXY.md §2 ∥ §7 D-PX7)
|
|
3
|
+
*
|
|
4
|
+
* 无监听者瞬间的 `destroy(err)` 会 emit 未处理 'error' ⇒ uncaughtException ⇒ 整个进程被杀
|
|
5
|
+
* (2026-09-22/23 三份 crash-report 签名 = `Response body timeout (idle)`,栈 = proxy.mjs
|
|
6
|
+
* 看门狗;GitHub #16)。`destroyBody(body, err)` 在终止前先挂**永久** no-op `'error'`
|
|
7
|
+
* 监听者——pipe 内部监听者触发即自摘后的重发同样被兜住;消费面语义零变(在场 ∥ 迟到
|
|
8
|
+
* `for-await` 消费者仍收原错误;`stream.errored` 保留原错误对象)。
|
|
9
|
+
*
|
|
10
|
+
* 契约四则(PROXY.md §2):
|
|
11
|
+
* ① body 空 ∥ 非 destroy 形态(web `ReadableStream`)∥ 已 `destroyed` ⇒ 显式返回(no-op);
|
|
12
|
+
* ② 首次调用挂永久 no-op 'error' 监听者(一次性标记防重复挂);
|
|
13
|
+
* ③ 再 `destroy(err)`——原错误对象直传;
|
|
14
|
+
* ④ 禁 `listenerCount('error') === 0` 预检(pipe 监听者使计数失真——触发即自摘后重发)。
|
|
15
|
+
*
|
|
16
|
+
* 内部 abort 通道(2026-10-04 · #878 D-PX8):直连 fetch 的 body = web `ReadableStream`(无
|
|
17
|
+
* `destroy`——本档形态①的 no-op 面)。断流改经 `IDLE_ABORT` 符号挂载的 `AbortController`:
|
|
18
|
+
* `terminateBody(response, err)` 对挂通道者 `controller.abort(err)`(body 随之中止);无通道回落
|
|
19
|
+
* `destroyBody`(原契约零变)。建设点 = `proxy.mjs` `proxyFetch` 直连分支单点。
|
|
20
|
+
*
|
|
21
|
+
* 零 import——proxy ∥ provider 消费点共用(无环)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 一次性标记(模块私有 Symbol——不占 body 公开面)。 */
|
|
25
|
+
const GUARDED = Symbol("thincoder.destroyBody.guarded")
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 终止 body,且未处理 'error' 不逃逸到进程(契约四则见档头)。
|
|
29
|
+
* @param {import("node:stream").Stream} body — response body(PassThrough);
|
|
30
|
+
* web `ReadableStream`(无 destroy)⇒ 显式 no-op
|
|
31
|
+
* @param {Error} err — 原错误对象(直传 destroy)
|
|
32
|
+
*/
|
|
33
|
+
export function destroyBody(body, err) {
|
|
34
|
+
if (!body || typeof body.destroy !== "function" || body.destroyed) return
|
|
35
|
+
if (!body[GUARDED]) {
|
|
36
|
+
body[GUARDED] = true
|
|
37
|
+
body.on("error", () => {})
|
|
38
|
+
}
|
|
39
|
+
body.destroy(err)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 内部 abort 通道符号(#878 D-PX8——直连 fetch 响应的断流通道;module-private 语义面)。 */
|
|
43
|
+
export const IDLE_ABORT = Symbol("thincoder.idleAbort")
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* body 终止分流单点(#878):
|
|
47
|
+
* - response 挂有 `IDLE_ABORT`(直连 fetch——web `ReadableStream`)⇒ `controller.abort(err)`
|
|
48
|
+
* (读循环随之终结;原错误对象即 abort reason——undici 以该对象拒读);
|
|
49
|
+
* - 否则回落 `destroyBody(response.body, err)`(proxy PassThrough ∥ 无通道 web 流——原契约零变)。
|
|
50
|
+
* response 缺位 / 无 body ⇒ no-op 不抛(幂等——二次调用对 AbortController 原生安全)。
|
|
51
|
+
* @param {Response|{body?: import("node:stream").Stream}} response
|
|
52
|
+
* @param {Error} err — 原错误对象(abort reason ∥ destroy 直传)
|
|
53
|
+
*/
|
|
54
|
+
export function terminateBody(response, err) {
|
|
55
|
+
if (!response) return
|
|
56
|
+
const controller = response[IDLE_ABORT]
|
|
57
|
+
if (controller) {
|
|
58
|
+
controller.abort(err)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
destroyBody(response.body, err)
|
|
62
|
+
}
|
package/tool-docs/verify.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Generic pre-completion verification gate — language/framework/project agnostic. verify does NOT run any test/verification command for you and does NOT require a test per change. You DECLARE your verification status via the verification argument ({ status: 'passed' | 'failed' | 'skipped', command?, summary? } — effects per parameter): the declaration is self-reported evidence — verify only mechanically gates on it ('passed' → allowed; 'failed' → blocked; 'skipped' → allowed but needs a reason). Whether/how to verify is described in natural language in the project's AGENTS.md and is decided + executed by you. The report also carries the changed files (git diff), an advisory node --check hint on changed .js/.mjs (not gating), the task list, and a self-review checklist. Call this BEFORE declaring a coding task complete — do not say 'done' until verify passes.
|
|
1
|
+
Generic pre-completion verification gate — language/framework/project agnostic. verify does NOT run any test/verification command for you and does NOT require a test per change. You DECLARE your verification status via the verification argument ({ status: 'passed' | 'failed' | 'skipped', command?, summary?, basis? } — effects per parameter): the declaration is self-reported evidence — verify only mechanically gates on it ('passed' → allowed; 'failed' → blocked; 'skipped' → allowed but needs a reason). 'basis' (optional) is the 'what it stands on' dimension of the declaration — a judgment source, a read file:line coordinate, or an explicit 'unverified' (declaration-only: no format is enforced or machine-checked); a passed / skipped declaration with no basis gets one advisory hint line in the report — never blocking and never changing the gate — while the failed face gets no basis hint (already blocked). Whether/how to verify is described in natural language in the project's AGENTS.md and is decided + executed by you. The report also carries the changed files (git diff), an advisory node --check hint on changed .js/.mjs (not gating), the task list, and a self-review checklist. Call this BEFORE declaring a coding task complete — do not say 'done' until verify passes.
|
package/tools/edit-batch.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import { assertEditArgsExclusive, assertEditsContainer, assertEditEntries, valid
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Apply the `edits` array form: multi-file atomic replacement. Throws on any
|
|
26
|
-
* failure (atomic — nothing written). Returns the per-entry result text (joined)
|
|
26
|
+
* failure (atomic — nothing written). Returns the per-entry result text (joined)
|
|
27
|
+
* + the constant batch summary line at the end (#796 — EDIT.md §5).
|
|
27
28
|
*/
|
|
28
29
|
export async function applyEditBatch(args, ctx) {
|
|
29
30
|
assertEditsContainer(args.edits)
|
|
@@ -105,7 +106,11 @@ export async function applyEditBatch(args, ctx) {
|
|
|
105
106
|
note: p.note,
|
|
106
107
|
}))
|
|
107
108
|
}
|
|
108
|
-
|
|
109
|
+
// #796(核面小修批——EDIT.md §5 批量回执汇总行 ∥ §8 D-9):末行**恒为**汇总行——大回执
|
|
110
|
+
// (>64K)落盘后双端 preview 保尾(TOOL-OUTPUT-LIMITS.md §2)⇒ 汇总结论恒可见;N = 条目数
|
|
111
|
+
// (全判后原子写 ⇒ 成功恒 N/N);路径 = 条目原样形、去重保序首现序(groups 插入序即首现序)。
|
|
112
|
+
const summary = `edit batch: ${args.edits.length}/${args.edits.length} entries — ${[...groups.values()].map((g) => g.path).join(", ")}`
|
|
113
|
+
return [...results, summary].join("\n")
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
// ---------------------------------------------------------------------------
|
package/undo-stack.mjs
CHANGED
|
@@ -6,13 +6,23 @@
|
|
|
6
6
|
* 属核内面,函数本体零 TUI 依赖 ⇒ 按核内结构归位到核;`/undo` 命令面 `handleUndoCommand`
|
|
7
7
|
* 留 CLI 壳——S2 由壳 import 本档)。
|
|
8
8
|
*
|
|
9
|
+
* 双上界(#863 · TUI-COMMANDS.md §5.3):单条备份超 `MAX_UNDO_BYTES` ⇒ **oversize 占位**条目
|
|
10
|
+
* (`oversize: true` · `backup: null`——列表可见 · 不可回退;与「文件创建」态靠 `oversize` 字段分判,
|
|
11
|
+
* 消费面 = `cmd-undo.mjs` 先判该字段再判 `backup === null`);总量超 `MAX_UNDO_TOTAL_BYTES`
|
|
12
|
+
* ⇒ 逐最旧驱逐(条数上限 `MAX_UNDO` 保留)。
|
|
13
|
+
*
|
|
9
14
|
* Tracks write/edit/delete/hashline_edit/apply_patch operations in agent._undoStack.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
17
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
13
18
|
import { isAbsolute, join } from "node:path"
|
|
14
19
|
|
|
15
20
|
const MAX_UNDO = 50
|
|
21
|
+
const MAX_UNDO_BYTES = 10_000_000 // 10 MB — 单条备份上界(= tools/file.mjs 大档 read 守卫同值)
|
|
22
|
+
const MAX_UNDO_TOTAL_BYTES = 64_000_000 // 64 MB — 备份总预算(超限逐最旧驱逐)
|
|
23
|
+
|
|
24
|
+
/** 条目预算字节数(oversize 占位 ∥ 文件创建态(backup = null)不占预算)。 */
|
|
25
|
+
const backupBytes = (e) => (typeof e?.backup === "string" ? Buffer.byteLength(e.backup, "utf8") : 0)
|
|
16
26
|
|
|
17
27
|
/**
|
|
18
28
|
* Snapshot a file before a side-effect tool modifies it.
|
|
@@ -26,22 +36,26 @@ export function snapshotForUndo(agent, toolName, args, cwd) {
|
|
|
26
36
|
// 绝对路径直接用(path.join 对绝对段不重置——Windows 反斜杠路径也不按 "/" 切分);相对路径整段 join(cwd)。
|
|
27
37
|
const abs = isAbsolute(path) ? path : join(cwd, path)
|
|
28
38
|
let backup = null
|
|
39
|
+
let oversize = false
|
|
29
40
|
try {
|
|
30
41
|
if (existsSync(abs)) {
|
|
31
|
-
|
|
42
|
+
if (statSync(abs).size > MAX_UNDO_BYTES) oversize = true // 大档不入内存——占位条目(与「文件创建」态分判)
|
|
43
|
+
else backup = readFileSync(abs, "utf8")
|
|
32
44
|
}
|
|
33
45
|
} catch {
|
|
34
46
|
// can't read — maybe binary, skip
|
|
35
47
|
return
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
agent._undoStack.push(
|
|
39
|
-
tool: toolName,
|
|
40
|
-
path,
|
|
41
|
-
backup,
|
|
42
|
-
timestamp: Date.now(),
|
|
43
|
-
})
|
|
50
|
+
agent._undoStack.push(oversize
|
|
51
|
+
? { tool: toolName, path, backup: null, oversize: true, timestamp: Date.now() }
|
|
52
|
+
: { tool: toolName, path, backup, timestamp: Date.now() })
|
|
44
53
|
if (agent._undoStack.length > MAX_UNDO) agent._undoStack.shift()
|
|
54
|
+
// 总量上界:超限逐最旧驱逐(按字节预算计——条数上界保留)。
|
|
55
|
+
let total = agent._undoStack.reduce((n, e) => n + backupBytes(e), 0)
|
|
56
|
+
while (total > MAX_UNDO_TOTAL_BYTES && agent._undoStack.length > 0) {
|
|
57
|
+
total -= backupBytes(agent._undoStack.shift())
|
|
58
|
+
}
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
export { MAX_UNDO }
|
|
61
|
+
export { MAX_UNDO, MAX_UNDO_BYTES, MAX_UNDO_TOTAL_BYTES }
|