thincoder 0.12.54 → 0.12.59
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 +98 -0
- package/README.md +1 -1
- package/bin/thincoder.mjs +25 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +132 -26
- package/src/advisor/messages.mjs +38 -3
- package/src/advisor/run.mjs +91 -53
- package/src/advisor.mjs +15 -7
- package/src/agent/dispatch.mjs +156 -39
- package/src/agent/helpers.mjs +46 -4
- package/src/agent/setup.mjs +102 -19
- package/src/agent/spawn-child.mjs +28 -1
- package/src/agent-tools/advisor.mjs +43 -11
- package/src/agent-tools/consult.mjs +37 -6
- package/src/agent-tools/eng.mjs +4 -1
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +160 -0
- package/src/agent-tools/settings.mjs +162 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +432 -0
- package/src/agent-tools/subagent-async.mjs +427 -0
- package/src/agent-tools/subagent-scheduler.mjs +319 -0
- package/src/agent-tools/subagent.mjs +565 -128
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +161 -49
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +182 -81
- package/src/auto-think.mjs +14 -0
- package/src/cli/make-agent.mjs +27 -1
- package/src/cli/memory-command.mjs +28 -7
- package/src/cli/permission.mjs +8 -1
- package/src/config.mjs +125 -8
- package/src/context.mjs +115 -34
- package/src/distill.mjs +19 -1
- package/src/escape.mjs +82 -27
- package/src/log.mjs +195 -0
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/code-sync.mjs +1 -1
- package/src/memory/core.mjs +204 -10
- package/src/memory/docs.mjs +197 -62
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +38 -1
- package/src/prompts/advisor-design.md +46 -0
- package/src/prompts/advisor-round1.md +49 -2
- package/src/prompts/advisor-round2.md +47 -0
- package/src/prompts/advisor-round3.md +47 -0
- package/src/prompts/coder.md +22 -0
- package/src/prompts/consult-base.md +13 -0
- package/src/prompts/discipline.md +25 -6
- package/src/prompts/eng-coder.md +2 -2
- package/src/prompts/engineering-sub.md +23 -1
- package/src/prompts/engineering.md +157 -50
- package/src/prompts/explore.md +1 -2
- package/src/prompts/main.md +11 -5
- package/src/prompts/methodology-template.md +14 -0
- package/src/prompts/system.md +5 -2
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +104 -28
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session.mjs +15 -0
- package/src/tools/apply_patch.md +5 -1
- package/src/tools/bash.md +3 -3
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +92 -0
- package/src/tools/edit-diff.mjs +265 -0
- package/src/tools/edit.md +11 -6
- package/src/tools/execute.md +8 -8
- package/src/tools/execute.mjs +31 -35
- package/src/tools/file.mjs +26 -114
- package/src/tools/file_ops.md +3 -2
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -2
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +3 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/lsp.md +4 -1
- package/src/tools/patch.mjs +84 -13
- package/src/tools/pdf-parse-text.mjs +497 -0
- package/src/tools/pdf-parse-xref.mjs +499 -0
- package/src/tools/pdf.mjs +155 -0
- package/src/tools/question.md +2 -1
- package/src/tools/read.md +1 -0
- package/src/tools/read_pdf.md +21 -0
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +11 -32
- package/src/tools/system.mjs +6 -21
- package/src/tools/tree.md +2 -1
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +387 -24
- package/src/tui/clipboard.mjs +17 -6
- package/src/tui/cmd-config.mjs +29 -9
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +264 -114
- package/src/tui/cmd-think.mjs +1 -1
- package/src/tui/index.mjs +49 -95
- package/src/tui/interaction.mjs +41 -3
- package/src/tui/key-handler.mjs +105 -143
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +41 -1
- package/src/tui/pickers.mjs +73 -7
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +45 -20
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +165 -0
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +40 -2
- package/src/tui/subagent-blocks.mjs +404 -111
- package/src/tui/subagent-panel.mjs +88 -13
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-events.mjs +172 -95
- package/src/tui/update-notice.mjs +72 -0
- package/src/tui/wizard.mjs +36 -6
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/tools/exec-prelude.mjs +0 -84
package/src/provider/core.mjs
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* SSE parsing → provider/sse.mjs
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { providerSpec, resolveEnableThinking } from "../config.mjs"
|
|
8
8
|
import { proxyFetch } from "../proxy.mjs"
|
|
9
|
-
import { escapeMessages } from "../escape.mjs"
|
|
9
|
+
import { escapeMessages, stripLocalMessageFields } from "../escape.mjs"
|
|
10
|
+
import { logEvent, errText, classifyErr, headText } from "../log.mjs"
|
|
11
|
+
import { recordChatTrace } from "../traces/trace-store.mjs"
|
|
10
12
|
import { readSSE } from "./sse.mjs"
|
|
11
13
|
export { readSSE } from "./sse.mjs"
|
|
12
14
|
import {
|
|
@@ -15,10 +17,9 @@ import {
|
|
|
15
17
|
estimateRequestTokens, rateGate, recordRate,
|
|
16
18
|
} from "./rate.mjs"
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
// 2026-09-01:FETCH_TIMEOUT_MS 常量退役(绝对墙钟语义废除)——fetchTimeoutMs 现为每调用从 provider 读(config 归一化),见 effectiveFetchTimeoutMs。
|
|
19
21
|
|
|
20
|
-
/** 可中断 sleep
|
|
21
|
-
* 立即生效——原来最长睡 60s 无响应。内部走 _rateHooks.sleep(测试替换点)。 */
|
|
22
|
+
/** 可中断 sleep(会诊 #5):退避/Retry-After/overload 等待期 Ctrl+C 立即生效;内部走 _rateHooks.sleep(测试替换点) */
|
|
22
23
|
function abortDOM(signal) {
|
|
23
24
|
const e = new DOMException("The operation was aborted", "AbortError")
|
|
24
25
|
e.reason = signal.reason
|
|
@@ -61,11 +62,71 @@ export function createProvider(config) {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/** Send a streaming chat completion request with automatic continuation on truncation */
|
|
64
|
-
|
|
65
|
+
// 2026-09-01 根因修复:600s 绝对墙钟曾腰斩长上下文子代理(eng-coder TTFB>10min 即死)——TTFB 阶段改用
|
|
66
|
+
// fetchTimeoutMs(默认 600s,agent.fetchTimeoutMs 可配),body 阶段 idle 超时(FETCH_BODY_IDLE_MS,无新数据才断)。
|
|
67
|
+
const FETCH_BODY_IDLE_MS = 120_000
|
|
68
|
+
/** §14.2 设计值:prefix 续写只保留最近 8 条非工具文本(截断点语境足够,N 以测试锁定) */
|
|
69
|
+
const PREFIX_CONTINUATION_KEEP = 8
|
|
70
|
+
|
|
71
|
+
/** 2026-09-01:响应头阶段超时(默认 600s,agent.fetchTimeoutMs 可配)——anthropic/responses transport 共用 */
|
|
72
|
+
export function effectiveFetchTimeoutMs(provider) {
|
|
73
|
+
return Number.isFinite(provider?.fetchTimeoutMs) && provider.fetchTimeoutMs > 0 ? provider.fetchTimeoutMs : 600_000
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function chat(provider, opts = {}) {
|
|
77
|
+
// LOGGING(docs/design/LOGGING.md):llm:* 事件统一在此落点——所有 chat 调用
|
|
78
|
+
// (主回合/消化轮/compress/distill/advisor/子代理/auto-think/consult)都经本函数,
|
|
79
|
+
// 格式分派(anthropic/google/responses)在内部——单点覆盖即 llm:* 全覆盖。
|
|
80
|
+
// 续写/重试各自为独立 HTTP 请求——续写递归(下方 chatImpl 内)会再包一层(嵌套
|
|
81
|
+
// llm:start/done 对——每请求一事件);重试在 requestWithRetry 内部不可见。
|
|
82
|
+
// §18.6 完整轨迹存档(AGENT-LOOP.md §18.6 N-TR2——权威句 D-TR1):采集点唯一=
|
|
83
|
+
// 本函数出口——所有 chat 调用(主回合/消化轮/compress/distill/advisor/子代理/
|
|
84
|
+
// auto-think/consult)都经本函数;续写/重试在出口已合并——reasoning 全量才完整。
|
|
85
|
+
const logCtx = opts.logCtx ?? {}
|
|
86
|
+
const t0 = Date.now()
|
|
87
|
+
const pname = provider?.name ?? provider?.model ?? "unknown"
|
|
88
|
+
logEvent("llm:start", { provider: pname, model: provider?.model ?? "", stage: logCtx.stage, turn: logCtx.turn, auto: logCtx.auto === true, child: logCtx.child })
|
|
89
|
+
try {
|
|
90
|
+
const result = await chatImpl(provider, opts)
|
|
91
|
+
logEvent("llm:done", {
|
|
92
|
+
provider: pname, model: provider?.model ?? "",
|
|
93
|
+
ms: Date.now() - t0,
|
|
94
|
+
stage: logCtx.stage, turn: logCtx.turn, auto: logCtx.auto === true, child: logCtx.child,
|
|
95
|
+
head: headText(result?.content ?? "", 300, { paragraph: true }),
|
|
96
|
+
len: String(result?.content ?? "").length,
|
|
97
|
+
finish: result?.finishReason ?? null,
|
|
98
|
+
tools: Array.isArray(result?.toolCalls) ? result.toolCalls.length : 0,
|
|
99
|
+
})
|
|
100
|
+
// §18.6 D-TR1/D-TR5:出口收集——成功路径轨迹(含 content/reasoning 全文/toolCalls)
|
|
101
|
+
recordChatTrace(provider, opts, result, null)
|
|
102
|
+
return result
|
|
103
|
+
} catch (e) {
|
|
104
|
+
logEvent("llm:error", {
|
|
105
|
+
provider: pname, model: provider?.model ?? "",
|
|
106
|
+
ms: Date.now() - t0,
|
|
107
|
+
stage: logCtx.stage, turn: logCtx.turn, auto: logCtx.auto === true, child: logCtx.child,
|
|
108
|
+
err: errText(e, 200),
|
|
109
|
+
kind: classifyErr(e, opts.signal),
|
|
110
|
+
})
|
|
111
|
+
// §18.6 D-TR5:失败路径也落盘——error(errText 截断 + 类别)+ finishReason:null
|
|
112
|
+
recordChatTrace(provider, opts, null, e)
|
|
113
|
+
throw e
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** chat 本体(LOG(LLM) 事件包装之外——见上方 chat 包装器)。 */
|
|
118
|
+
async function chatImpl(provider, { messages, tools, onToken, onReasoning, onWait, signal, streamRules, firedPatterns, toolChoice, parallelToolCalls, logCtx }) {
|
|
65
119
|
// Sanitize BEFORE format dispatch — image poisoning bricks anthropic/google sessions
|
|
66
120
|
// the same way it bricks OpenAI-format ones (all raster-only).
|
|
67
|
-
|
|
121
|
+
// providerSpec: spec with the provider-level context override (PROVIDER.md §15) — the
|
|
122
|
+
// window/clamping logic below reads the overridden value where it matters.
|
|
123
|
+
const spec = providerSpec(provider)
|
|
68
124
|
messages = stripImagesForTextModel(messages, spec)
|
|
125
|
+
// SESSION.md §9 T-S3: local-only message fields (ts/transient) never reach the wire.
|
|
126
|
+
// Stripped BEFORE format dispatch — anthropic/responses transports pass whole message
|
|
127
|
+
// objects through verbatim (only the OpenAI path ran escapeMessages). Copy-on-write:
|
|
128
|
+
// history keeps the fields, the request never sees them.
|
|
129
|
+
messages = stripLocalMessageFields(messages)
|
|
69
130
|
const _debugBeforeLen = process.env.THIN_DEBUG_BODY ? JSON.stringify(messages).length : 0
|
|
70
131
|
|
|
71
132
|
// Format dispatch: delegate to non-OpenAI transports
|
|
@@ -190,24 +251,28 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
190
251
|
|
|
191
252
|
if (!spec.partialMode && !spec.prefixMode) return result
|
|
192
253
|
for (let n = 0; result.finishReason === "length" && result.content && n < MAX_CONTINUATIONS; n++) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
spec
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
254
|
+
let continued
|
|
255
|
+
try {
|
|
256
|
+
continued = await chat(spec.prefixMode ? { ...provider, baseURL: betaBaseURL(provider.baseURL) } : provider, {
|
|
257
|
+
messages: buildContinuationMessages(messages, result, spec),
|
|
258
|
+
tools,
|
|
259
|
+
onToken,
|
|
260
|
+
onReasoning,
|
|
261
|
+
onWait,
|
|
262
|
+
signal,
|
|
263
|
+
// §18.6:续写是同一逻辑调用的子请求——logCtx 原样透传(元数据与门控
|
|
264
|
+
// traces.enabled 对续写调用同样生效,不在出口静默越过开关)
|
|
265
|
+
// fix round1(D-TR1):续写子请求标记 isContinuation:true(T-TR14——true =
|
|
266
|
+
// 该调用是续写链的一环;外层新调用 false)——分析"纠结"时区分续写/重试链。
|
|
267
|
+
logCtx: { ...logCtx, isContinuation: true },
|
|
268
|
+
})
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// §14.3 失败可见性:续写失败注入 _warnings(agent 机读线可见)不整轮飞出;AbortError 用户中断透传
|
|
271
|
+
if (error?.name === "AbortError") throw error
|
|
272
|
+
result._warnings ??= []
|
|
273
|
+
result._warnings.push({ name: "continuation-failed", message: `output continuation failed: ${error.message}` })
|
|
274
|
+
break
|
|
275
|
+
}
|
|
211
276
|
result.content += continued.content
|
|
212
277
|
result.reasoning += continued.reasoning ?? ""
|
|
213
278
|
mergeRetryToolCalls(result, continued.toolCalls)
|
|
@@ -226,6 +291,14 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
226
291
|
return result
|
|
227
292
|
}
|
|
228
293
|
|
|
294
|
+
/** 续写消息构造(§14.3):prefix 精简历史(§14.2——deepseek /beta 网关对含工具链历史必 400,真机矩阵);partial 保持现状 */
|
|
295
|
+
export function buildContinuationMessages(messages, result, spec) {
|
|
296
|
+
const tail = (extra) => ({ role: "assistant", content: result.content, ...extra, ...(result.reasoning ? { reasoning_content: result.reasoning } : {}) })
|
|
297
|
+
if (!spec.prefixMode) return [...messages, tail({ partial: true })]
|
|
298
|
+
const slim = messages.filter((m) => m.role !== "tool" && !(m.role === "assistant" && m.tool_calls?.length))
|
|
299
|
+
return [...slim.filter((m) => m.role === "system"), ...slim.filter((m) => m.role !== "system").slice(-PREFIX_CONTINUATION_KEEP), tail({ prefix: true })]
|
|
300
|
+
}
|
|
301
|
+
|
|
229
302
|
/**
|
|
230
303
|
* Replace image parts with text placeholders when they would 400 the request:
|
|
231
304
|
* - the model has no vision support at all (history may carry image_url parts from a
|
|
@@ -347,11 +420,14 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
347
420
|
Authorization: `Bearer ${provider.apiKey}`,
|
|
348
421
|
},
|
|
349
422
|
body: JSON.stringify(body),
|
|
350
|
-
|
|
423
|
+
// 2026-09-01 根因修复:原 600s 绝对墙钟会腰斩长上下文子代理(TTFB/首 token >10min 即死)。
|
|
424
|
+
// 拆分语义:响应头阶段仍用 fetchTimeoutMs(600s,覆盖网关排队);body 阶段由读侧 idle 超时管
|
|
425
|
+
// (sse.mjs readIdleMs——无新数据才断)。signal 只保留用户取消链,不再叠加绝对墙钟。
|
|
426
|
+
signal,
|
|
351
427
|
// 2026-08-31 会诊 #4:代理路径响应头超时对齐直连语义(原 15s 与直连 600s 割裂,
|
|
352
428
|
// DeepSeek 排队 TTFB>15s 即误报)— 仅 _ 前缀内部字段,proxyFetch 消费
|
|
353
|
-
_headerTimeoutMs:
|
|
354
|
-
_bodyIdleMs:
|
|
429
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
430
|
+
_bodyIdleMs: FETCH_BODY_IDLE_MS,
|
|
355
431
|
}
|
|
356
432
|
response = provider.proxyUri
|
|
357
433
|
? await proxyFetch(url, opts, provider.proxyUri)
|
package/src/provider/google.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { proxyFetch } from "../proxy.mjs"
|
|
8
8
|
import { requestWithRetry } from "./retry.mjs"
|
|
9
|
+
import { effectiveFetchTimeoutMs } from "./core.mjs"
|
|
9
10
|
|
|
10
11
|
/** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
|
|
11
12
|
function mapFunctionCallingConfig(choice) {
|
|
@@ -100,7 +101,6 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
100
101
|
body.toolConfig = { functionCallingConfig: mapFunctionCallingConfig(toolChoice) }
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
const FETCH_TIMEOUT_MS = 600_000
|
|
104
104
|
// Gemini uses API key as query parameter
|
|
105
105
|
const url = `${provider.baseURL}/models/${provider.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(provider.apiKey)}`
|
|
106
106
|
|
|
@@ -118,10 +118,9 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
118
118
|
method: "POST",
|
|
119
119
|
headers: { "Content-Type": "application/json" },
|
|
120
120
|
body: JSON.stringify(body),
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
_headerTimeoutMs: FETCH_TIMEOUT_MS,
|
|
121
|
+
// 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
|
|
122
|
+
signal,
|
|
123
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
125
124
|
_bodyIdleMs: 120_000,
|
|
126
125
|
}, provider.proxyUri),
|
|
127
126
|
{ signal, onWait, buildMessage: (status, text) => `Gemini API error ${status}: ${text}` },
|
|
@@ -194,32 +193,66 @@ async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
|
194
193
|
}
|
|
195
194
|
|
|
196
195
|
if (!response.body) throw new Error("No stream response body")
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
196
|
+
// 2026-09-01 读侧 idle 超时(同 sse.mjs):body 有数据流动即不超时;连续 120s 无新 chunk 判死
|
|
197
|
+
const READ_IDLE_MS = 120_000
|
|
198
|
+
let idleTimer = null
|
|
199
|
+
const armIdle = () => {
|
|
200
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
201
|
+
idleTimer = setTimeout(() => {
|
|
202
|
+
try { response.body?.destroy(new Error(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`)) } catch { /* already gone */ }
|
|
203
|
+
}, READ_IDLE_MS)
|
|
204
|
+
idleTimer.unref?.()
|
|
205
|
+
}
|
|
206
|
+
armIdle()
|
|
207
|
+
try {
|
|
208
|
+
for await (const chunk of response.body) {
|
|
209
|
+
armIdle()
|
|
210
|
+
if (signal?.aborted) {
|
|
211
|
+
const e = new DOMException("Aborted", "AbortError")
|
|
212
|
+
e.reason = signal.reason
|
|
213
|
+
throw e
|
|
214
|
+
}
|
|
215
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
216
|
+
// BOM 剥除(会诊 #12):首个 chunk 可能带 \uFEFF,否则首个 data 事件静默丢失
|
|
217
|
+
if (buffer.charCodeAt(0) === 0xfeff) buffer = buffer.slice(1)
|
|
218
|
+
const lines = buffer.split("\n")
|
|
219
|
+
buffer = lines.pop()
|
|
208
220
|
|
|
209
|
-
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (!line.startsWith("data:")) continue
|
|
223
|
+
const data = line.slice(5).trim()
|
|
224
|
+
if (!data || data === "[DONE]") continue
|
|
225
|
+
processData(data)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
buffer += decoder.decode()
|
|
229
|
+
for (const line of buffer.split("\n")) {
|
|
210
230
|
if (!line.startsWith("data:")) continue
|
|
211
231
|
const data = line.slice(5).trim()
|
|
212
232
|
if (!data || data === "[DONE]") continue
|
|
213
233
|
processData(data)
|
|
214
234
|
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
235
|
+
} catch (e) {
|
|
236
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
237
|
+
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
238
|
+
result.interrupted = true
|
|
239
|
+
result.interruptMessage = signal.reason.message
|
|
240
|
+
return result
|
|
241
|
+
}
|
|
242
|
+
if (hasPartial(e)) {
|
|
243
|
+
result.partial = true
|
|
244
|
+
result.networkError = e.message ?? String(e)
|
|
245
|
+
return result
|
|
246
|
+
}
|
|
247
|
+
throw e
|
|
248
|
+
} finally {
|
|
249
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
222
250
|
}
|
|
223
251
|
|
|
224
252
|
return result
|
|
225
253
|
}
|
|
254
|
+
|
|
255
|
+
/** google.mjs 无 hasChoices 追踪——只有流中途死且已有内容才标 partial(同 sse.mjs 语义的简化版) */
|
|
256
|
+
function hasPartial(e) {
|
|
257
|
+
return /ECONNRESET|terminated|idle timeout|network/i.test(e?.message ?? "")
|
|
258
|
+
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* pure functions sanitize the message array right before it hits the wire;
|
|
6
6
|
* no dependency on chat()/retry logic. core.mjs re-exports them so
|
|
7
7
|
* provider/index.mjs and tool-pairing.test.mjs keep their import paths.
|
|
8
|
+
* The caller passes the spec (providerSpec from core.mjs — provider-aware).
|
|
8
9
|
*/
|
|
9
|
-
import { specForModel } from "../config.mjs"
|
|
10
10
|
|
|
11
11
|
const RASTER_IMAGE_URL = /^data:image\/(png|jpe?g|gif|webp);base64,/
|
|
12
12
|
|
package/src/provider/rate.mjs
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
* Sliding-window accounting; pre-check budget before sending requests; sleep until window frees space when over budget.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { specForModel } from "../config.mjs"
|
|
7
|
-
|
|
8
6
|
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
9
7
|
export const MAX_RETRIES = 3
|
|
10
8
|
export const MAX_CONTINUATIONS = 3
|
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* 没有 "data: [DONE]"。
|
|
13
13
|
*/
|
|
14
14
|
import { specForModel, isBailianHost } from "../config.mjs"
|
|
15
|
+
import { effectiveFetchTimeoutMs } from "./core.mjs"
|
|
15
16
|
import { proxyFetch } from "../proxy.mjs"
|
|
16
17
|
import { requestWithRetry } from "./retry.mjs"
|
|
17
18
|
import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// 2026-09-01:FETCH_TIMEOUT_MS 常量退役(绝对墙钟废除)——经 core.mjs effectiveFetchTimeoutMs 共用
|
|
20
21
|
|
|
21
22
|
/** 白名单:已实证 previous_response_id 的官方端(2026-08-31 真机验证:
|
|
22
23
|
* 百炼 store:true 全链路 ✅;GLM(open.bigmodel.cn/api/v1)store:true 全链路 ✅)。 */
|
|
@@ -430,10 +431,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
430
431
|
method: "POST",
|
|
431
432
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
|
|
432
433
|
body: JSON.stringify(body),
|
|
433
|
-
signal
|
|
434
|
-
|
|
435
|
-
: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
436
|
-
_headerTimeoutMs: FETCH_TIMEOUT_MS,
|
|
434
|
+
signal,
|
|
435
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
437
436
|
_bodyIdleMs: 120_000,
|
|
438
437
|
}, provider.proxyUri),
|
|
439
438
|
{ signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
|
|
@@ -451,10 +450,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
451
450
|
method: "POST",
|
|
452
451
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
|
|
453
452
|
body: JSON.stringify(fullBody),
|
|
454
|
-
signal
|
|
455
|
-
|
|
456
|
-
: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
457
|
-
_headerTimeoutMs: FETCH_TIMEOUT_MS,
|
|
453
|
+
signal,
|
|
454
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
458
455
|
_bodyIdleMs: 120_000,
|
|
459
456
|
}, provider.proxyUri),
|
|
460
457
|
{ signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
|
|
@@ -471,10 +468,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
471
468
|
method: "POST",
|
|
472
469
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
|
|
473
470
|
body: JSON.stringify(fresh2.body),
|
|
474
|
-
signal
|
|
475
|
-
|
|
476
|
-
: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
477
|
-
_headerTimeoutMs: FETCH_TIMEOUT_MS,
|
|
471
|
+
signal,
|
|
472
|
+
_headerTimeoutMs: effectiveFetchTimeoutMs(provider),
|
|
478
473
|
_bodyIdleMs: 120_000,
|
|
479
474
|
}, provider.proxyUri),
|
|
480
475
|
{ signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
|
package/src/provider/sse.mjs
CHANGED
|
@@ -165,8 +165,23 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
if (!response.body) throw new Error("No stream response body")
|
|
168
|
+
// 2026-09-01 读侧 idle 超时(根因修复):原实现靠 fetch 层的 600s 绝对墙钟兜底——长上下文子代理
|
|
169
|
+
// 单次生成(或上游排队)超 10 分钟即被腰斩("The operation was aborted due to timeout" 直透)。
|
|
170
|
+
// 现改为:读侧空闲超时——body 只要有数据流动就永不超时,连续 READ_IDLE_MS 无新 chunk 才判死。
|
|
171
|
+
// 直连 fetch 与 proxyFetch 统一走这里(proxy 的 _bodyIdleMs 语义与之等价,双保险)。
|
|
172
|
+
const READ_IDLE_MS = 120_000
|
|
173
|
+
let idleTimer = null
|
|
174
|
+
const armIdle = () => {
|
|
175
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
176
|
+
idleTimer = setTimeout(() => {
|
|
177
|
+
try { response.body?.destroy(new Error(`SSE idle timeout: no data for ${READ_IDLE_MS / 1000}s`)) } catch { /* already gone */ }
|
|
178
|
+
}, READ_IDLE_MS)
|
|
179
|
+
idleTimer.unref?.()
|
|
180
|
+
}
|
|
181
|
+
armIdle()
|
|
168
182
|
try {
|
|
169
183
|
for await (const chunk of response.body) {
|
|
184
|
+
armIdle()
|
|
170
185
|
if (signal?.aborted) {
|
|
171
186
|
const e = new DOMException("The operation was aborted", "AbortError")
|
|
172
187
|
e.reason = signal.reason
|
|
@@ -189,6 +204,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
189
204
|
result.ruleTriggered = true
|
|
190
205
|
result.ruleMessage = rule.message
|
|
191
206
|
result.ruleName = rule.name
|
|
207
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
192
208
|
return result
|
|
193
209
|
}
|
|
194
210
|
const existing = result._warnings ??= []
|
|
@@ -202,9 +218,11 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
202
218
|
buffer += decoder.decode()
|
|
203
219
|
processLines(buffer.split("\n"))
|
|
204
220
|
} catch (e) {
|
|
221
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
205
222
|
if (e.name === "AbortError" && signal?.reason?.interrupt) {
|
|
206
223
|
result.interrupted = true
|
|
207
224
|
result.interruptMessage = signal.reason.message
|
|
225
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
208
226
|
return result
|
|
209
227
|
}
|
|
210
228
|
// 2026-08-31 会诊 #2(流中断丢全部已收内容):网络级失败(ECONNRESET / 半截 EOF /
|
|
@@ -223,6 +241,8 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
|
|
|
223
241
|
throw e
|
|
224
242
|
}
|
|
225
243
|
|
|
244
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
245
|
+
|
|
226
246
|
if (!hasChoices) {
|
|
227
247
|
// Stream started as SSE but no choices were parsed — unusual. Include status for debugging.
|
|
228
248
|
const raw = buffer.trim()
|
package/src/session.mjs
CHANGED
|
@@ -116,6 +116,12 @@ export function saveSession(agent) {
|
|
|
116
116
|
autoApprove: agent.autoApprove ?? false,
|
|
117
117
|
engineering: agent.config?.agent?.engineering ?? false,
|
|
118
118
|
engDesignToken: agent._engDesignToken ?? null,
|
|
119
|
+
// Multi-design slots ride the same round-trip (2026-09-01 audit #1): Map → {designId: token}
|
|
120
|
+
// (JSON-safe). Empty/absent Map → undefined → the key is dropped by JSON.stringify, so a
|
|
121
|
+
// cleared session writes NO field instead of resurrecting slots from the previous save.
|
|
122
|
+
engDesignTokens: agent._engDesignTokens instanceof Map && agent._engDesignTokens.size > 0
|
|
123
|
+
? Object.fromEntries(agent._engDesignTokens)
|
|
124
|
+
: undefined,
|
|
119
125
|
goal: agent.goal ?? null,
|
|
120
126
|
advisor: agent.config?.advisor ?? null,
|
|
121
127
|
pendingReminders: agent._pendingReminders ?? [],
|
|
@@ -315,6 +321,14 @@ export function applySession(agent, data) {
|
|
|
315
321
|
agent._pendingReminders = data.pendingReminders ?? []
|
|
316
322
|
agent._sessionStart = data.sessionStart ?? null
|
|
317
323
|
agent._engDesignToken = data.engDesignToken ?? null
|
|
324
|
+
// Multi-design slots restore from the {designId: token} object (2026-09-01 audit #1). A legacy
|
|
325
|
+
// slot without the field restores NO Map (fresh state) — never resurrect slots the writer did
|
|
326
|
+
// not have. Expired tokens are rejected downstream by validateDesignToken (fail-closed, TTL).
|
|
327
|
+
if (data.engDesignTokens && typeof data.engDesignTokens === "object" && !Array.isArray(data.engDesignTokens)) {
|
|
328
|
+
agent._engDesignTokens = new Map(Object.entries(data.engDesignTokens))
|
|
329
|
+
} else {
|
|
330
|
+
delete agent._engDesignTokens
|
|
331
|
+
}
|
|
318
332
|
// engineering is session-level (2026-08-29): the slot value is the CLI session's authority
|
|
319
333
|
// — config.json is only the initial default / cross-end mirror. A legacy slot without the
|
|
320
334
|
// field keeps whatever config.json seeded (unchanged behavior).
|
|
@@ -429,6 +443,7 @@ export function resetSessionState(agent) {
|
|
|
429
443
|
agent.tasks = []
|
|
430
444
|
agent._sessionStart = null
|
|
431
445
|
agent._engDesignToken = null
|
|
446
|
+
agent._engDesignTokens = new Map() // multi-design slots die with the session (2026-09-01 fix #2)
|
|
432
447
|
agent._compressFailures = 0
|
|
433
448
|
agent._verifyRetries = 0
|
|
434
449
|
agent._verifyPassed = undefined
|
package/src/tools/apply_patch.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
Apply a unified diff to one or more files, atomically: if any hunk fails to apply, nothing is written.
|
|
2
2
|
|
|
3
|
+
**Use it for whole-file and multi-file changes:** creating MULTIPLE new files at once (`--- /dev/null` header per file), whole-file replacement, and cross-file refactors — one unified-diff call covers the whole change. A batched call is one permission ask and one turn instead of N separate calls.
|
|
4
|
+
|
|
3
5
|
Parameters:
|
|
4
|
-
- patch (required): Unified diff text. One `--- a/path` / `+++ b/path` header pair per file, then `@@ -old,count +new,count @@` hunks. Use `--- /dev/null` to create a new file.
|
|
6
|
+
- patch (required): Unified diff text. One `--- a/path` / `+++ b/path` header pair per file, then `@@ -old,count +new,count @@` hunks. Use `--- /dev/null` to create a new file. The `+++ b/path` pair may be omitted for existing files — a lone `--- a/path` (or `--- b/path`) header followed directly by hunks applies to that path (new files still need `--- /dev/null` + `+++ b/path`).
|
|
5
7
|
|
|
6
8
|
Notes:
|
|
9
|
+
- Hunk header "@@" without coordinates is accepted. Coordinate-less hunks are located by their anchor lines: context lines plus the removed (-) lines, matched as a contiguous sequence — a unique match applies. The anchor-free forms require context: a hunk with no removed (-) lines (pure additions) needs at least 2 context lines for a unique match; a zero/one-context hunk with at least one removed (-) line is located by its anchor sequence (context + removed lines, in order) and applies on a unique match.
|
|
10
|
+
- Returns the applied-file summary (`Applied patch to N file(s)` plus per-file change lines and syntax-check notes for changed .mjs files)
|
|
7
11
|
- Use this for multi-file changes (e.g. rename an interface + update all callers) — one call, all-or-nothing
|
|
8
12
|
- Hunks are located by their context/removed lines, not line numbers — but the context must match the file EXACTLY. Read the files first and generate the patch from actual content
|
|
9
13
|
- If a hunk's context matches multiple locations it is rejected — add more surrounding context lines
|
package/src/tools/bash.md
CHANGED
|
@@ -5,7 +5,7 @@ Execute a shell command and return stdout+stderr. Use for running commands, buil
|
|
|
5
5
|
- `ls` / `dir` → `ls`
|
|
6
6
|
- `find` / glob search → `glob`
|
|
7
7
|
- `grep` / `rg` → `grep`
|
|
8
|
-
- `echo >` / `sed` / `printf >` / `cat << EOF` → `write` / `edit` / `hashline_edit` / `apply_patch` (
|
|
8
|
+
- `echo >` / `sed` / `printf >` / `cat << EOF` → `write` / `edit` / `hashline_edit` / `apply_patch` (preferred: write tools handle encoding/EOL correctly)
|
|
9
9
|
- `git diff` / `git status` / `git log` → `git` tool
|
|
10
10
|
|
|
11
11
|
Parameters:
|
|
@@ -33,5 +33,5 @@ Notes:
|
|
|
33
33
|
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
34
34
|
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
35
35
|
- After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
|
|
36
|
-
- Prefer read/glob/grep/ls for file operations inside the project — bash
|
|
37
|
-
- NEVER use bash to write or modify files (echo/sed/printf > file, cat << EOF, etc.). Use write/edit/insert_after/apply_patch instead — they handle encoding, escaping, and
|
|
36
|
+
- Prefer read/glob/grep/ls for file operations inside the project — file tools and bash reach the same paths (no directory restriction).
|
|
37
|
+
- NEVER use bash to write or modify files (echo/sed/printf > file, cat << EOF, etc.). Use write/edit/insert_after/apply_patch instead — they handle encoding, escaping, and EOL conventions correctly.
|
package/src/tools/delete.md
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit-batch.mjs — edit 工具的数组形态(edits: [...]):一次多文件原子替换。
|
|
3
|
+
* (2026-08-31 工具顺手度 §9 ②;2026-09-01 缺陷修复"同文件多条串行累积";
|
|
4
|
+
* 2026-09-04 TOOLS.md §15 D15.1——条目级判定+应用迁至 edit-diff.mjs——批量调共用。)
|
|
5
|
+
*
|
|
6
|
+
* 语义:同一 path 的多条编辑按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的
|
|
7
|
+
* 累积内容做匹配与替换;跨 path 条目互不影响(并行原子语义);任一条失败 →
|
|
8
|
+
* 全不写(原子性保留)。每条目独立按判定序(§15.2 分支 0 单行替换 / 零重叠→插入 / LCS /
|
|
9
|
+
* 空 new 显式报错)。顶层 path(args.path)为无自带 path 条目的默认(2026-09-05 用户裁定
|
|
10
|
+
* ——条目自带 path 优先——见 TOOLS.md D15.3#9 修订注)。
|
|
11
|
+
*/
|
|
12
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
13
|
+
import { resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck } from "./shared.mjs"
|
|
14
|
+
// file.mjs ↔ edit-batch.mjs 循环引用:两侧导入的都是函数声明(提升初始化),
|
|
15
|
+
// 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
|
|
16
|
+
import { recordWrite, appendWriteContext } from "./file.mjs"
|
|
17
|
+
// TOOLS.md §15 D15.1:批量条目判定+应用共用 edit-diff(§15.2 分支 0 单行替换 + 行级 LCS——零重叠→插入);
|
|
18
|
+
// D15.3#9:edits 互斥错误文本随前置校验分支迁出至 edit-diff.mjs。
|
|
19
|
+
import { assertEditArgsExclusive, validateEditEntry, computeEditEntry } from "./edit-diff.mjs"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Apply the `edits` array form: multi-file atomic replacement. Throws on any
|
|
23
|
+
* failure (atomic — nothing written). Returns the per-entry result text (joined).
|
|
24
|
+
*/
|
|
25
|
+
export async function applyEditBatch(args, ctx) {
|
|
26
|
+
if (!Array.isArray(args.edits) || args.edits.length === 0) {
|
|
27
|
+
throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
|
|
28
|
+
}
|
|
29
|
+
assertEditArgsExclusive(args)
|
|
30
|
+
// 原子:先全量检查(所有文件的替换都可执行)——任一失败全不写。
|
|
31
|
+
// 2026-09-01 缺陷修复(TOOLS.md §9 ②"同文件多条规则"):同一 path 的多条编辑
|
|
32
|
+
// 按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的累积内容做匹配与替换
|
|
33
|
+
// (原实现每条都基于盘上原始内容计算、写盘循环后置,同文件后者覆盖前者 →
|
|
34
|
+
// 除最后一条外全部静默丢失);跨 path 条目互不影响(并行原子语义不变)。
|
|
35
|
+
const groups = new Map() // abs → 每文件一条流水线
|
|
36
|
+
for (const e of args.edits) {
|
|
37
|
+
// 2026-09-05 用户裁定:顶层 path + edits 并存合法化——`e.path || args.path`(条目优先;
|
|
38
|
+
// 缺省入口仅在此兜底);两者皆无 → 原错误文本(措辞补顶层选项)。
|
|
39
|
+
const p = e.path || args.path
|
|
40
|
+
if (!p) throw new Error("each edit must have a path — give each entry its own path or pass a top-level path")
|
|
41
|
+
// 前置校验(空 old / 非字符串 new——文本同 edit-diff 批量形态;读盘前校验)
|
|
42
|
+
validateEditEntry(e, { label: `edit for ${p}: `, rich: false })
|
|
43
|
+
const abs = resolveInCwd(ctx, p)
|
|
44
|
+
let g = groups.get(abs)
|
|
45
|
+
if (!g) {
|
|
46
|
+
const raw = await readFile(abs, "utf8")
|
|
47
|
+
g = { abs, path: p, raw, content: normalizeEOL(raw), edits: [] }
|
|
48
|
+
groups.set(abs, g)
|
|
49
|
+
}
|
|
50
|
+
g.edits.push(e)
|
|
51
|
+
}
|
|
52
|
+
const prepared = [] // 每条目一条回显;同文件内按 args 序,跨文件按首次出现分组序(应用/回显语义均正确)
|
|
53
|
+
for (const g of groups.values()) {
|
|
54
|
+
g.netShift = 0 // 组内行数差累积(合并快照的 shift = 全组净漂移)
|
|
55
|
+
for (const e of g.edits) {
|
|
56
|
+
// 条目级判定+应用(edit-diff——§15.2 分支 0 单行替换 + 判定序 1/2/3 + 空 new 显式报错 + >1000 行报错)
|
|
57
|
+
const out = computeEditEntry(g.content, e, {
|
|
58
|
+
path: g.path,
|
|
59
|
+
absPath: g.abs,
|
|
60
|
+
abortPrefix: "edit aborted (atomic — no files written): ",
|
|
61
|
+
})
|
|
62
|
+
prepared.push({
|
|
63
|
+
g,
|
|
64
|
+
editStartLine: out.editStartLine, // 基于累积内容计算——已天然计入前面条目的行偏移,不再累加
|
|
65
|
+
lineShift: out.lineShift,
|
|
66
|
+
occurrences: out.occurrences,
|
|
67
|
+
note: out.note ?? null, // P15.11——空白差异自动落点标记(成功消息追加)
|
|
68
|
+
})
|
|
69
|
+
g.content = out.updated // 串行累积:下一条基于本条应用后的内容
|
|
70
|
+
g.netShift += out.lineShift
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 全部检查通过——每文件一次写盘(同文件多条:写入串行累积后的最终内容);
|
|
74
|
+
// recordWrite 每组一条合并快照:startLine = 组内**所有**编辑受影响行的最小值
|
|
75
|
+
// (#2,2026-09-01 交付评审尾巴——原实现取首条 = 调用序第一条,逆序条目时
|
|
76
|
+
// 护栏下界过高、受影响区内的 insert_after 被放行),shift = 全组行数差累积
|
|
77
|
+
// ——受影响区护栏覆盖组内所有编辑
|
|
78
|
+
for (const g of groups.values()) {
|
|
79
|
+
const startLine = Math.min(...prepared.filter((p) => p.g === g).map((p) => p.editStartLine))
|
|
80
|
+
await writeFile(g.abs, joinWithEol(normalizeEOL(g.content).split("\n"), g.raw), "utf8")
|
|
81
|
+
recordWrite(g.abs, { type: "edit", startLine, shift: g.netShift })
|
|
82
|
+
}
|
|
83
|
+
const results = []
|
|
84
|
+
for (const p of prepared) {
|
|
85
|
+
// #4(2026-09-01 交付评审尾巴):与单文件路径对齐——每条结果附 git diff +
|
|
86
|
+
// autoSyntaxCheck(同文件多条会重复 diff/检查,换取格式一致、实现零分支)
|
|
87
|
+
const diff = gitDiffOne(ctx.cwd, p.g.abs)
|
|
88
|
+
const base = `Edited ${p.g.path}: replaced ${p.occurrences} occurrence(s)${p.note ? ` — ${p.note}` : ""}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(p.g.abs)}`
|
|
89
|
+
results.push(await appendWriteContext(p.g.abs, p.editStartLine, base))
|
|
90
|
+
}
|
|
91
|
+
return results.join("\n")
|
|
92
|
+
}
|