@thincoder/core 0.10.2 → 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 +33 -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 +30 -0
- 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/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.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
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.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 }
|