thincoder 0.12.61 → 0.12.62
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 +25 -1
- package/bin/thincoder.mjs +12 -3
- package/package.json +1 -1
- package/src/acp.mjs +13 -5
- package/src/agent/run-stages.mjs +3 -1
- package/src/agent/spawn-child.mjs +17 -2
- package/src/agent-tools/async-settle.mjs +13 -0
- package/src/agent-tools/consult.mjs +5 -0
- package/src/agent-tools/escalate-async.mjs +6 -0
- package/src/agent-tools/read-history.mjs +18 -3
- package/src/agent-tools/subagent-actions.mjs +3 -0
- package/src/agent-tools/subagent-run.mjs +1 -1
- package/src/agent-tools/subagent-spawn.mjs +6 -0
- package/src/agent.mjs +4 -0
- package/src/context.mjs +12 -1
- package/src/crash-reports.mjs +31 -9
- package/src/generate-title.mjs +8 -3
- package/src/heap-watch.mjs +88 -0
- package/src/ledger.mjs +227 -0
- package/src/memory/code-sync.mjs +7 -5
- package/src/memory/core.mjs +8 -9
- package/src/memory/docs.mjs +7 -5
- package/src/memory/scan.mjs +95 -0
- package/src/prompts/advisor-design.md +1 -1
- package/src/prompts/advisor-round1.md +1 -1
- package/src/prompts/advisor-round2.md +1 -1
- package/src/prompts/advisor-round3.md +1 -1
- package/src/prompts/discipline-engineering.md +51 -10
- package/src/prompts/discipline-normal.md +10 -4
- package/src/prompts/persona-eng-designer.md +6 -1
- package/src/prompts/persona-engineering.md +1 -0
- package/src/session-gc.mjs +9 -2
- package/src/session-guard.mjs +12 -0
- package/src/session-segments.mjs +100 -0
- package/src/session-slots.mjs +3 -0
- package/src/session-store.mjs +441 -0
- package/src/session.mjs +78 -61
- package/src/text-budget.mjs +46 -0
- package/src/traces/trace-store.mjs +195 -64
- package/src/tui/cmd-clear.mjs +2 -0
- package/src/tui/cmd-new.mjs +5 -1
- package/src/tui/cmd-session.mjs +8 -4
- package/src/tui/display-budget.mjs +184 -0
- package/src/tui/index.mjs +31 -3
- package/src/tui/key-handler-search.mjs +9 -1
- package/src/tui/ledger-surface.mjs +69 -0
- package/src/tui/render-frame.mjs +7 -1
- package/src/tui/startup.mjs +45 -13
- package/src/tui/subagent-blocks.mjs +1 -0
- package/src/tui/subagent-children.mjs +86 -14
- package/src/tui/subagent-freeze.mjs +8 -2
- package/src/tui/suspension-drive.mjs +3 -1
- package/src/tui/tool-args.mjs +5 -2
- package/src/tui/tool-display.mjs +16 -2
- package/src/tui/tool-events.mjs +53 -14
- package/src/tui/tui-lifecycle.mjs +9 -2
- package/src/tui/wrapped-spawn.mjs +21 -5
|
@@ -15,9 +15,26 @@
|
|
|
15
15
|
* trim = 单载体最旧先行(原树级 trim「子块先丢 / done 豁免」随子块内容层退役)。
|
|
16
16
|
* 省略计数真值(N6——D-ST5):`…(已省略 N 行)` 的 N 只随内容移除增长;标记自身
|
|
17
17
|
* 不占额度、不被丢弃、不计入 N。
|
|
18
|
+
*
|
|
19
|
+
* TUI-OOM-ROOTCAUSE(TUI.md §15.1 事实 3 / §15.3.3):原环按 `\n` 计数——无换行巨 chunk
|
|
20
|
+
* 净增 0 行 → 环永不触发、文本无界。本模块加**字符维**(第二维——行数维逐字不动,D-TB5):
|
|
21
|
+
* `carrier._charCount` 与 `SUB_BLOCK_CHAR_LIMIT` 双维同时满足(先到先裁);字符维裁剪
|
|
22
|
+
* 按被裁文本行数折算 N(无幽灵计数——N6)。
|
|
18
23
|
*/
|
|
24
|
+
import { SUB_BLOCK_CHAR_LIMIT, MIDDLE_TRUNC_MARKER, capText } from "./display-budget.mjs"
|
|
25
|
+
|
|
19
26
|
export const SUB_BLOCK_LINE_LIMIT = 500
|
|
20
27
|
|
|
28
|
+
/** 单块自身额度(TUI-OOM-ROOTCAUSE §15.1 事实 3:无换行巨 chunk —— 行数维净增 0、
|
|
29
|
+
* 环永不触发;入块文本先过本条(头 3/4 + 尾 1/4 + 中段标记)——单行内容不因字符维
|
|
30
|
+
* 裁剪被整行丢光(环总量裁剪仍按最旧先行)。 */
|
|
31
|
+
const SUB_BLOCK_CAP_OPTS = {
|
|
32
|
+
max: SUB_BLOCK_CHAR_LIMIT,
|
|
33
|
+
keepHead: 90_000,
|
|
34
|
+
keepTail: 30_000,
|
|
35
|
+
marker: MIDDLE_TRUNC_MARKER,
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
/** 显示行计数(N6 口径):块尾 `\n` 的空元素不计(行结束符非空行)。 */
|
|
22
39
|
const countBlockLines = (text) => {
|
|
23
40
|
const lines = String(text ?? "").split("\n")
|
|
@@ -27,11 +44,42 @@ const countBlockLines = (text) => {
|
|
|
27
44
|
/** 省略标记块判定(trim 产物——不占额度/不可被丢;N6)。 */
|
|
28
45
|
const isTrimMarker = (b) => b?.kind === "meta" && b._trimMarker === true
|
|
29
46
|
|
|
47
|
+
/** 省略标记幂等更新(不占额度/不计入字符账——N6)。 */
|
|
48
|
+
function updateTrimMarker(carrier) {
|
|
49
|
+
const marker = `…(已省略 ${carrier.dropped} 行)`
|
|
50
|
+
const head = carrier.blocks[0]
|
|
51
|
+
if (head && isTrimMarker(head)) head.text = marker // 既有标记原位更新(不重复 unshift)
|
|
52
|
+
else carrier.blocks.unshift({ kind: "meta", text: marker, _trimMarker: true })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 单行丢弃(最旧内容块首行——字符维/行维共用):返回释放字符数(行级元数据同步)。 */
|
|
56
|
+
function dropFirstContentLine(carrier) {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const idx = carrier.blocks.findIndex((b) => !isTrimMarker(b))
|
|
59
|
+
if (idx === -1) return 0 // 仅余省略标记(内容耗尽)——不再产生幽灵行
|
|
60
|
+
const first = carrier.blocks[idx]
|
|
61
|
+
const before = countBlockLines(first.text)
|
|
62
|
+
if (before <= 0) { carrier.blocks.splice(idx, 1); continue }
|
|
63
|
+
const parts = String(first.text).split("\n")
|
|
64
|
+
const droppedLine = parts.shift() ?? ""
|
|
65
|
+
const kept = parts.join("\n")
|
|
66
|
+
if (kept === "") carrier.blocks.splice(idx, 1)
|
|
67
|
+
else first.text = kept
|
|
68
|
+
const lineDelta = before - (kept === "" ? 0 : countBlockLines(kept))
|
|
69
|
+
carrier._lineCount = Math.max(0, (carrier._lineCount ?? 0) - lineDelta)
|
|
70
|
+
carrier.dropped = (carrier.dropped ?? 0) + lineDelta
|
|
71
|
+
// 释放字符数 = 本行正文 + 行分隔符(parts.join("\n") 同步移除——代码评审 #6:少记 1
|
|
72
|
+
// 字符会使 _charCount 偏高、触发早裁)
|
|
73
|
+
return droppedLine.length + (kept === "" ? 0 : 1)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
/** 载体级丢行(D-ST4 单环最旧先行):从头部内容块 FIFO 丢行;省略标记跳过(不占额度/
|
|
31
|
-
* 不被丢弃/不计入 N——N6)。返回实际丢弃行数(= N
|
|
78
|
+
* 不被丢弃/不计入 N——N6)。返回实际丢弃行数(= N 增量——无幽灵行)。字符账同步扣减。 */
|
|
32
79
|
function dropCarrierLines(carrier, want) {
|
|
33
80
|
if (want <= 0) return 0
|
|
34
81
|
let dropped = 0
|
|
82
|
+
let freedChars = 0
|
|
35
83
|
while (want > dropped) {
|
|
36
84
|
const idx = carrier.blocks.findIndex((b) => !isTrimMarker(b))
|
|
37
85
|
if (idx === -1) break // 仅余省略标记(内容耗尽)——不再产生幽灵行
|
|
@@ -42,39 +90,63 @@ function dropCarrierLines(carrier, want) {
|
|
|
42
90
|
if (take >= lines) {
|
|
43
91
|
carrier.blocks.splice(idx, 1)
|
|
44
92
|
dropped += lines
|
|
93
|
+
freedChars += String(first.text).length
|
|
45
94
|
} else {
|
|
46
|
-
|
|
95
|
+
const kept = String(first.text).split("\n").slice(take).join("\n")
|
|
96
|
+
freedChars += String(first.text).length - kept.length
|
|
97
|
+
first.text = kept
|
|
47
98
|
dropped += take
|
|
48
99
|
}
|
|
49
100
|
}
|
|
50
101
|
carrier._lineCount = Math.max(0, (carrier._lineCount ?? 0) - dropped)
|
|
51
102
|
carrier.dropped = (carrier.dropped ?? 0) + dropped
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const head = carrier.blocks[0]
|
|
55
|
-
if (head && isTrimMarker(head)) head.text = marker // 既有标记原位更新(不重复 unshift)
|
|
56
|
-
else carrier.blocks.unshift({ kind: "meta", text: marker, _trimMarker: true }) // 标记不占额度(N6)
|
|
57
|
-
}
|
|
103
|
+
carrier._charCount = Math.max(0, (carrier._charCount ?? 0) - freedChars)
|
|
104
|
+
if (dropped > 0) updateTrimMarker(carrier)
|
|
58
105
|
return dropped
|
|
59
106
|
}
|
|
60
107
|
|
|
61
|
-
/**
|
|
108
|
+
/** 字符维裁剪(§15.3.3——无换行巨 chunk 的堵口):逐行丢最旧直至释放 ≥ want 字符;
|
|
109
|
+
* 行数按实际被丢行折算 N(无幽灵计数——N6);标记不占额度。返回释放字符数。 */
|
|
110
|
+
function dropCarrierChars(carrier, want) {
|
|
111
|
+
if (!(want > 0)) return 0
|
|
112
|
+
let freed = 0
|
|
113
|
+
while (freed < want) {
|
|
114
|
+
const got = dropFirstContentLine(carrier)
|
|
115
|
+
if (got <= 0) break // 内容耗尽(不再产生幽灵行)
|
|
116
|
+
freed += got
|
|
117
|
+
}
|
|
118
|
+
if (freed > 0) {
|
|
119
|
+
carrier._charCount = Math.max(0, (carrier._charCount ?? 0) - freed)
|
|
120
|
+
updateTrimMarker(carrier)
|
|
121
|
+
}
|
|
122
|
+
return freed
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 单载体 trim(原树级 trimSubTree 收窄——D-ST4):超 500 显示行 → 最旧先行丢行;
|
|
126
|
+
* 超 SUB_BLOCK_CHAR_LIMIT 字符 → 同款最旧先行(双维先到先裁——TUI.md §15.3.4)。 */
|
|
62
127
|
function trimSubCarrier(carrier) {
|
|
63
128
|
const over = (carrier._lineCount ?? 0) - SUB_BLOCK_LINE_LIMIT
|
|
64
129
|
if (over > 0) dropCarrierLines(carrier, over)
|
|
130
|
+
const overChars = (carrier._charCount ?? 0) - SUB_BLOCK_CHAR_LIMIT
|
|
131
|
+
if (overChars > 0) dropCarrierChars(carrier, overChars)
|
|
65
132
|
}
|
|
66
133
|
|
|
67
|
-
/** kind 合并追加(载体级)——净增行记账(显示行口径;省略标记不参与合并——N6
|
|
134
|
+
/** kind 合并追加(载体级)——净增行记账(显示行口径;省略标记不参与合并——N6);
|
|
135
|
+
* 字符账同步累加(第二维——§15.3.4)。 */
|
|
68
136
|
function pushBlock(carrier, kind, text, fresh) {
|
|
69
137
|
if (!text) return
|
|
138
|
+
const capped = capText(text, SUB_BLOCK_CAP_OPTS) // 单块自身额度(无换行巨 chunk 堵口)
|
|
70
139
|
const last = carrier.blocks.at(-1)
|
|
71
140
|
if (!fresh && last && last.kind === kind && !isTrimMarker(last)) {
|
|
72
141
|
const before = countBlockLines(last.text)
|
|
73
|
-
last.text
|
|
142
|
+
const beforeChars = last.text.length
|
|
143
|
+
last.text = capText(last.text + capped, SUB_BLOCK_CAP_OPTS)
|
|
74
144
|
carrier._lineCount = (carrier._lineCount ?? 0) + countBlockLines(last.text) - before
|
|
145
|
+
carrier._charCount = (carrier._charCount ?? 0) + (last.text.length - beforeChars)
|
|
75
146
|
} else {
|
|
76
|
-
carrier.blocks.push({ kind, text })
|
|
77
|
-
carrier._lineCount = (carrier._lineCount ?? 0) + countBlockLines(
|
|
147
|
+
carrier.blocks.push({ kind, text: capped })
|
|
148
|
+
carrier._lineCount = (carrier._lineCount ?? 0) + countBlockLines(capped)
|
|
149
|
+
carrier._charCount = (carrier._charCount ?? 0) + capped.length
|
|
78
150
|
}
|
|
79
151
|
carrier.blockEpoch = (carrier.blockEpoch ?? 0) + 1
|
|
80
152
|
}
|
|
@@ -102,7 +174,7 @@ export function ensureSubChild(carrier, seg) {
|
|
|
102
174
|
started: Date.now(), done: false, doneAt: null, stopped: false,
|
|
103
175
|
blocks: [], currentTool: null, toolArgs: null,
|
|
104
176
|
approval: null, lastError: null, dropped: 0,
|
|
105
|
-
blockEpoch: 0, children: [], _lineCount: 0,
|
|
177
|
+
blockEpoch: 0, children: [], _lineCount: 0, _charCount: 0,
|
|
106
178
|
}
|
|
107
179
|
carrier.children.push(c)
|
|
108
180
|
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { C } from "./ansi.mjs"
|
|
14
14
|
import { closeOpenSubChildren } from "./subagent-children.mjs"
|
|
15
|
+
// TUI-OOM-ROOTCAUSE(TUI.md §15.3.3 落点表末行):state.lines 总量账——splice 插入路径过账。
|
|
16
|
+
import { accountLine } from "./display-budget.mjs"
|
|
15
17
|
|
|
16
18
|
// ─── §19.6 D-P1 面板视图(subagent panel 检查工具)───
|
|
17
19
|
// CLI-ACTIVITY-DEBLOAT F-3:面板区块列表由消费面**读时现算**(action:"panel" 经
|
|
@@ -84,9 +86,13 @@ export function freezeSubTaskLines(state, sub) {
|
|
|
84
86
|
sub.done = true
|
|
85
87
|
sub.doneAt = sub.doneAt ?? Date.now()
|
|
86
88
|
const anchor = sub._freezeAt ?? state.lines.length
|
|
87
|
-
|
|
89
|
+
const line = {
|
|
88
90
|
text: `subagent activity: ${sub.key}`, color: C.dim, _frozenSubTask: sub,
|
|
89
|
-
}
|
|
91
|
+
}
|
|
92
|
+
state.lines.splice(Math.min(anchor, state.lines.length), 0, line)
|
|
93
|
+
// TUI-OOM-ROOTCAUSE(TUI.md §15.3.3 落点表末行「冻结子代理 splice 插入——经
|
|
94
|
+
// syncLineBudget 同款对账」):冻结行进 state.lines 总量账(splice 插入路径)。
|
|
95
|
+
accountLine(state, line)
|
|
90
96
|
}
|
|
91
97
|
|
|
92
98
|
/** 头裁锚点校正(index.mjs pushLine 调用):裁 removedCount 补 1 标记行 = 净位移
|
|
@@ -19,7 +19,7 @@ import { sweepToolBlocks } from "./tool-events.mjs"
|
|
|
19
19
|
import { logEvent } from "../log.mjs"
|
|
20
20
|
import { C } from "./ansi.mjs"
|
|
21
21
|
// ASYNC-RESULT-CONTAINER.md D1/D2:池 accessor(双池 absorb)+ pending 单容器停靠
|
|
22
|
-
import { getAsyncPool, parkAsyncPending } from "../agent-tools/async-settle.mjs"
|
|
22
|
+
import { getAsyncPool, parkAsyncPending, releaseSettledEntry } from "../agent-tools/async-settle.mjs"
|
|
23
23
|
|
|
24
24
|
// INPUT-LOCK-ASYNC(C'——2026-09-09,本档 INPUT-LOCK-ASYNC.md):R15 排队
|
|
25
25
|
// 用户指令合并(§11.3 D-24c——攒批计划/合并文案/上限常量)整批废弃
|
|
@@ -285,6 +285,8 @@ export async function suspensionSession(ctx) {
|
|
|
285
285
|
for (const e of residual.splice(0)) {
|
|
286
286
|
if (e.role === "consult") await injectConsultResult(agent, e)
|
|
287
287
|
else await injectAsyncResult(agent, e)
|
|
288
|
+
// TUI-OOM-ROOTCAUSE(§23.3.1 消费点③——挂起残差注入):注入完成 → 释放条目持有
|
|
289
|
+
releaseSettledEntry(e)
|
|
288
290
|
}
|
|
289
291
|
}
|
|
290
292
|
}
|
package/src/tui/tool-args.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* 全量必须落行)。
|
|
13
13
|
*/
|
|
14
14
|
import { sliceByWidth } from "./render.mjs"
|
|
15
|
+
import { capLines, ARGS_JSON_MAX_CHARS } from "./display-budget.mjs"
|
|
15
16
|
|
|
16
17
|
/** 单行参数摘要:按工具挑关键字段(vscode 卡片头对齐),未知工具回退 JSON。 */
|
|
17
18
|
export function describeToolArgs(name, args) {
|
|
@@ -74,8 +75,10 @@ export function describeToolArgs(name, args) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** 恢复路径用:全量参数 pretty JSON 的 dim 行(非空才输出)。
|
|
77
|
-
*
|
|
78
|
+
* 与工具结果的恢复惯例一致——完整落行;**总量额度**(TUI-OOM-ROOTCAUSE §15.3.1
|
|
79
|
+
* ARGS_JSON_MAX_CHARS——`write`/`apply_patch` 整文件内容进显示层的堵口):超出尾截断
|
|
80
|
+
* + 标记,首行保真。 */
|
|
78
81
|
export function toolArgsLines(args) {
|
|
79
82
|
if (!args || typeof args !== "object" || Object.keys(args).length === 0) return []
|
|
80
|
-
return JSON.stringify(args, null, 2).split("\n")
|
|
83
|
+
return capLines(JSON.stringify(args, null, 2).split("\n"), ARGS_JSON_MAX_CHARS)
|
|
81
84
|
}
|
package/src/tui/tool-display.mjs
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* 不禁内容变更(.set/.delete/.push),调用方行为与同模块时代一致。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// TUI-OOM-ROOTCAUSE(TUI.md §15.3.1):显示层额度常量/工具行定位(display-budget 叶子)。
|
|
11
|
+
import { capLines, TOOL_RESULT_MAX_CHARS } from "./display-budget.mjs"
|
|
12
|
+
|
|
10
13
|
// Tool execution start timestamps (performance.now ms). Keyed by tool_call id
|
|
11
14
|
// when available (parallel same-name tools each get their own tick — the
|
|
12
15
|
// P0-3 fix, 2026-08-30), falling back to a per-name FIFO queue for callers
|
|
@@ -73,7 +76,9 @@ export function sweepToolBlocks(state) {
|
|
|
73
76
|
* 1) Multimodal results (read_image) embed FULL base64 images in the JSON —
|
|
74
77
|
* the human needs only the text part (model gets images via the multimodal
|
|
75
78
|
* channel); 2) results beyond maxRows are truncated in the block (full
|
|
76
|
-
* text always lives in history for the model). Returns row array.
|
|
79
|
+
* text always lives in history for the model). Returns row array.
|
|
80
|
+
* TUI-OOM-ROOTCAUSE(§15.3.1 TOOL_RESULT_MAX_CHARS):总量额度与 400 行双维
|
|
81
|
+
* (单行任意大——无 `\n` 巨 chunk/JSON 尾行),尾截断 + 标记。 */
|
|
77
82
|
export function slimToolResultForDisplay(result, maxRows = 400) {
|
|
78
83
|
let displayResult = result
|
|
79
84
|
try {
|
|
@@ -81,9 +86,18 @@ export function slimToolResultForDisplay(result, maxRows = 400) {
|
|
|
81
86
|
if (parsed?.images?.length) displayResult = parsed.text ?? result
|
|
82
87
|
} catch { /* not JSON — show as-is */ }
|
|
83
88
|
const rows = String(displayResult).split("\n").filter((l) => l.trim())
|
|
84
|
-
|
|
89
|
+
const capped = rows.length > maxRows
|
|
85
90
|
? [...rows.slice(0, maxRows), `… (result truncated at ${maxRows} rows — full text in history)`]
|
|
86
91
|
: rows
|
|
92
|
+
return capLines(capped, TOOL_RESULT_MAX_CHARS)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 按块定位其载体行(字符账 re-account 用——块对象唯一)。 */
|
|
96
|
+
export function findToolLine(state, name, toolId) {
|
|
97
|
+
const block = findToolBlock(state, name, toolId)
|
|
98
|
+
if (!block) return null
|
|
99
|
+
for (const l of state.lines ?? []) if (l._toolBlock === block) return l
|
|
100
|
+
return null
|
|
87
101
|
}
|
|
88
102
|
/** Mark the dispatch-level tool carrier done when its result is consumed by a
|
|
89
103
|
* dedicated branch (subagent/escalate/advisor blocks) — without this the turn
|
package/src/tui/tool-events.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
|
|
25
25
|
routeSubToolOutput, finishSubTask, finishSubTaskKey, finishSubTasksByRole, freezeDoneSubTasks,
|
|
26
26
|
ensureCompressPanel, markCompressFailed, markCompressDone, markCompressFallback,
|
|
27
|
+
shiftFreezeAnchors,
|
|
27
28
|
} from "./subagent-blocks.mjs"
|
|
28
29
|
// 第 27 批 §12.3②:前缀正则换名 + import 源改文法模块(纯换名——语义零改)。
|
|
29
30
|
import { RELAY_PREFIX_RE } from "../agent/relay-prefix.mjs"
|
|
@@ -34,14 +35,42 @@ import {
|
|
|
34
35
|
TOOL_OUTPUT_LINE_CAP, REMINDER_CAP, REMINDER_PERSIST_TURNS,
|
|
35
36
|
_toolTicks, _subActions, _subActionQ,
|
|
36
37
|
tickStart, tickTake, settleToolBlock, isAsyncSpawnResult, isSpawnErrorResult,
|
|
37
|
-
findToolBlock, slimToolResultForDisplay, sweepToolBlocks,
|
|
38
|
+
findToolBlock, findToolLine, slimToolResultForDisplay, sweepToolBlocks,
|
|
38
39
|
} from "./tool-display.mjs"
|
|
39
40
|
export { sweepToolBlocks, slimToolResultForDisplay } from "./tool-display.mjs"
|
|
41
|
+
// TUI-OOM-ROOTCAUSE(TUI.md §15.3.1/§15.3.3):显示层额度(行/载体/输出环/评审/流式)。
|
|
42
|
+
import {
|
|
43
|
+
capText, capLine, capLines, appendCapped, capAdvisorText, accountLine, syncLineBudget,
|
|
44
|
+
LINE_TRUNC_MARKER, MIDDLE_TRUNC_MARKER, ADVISOR_CAP_OPTS, STREAM_CAP_OPTS,
|
|
45
|
+
TOOL_OUTPUT_ENTRY_MAX_CHARS, TOOL_OUTPUT_TOTAL_MAX_CHARS, ADVISOR_TEXT_MAX_CHARS,
|
|
46
|
+
} from "./display-budget.mjs"
|
|
47
|
+
|
|
48
|
+
/** 输出环字符维总量(§15.3.1 TOOL_OUTPUT_TOTAL_MAX_CHARS——与 200 条目环同款丢最旧)。 */
|
|
49
|
+
function trimOutputChars(block) {
|
|
50
|
+
let total = 0
|
|
51
|
+
for (const s of block.output) total += s.length
|
|
52
|
+
while (total > TOOL_OUTPUT_TOTAL_MAX_CHARS && block.output.length > 0) {
|
|
53
|
+
total -= block.output.shift().length
|
|
54
|
+
}
|
|
55
|
+
}
|
|
40
56
|
|
|
41
57
|
/** Build the agent callbacks + the shared flushStream for one turn.
|
|
42
58
|
* askPermission, askQuestion, saveSessionImpl } */
|
|
43
59
|
export function buildToolCallbacks(deps) {
|
|
44
60
|
const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, saveSessionImpl } = deps
|
|
61
|
+
// 行集总量对账便捷式(§15.3.3——冻结锚点平移交 subagent 族函数)
|
|
62
|
+
const budgetSync = () => syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
|
|
63
|
+
/** Advisor 有序块累积 + 额度(§15.3.1 ADVISOR_TEXT_MAX_CHARS——头 32K/尾 96K/中段标记)。 */
|
|
64
|
+
const pushAdvisorChunk = (raw, kind) => {
|
|
65
|
+
const blocks = state._advisorBlocks ??= []
|
|
66
|
+
const last = blocks.at(-1)
|
|
67
|
+
if (last && last.kind === kind) last.text = appendCapped(last.text, raw, ADVISOR_CAP_OPTS)
|
|
68
|
+
else blocks.push({ kind, text: appendCapped("", raw, ADVISOR_CAP_OPTS) })
|
|
69
|
+
// 多块总量:丢最旧块直至 ≤ 额度(裁决尾部优先)
|
|
70
|
+
let total = 0
|
|
71
|
+
for (const b of blocks) total += b.text.length
|
|
72
|
+
while (total > ADVISOR_TEXT_MAX_CHARS && blocks.length > 1) total -= blocks.shift().text.length
|
|
73
|
+
}
|
|
45
74
|
// NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
|
|
46
75
|
// Timing safety: onToolResult flushes _advisorThink into history and empties
|
|
47
76
|
// the buffers BEFORE onTurnEnd can call flushStream (tool result is
|
|
@@ -69,7 +98,7 @@ export function buildToolCallbacks(deps) {
|
|
|
69
98
|
// block content, never the main stream (D1). Routing details in subagent-blocks.
|
|
70
99
|
if (routeSubToken(state, t, scheduleRender)) return
|
|
71
100
|
ensureAssistantLabel()
|
|
72
|
-
state.streaming
|
|
101
|
+
state.streaming = appendCapped(state.streaming, t, STREAM_CAP_OPTS) // §15.3.1 STREAM_MAX_CHARS
|
|
73
102
|
scheduleRender()
|
|
74
103
|
},
|
|
75
104
|
onReasoning: (t) => {
|
|
@@ -77,7 +106,7 @@ export function buildToolCallbacks(deps) {
|
|
|
77
106
|
// buffer as kind=think (F2: same treatment as the main reasoning stream).
|
|
78
107
|
if (routeSubReasoning(state, t, scheduleRender)) return
|
|
79
108
|
ensureAssistantLabel()
|
|
80
|
-
state.reasoning
|
|
109
|
+
state.reasoning = appendCapped(state.reasoning, t, STREAM_CAP_OPTS) // §15.3.1 STREAM_MAX_CHARS
|
|
81
110
|
scheduleRender()
|
|
82
111
|
},
|
|
83
112
|
onToolCall: (name, args, toolId) => {
|
|
@@ -139,6 +168,8 @@ export function buildToolCallbacks(deps) {
|
|
|
139
168
|
done: false,
|
|
140
169
|
},
|
|
141
170
|
})
|
|
171
|
+
accountLine(state, state.lines[state.lines.length - 1]) // 载体入总量账(§15.3.4)
|
|
172
|
+
budgetSync()
|
|
142
173
|
tickStart(name, toolId)
|
|
143
174
|
},
|
|
144
175
|
// §7.2.3(方案 e):dispatch runOne 把工具 ctx 上的 _subagentKey(sync spawn/
|
|
@@ -236,11 +267,13 @@ export function buildToolCallbacks(deps) {
|
|
|
236
267
|
// screen of garbage (user report 2026-08-30). The model gets the image
|
|
237
268
|
// via the multimodal channel (agent.mjs), the human needs only the
|
|
238
269
|
// text part: strip image parts from the displayed result.
|
|
239
|
-
block.result = slimToolResultForDisplay(result)
|
|
270
|
+
block.result = slimToolResultForDisplay(result) // §15.3.1 RESULT 额度(行 400 + 字符 64K 双维)
|
|
240
271
|
block.summary = formatToolSummary(name, result)
|
|
241
272
|
block.done = true
|
|
242
273
|
const started = tickTake(name, toolId)
|
|
243
274
|
block.elapsed = started !== null ? Math.round(performance.now() - started) : null
|
|
275
|
+
accountLine(state, findToolLine(state, name, toolId)) // 载体原地变更 → 重新入账
|
|
276
|
+
budgetSync()
|
|
244
277
|
}
|
|
245
278
|
}
|
|
246
279
|
if (name === "advisor") {
|
|
@@ -255,17 +288,22 @@ export function buildToolCallbacks(deps) {
|
|
|
255
288
|
// _advisorBlocks keep rendering the running view until cleared at turn end.
|
|
256
289
|
const blocks = state._advisorBlocks ?? []
|
|
257
290
|
if (blocks.length > 0) {
|
|
258
|
-
|
|
291
|
+
// 冻结文本额度(§15.3.1:头 32K + 尾 96K 保裁决尾部 + 中段标记——非静默截断,
|
|
292
|
+
// 代码评审 #3:capLines(...)[0] 会丢标记且截尾)
|
|
293
|
+
const text = capAdvisorText(blocks
|
|
259
294
|
.map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
|
|
260
295
|
.join("")
|
|
261
296
|
.replace(/\n{3,}/g, "\n\n")
|
|
262
|
-
.trim()
|
|
297
|
+
.trim())
|
|
263
298
|
if (text) {
|
|
264
|
-
|
|
299
|
+
const line = {
|
|
265
300
|
text: "advisor review",
|
|
266
301
|
color: C.dim,
|
|
267
302
|
_frozenAdvisor: text,
|
|
268
|
-
}
|
|
303
|
+
}
|
|
304
|
+
state.lines.push(line)
|
|
305
|
+
accountLine(state, line)
|
|
306
|
+
budgetSync()
|
|
269
307
|
}
|
|
270
308
|
}
|
|
271
309
|
}
|
|
@@ -298,25 +336,26 @@ export function buildToolCallbacks(deps) {
|
|
|
298
336
|
const isString = typeof chunk === "string"
|
|
299
337
|
const raw = isString ? chunk : String(chunk?.text ?? "")
|
|
300
338
|
const kind = isString ? "text" : (chunk?.kind ?? "text")
|
|
301
|
-
|
|
302
|
-
const last = blocks.at(-1)
|
|
303
|
-
if (last && last.kind === kind) last.text += raw
|
|
304
|
-
else blocks.push({ kind, text: raw })
|
|
339
|
+
pushAdvisorChunk(raw, kind) // §15.3.1 累积额度(多块丢最旧)
|
|
305
340
|
scheduleRender()
|
|
306
341
|
return
|
|
307
342
|
}
|
|
308
343
|
// Append into the CURRENT tool block's output buffer (the block is the
|
|
309
344
|
// display; no _live scroll lines anymore). N2-style cap keeps memory
|
|
310
|
-
// bounded: keep the LAST 200 output lines per call
|
|
345
|
+
// bounded: keep the LAST 200 output lines per call; 字符维双维
|
|
346
|
+
// (§15.3.1:单条目 ≤ TOOL_OUTPUT_ENTRY_MAX_CHARS、总量 ≤ TOOL_OUTPUT_TOTAL_MAX_CHARS 丢最旧)。
|
|
311
347
|
const block = findToolBlock(state, name, toolId)
|
|
312
348
|
if (block) {
|
|
313
349
|
for (const line of part.text.split("\n")) {
|
|
314
350
|
const trimmed = line.trimEnd()
|
|
315
|
-
if (trimmed) block.output.push(trimmed)
|
|
351
|
+
if (trimmed) block.output.push(capText(trimmed, { max: TOOL_OUTPUT_ENTRY_MAX_CHARS, keepHead: 6_000, keepTail: 2_000, marker: MIDDLE_TRUNC_MARKER }))
|
|
316
352
|
}
|
|
317
353
|
if (block.output.length > TOOL_OUTPUT_LINE_CAP) {
|
|
318
354
|
block.output.splice(0, block.output.length - TOOL_OUTPUT_LINE_CAP)
|
|
319
355
|
}
|
|
356
|
+
trimOutputChars(block)
|
|
357
|
+
accountLine(state, findToolLine(state, name, toolId))
|
|
358
|
+
budgetSync()
|
|
320
359
|
}
|
|
321
360
|
scheduleRender()
|
|
322
361
|
},
|
|
@@ -15,9 +15,16 @@ export function writeStartupSequence(write = (s) => process.stdout.write(s)) {
|
|
|
15
15
|
write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn + ansi.keyboardPush + ansi.modifyOtherKeysOn + ansi.wrapOff)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** TUI
|
|
18
|
+
/** TUI 清理序列 = 异常退出恢复序列(单一来源——§9.2 表 2 候选 1):清屏 + 关闭鼠标/
|
|
19
|
+
* 粘贴/键盘增强 + 退出 alt buffer + 显示光标 + 恢复环绕。
|
|
20
|
+
* TUI-OOM-ROOTCAUSE(CRASH-REPORTS.md §9.3):提取为导出常量 `RECOVERY_SEQUENCE`——
|
|
21
|
+
* 包装父在子异常退出(V8 fatal——不走 JS 钩子)时补发同一序列(单一来源,禁第三份
|
|
22
|
+
* 副本);writeCleanupSequence 引用同常量(行为逐字不变——既有字节锁测试保持,AC-RT3)。 */
|
|
23
|
+
export const RECOVERY_SEQUENCE =
|
|
24
|
+
ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset + ansi.wrapOn
|
|
25
|
+
|
|
19
26
|
export function writeCleanupSequence(write = (s) => process.stdout.write(s)) {
|
|
20
|
-
write(
|
|
27
|
+
write(RECOVERY_SEQUENCE)
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
// R25(ARCHITECTURE.md §R25 F-R25a——复审 #1):TUI 活动态标志——index.mjs 在终端接管
|
|
@@ -6,12 +6,13 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"
|
|
|
6
6
|
import { spawn } from "node:child_process"
|
|
7
7
|
import { fileURLToPath } from "node:url"
|
|
8
8
|
import { crashReportsDir } from "../crash-reports.mjs"
|
|
9
|
+
import { RECOVERY_SEQUENCE } from "./tui-lifecycle.mjs"
|
|
9
10
|
|
|
10
11
|
// F-3 信号语义(raw mode 既有 key-handler 双按语义——Ctrl+C = stdin 字节不产生信号 → 子正常退 → 父收
|
|
11
12
|
// exit 同码退):父忽略 SIGINT/SIGTERM——tee 不被打断。2026-09-09 实测:Windows process.kill(SIGINT)
|
|
12
13
|
// = 硬杀 ≠ 控制台 Ctrl+C 事件(handler 不触发)——信号面测试走 mock(真控制台端到端留发布前手动 QA)。
|
|
13
14
|
export const ignoreSignal = () => {} // no-op 单一引用——父信号 + stderr-error 监听共用——测试可精确复原
|
|
14
|
-
export function spawnTuiWrapped({ dir = crashReportsDir(), script = fileURLToPath(new URL("../../bin/thincoder.mjs", import.meta.url)), spawnImpl = spawn, exitImpl = (code) => process.exit(code) } = {}) {
|
|
15
|
+
export function spawnTuiWrapped({ dir = crashReportsDir(), script = fileURLToPath(new URL("../../bin/thincoder.mjs", import.meta.url)), spawnImpl = spawn, exitImpl = (code) => process.exit(code), writeImpl = (s) => process.stdout.write(s) } = {}) {
|
|
15
16
|
// ① mkdir 前置(评审 #2——prepareCrashReporting 只在子内跑——首启目录缺失会静默不包装)+ 开日志
|
|
16
17
|
//(文件头元信息行:时间/pid/argv——F-2——0600 append)——失败 → false:不包装直接跑现逻辑(尽力面)
|
|
17
18
|
let logPath = null
|
|
@@ -25,14 +26,29 @@ export function spawnTuiWrapped({ dir = crashReportsDir(), script = fileURLToPat
|
|
|
25
26
|
const note = (s) => { try { appendFileSync(logPath, s) } catch { /* 落盘尽力面——不阻断 tee */ } }
|
|
26
27
|
let exitCode = null, exitSignal = null, settled = false
|
|
27
28
|
const finish = (code) => { if (!settled) { settled = true; exitImpl(code) } }
|
|
28
|
-
|
|
29
|
+
// TUI-OOM-ROOTCAUSE(CRASH-REPORTS.md §9.3):子异常退出(V8 fatal——不走 JS 钩子)→
|
|
30
|
+
// 包装父补发恢复序列(唯一存活方;序列单一来源 = tui-lifecycle.RECOVERY_SEQUENCE)。
|
|
31
|
+
// 守卫落点 = exit/close 处置内(**不在共享 finish**——spawn error 路径经 error→finish(1),
|
|
32
|
+
// 字面挂 finish 会在子未启动时补发 clearScreen 序列,违 F5③);按 exitCode/exitSignal
|
|
33
|
+
// 判定(code !== 0 || signal != null);异常退出零动作;正常退出(code 0)零干预;
|
|
34
|
+
// 每进程恰一次(recovered 守卫,exit/close 双路 + 30s 兜底路径同守);写失败不阻断收尾。
|
|
35
|
+
let recovered = false
|
|
36
|
+
const recoverTerminal = (code, signal) => {
|
|
37
|
+
if (recovered) return
|
|
38
|
+
if (!(code !== 0 || signal != null)) return
|
|
39
|
+
recovered = true
|
|
40
|
+
try { writeImpl(RECOVERY_SEQUENCE) } catch { /* 尽力面:终端已关/管道已断——不阻断收尾 */ }
|
|
41
|
+
}
|
|
42
|
+
// F3③(CRASH-REPORTS):近堆上限快照落点定向——--diagnostic-dir 为 Node 选项,须在脚本路径前;
|
|
43
|
+
// 无条件注入(gate 单点在 crash-reports.mjs——包装器不判 env);未触发快照时零副作用。
|
|
44
|
+
const child = spawnImpl(process.execPath, [`--diagnostic-dir=${dir}`, script, ...process.argv.slice(2)], {
|
|
29
45
|
stdio: ["inherit", "inherit", "pipe"], // 子 stderr pipe → tee;stdin/stdout 继承(TTY 原样)
|
|
30
46
|
env: { ...process.env, THINCODER_TUI_WRAPPED: "1" }, // env 门——子内判定不包装——纯现逻辑(红线)
|
|
31
47
|
windowsHide: false,
|
|
32
48
|
})
|
|
33
49
|
child.stderr.on("data", (chunk) => { try { process.stderr.write(chunk) } catch { /* 终端已关——日志仍落 */ } note(chunk) }) // ② tee 双写:终端实时 + 日志
|
|
34
|
-
child.on("exit", (code, signal) => { exitCode = code; exitSignal = signal; setTimeout(() => finish(exitCode ?? (exitSignal ? 1 : 0)), 30_000).unref() }) // ③ exit
|
|
35
|
-
child.on("close", () => finish(exitCode ?? (exitSignal ? 1 : 0))) // ④ close = stderr 尾数据全收(AC-2)→ 同码退
|
|
36
|
-
child.on("error", (err) => { note(`# spawn error: ${err.message}\n`); finish(1) }) // ⑤ spawn 失败不发 exit 只发 error
|
|
50
|
+
child.on("exit", (code, signal) => { exitCode = code; exitSignal = signal; recoverTerminal(code, signal); setTimeout(() => { recoverTerminal(exitCode, exitSignal); finish(exitCode ?? (exitSignal ? 1 : 0)) }, 30_000).unref() }) // ③ exit 记码;异常退出→补发恢复;兜底 30s 强退
|
|
51
|
+
child.on("close", () => { recoverTerminal(exitCode, exitSignal); finish(exitCode ?? (exitSignal ? 1 : 0)) }) // ④ close = stderr 尾数据全收(AC-2)→ 同码退
|
|
52
|
+
child.on("error", (err) => { note(`# spawn error: ${err.message}\n`); finish(1) }) // ⑤ spawn 失败不发 exit 只发 error(零序列动作——F5③)
|
|
37
53
|
return true
|
|
38
54
|
}
|