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
package/src/session.mjs
CHANGED
|
@@ -25,6 +25,14 @@ import {
|
|
|
25
25
|
import { guardForeignSlotFile } from "./session-guard.mjs"
|
|
26
26
|
import { scheduleSessionGC } from "./session-gc.mjs"
|
|
27
27
|
import { engTokenSlotFields, restoreEngTokens } from "./token-ttl.mjs"
|
|
28
|
+
// TUI-OOM-ROOTCAUSE 批(SESSION.md §14):人读线记录存储(磁盘为准 + 内存窗口)。
|
|
29
|
+
// slimForDisplay/isLegacyTransient 迁入 store 模块(§14.5 文件表)——本文件 re-export 保持
|
|
30
|
+
// 既有 import 面(session-slots.mjs 引 isLegacyTransient)。依赖方向单向:store 零项目内依赖。
|
|
31
|
+
import {
|
|
32
|
+
bindRecordStore, unbindRecordStore, saveProjectedSlot, isLegacyTransient, slimForDisplay,
|
|
33
|
+
RECORD_WINDOW_MESSAGES,
|
|
34
|
+
} from "./session-store.mjs"
|
|
35
|
+
export { isLegacyTransient, slimForDisplay, bindRecordStore, unbindRecordStore }
|
|
28
36
|
|
|
29
37
|
// re-export slot 管理(保持既有 import session.mjs 的调用点不变;resumeSlot 下方本地包装导出)
|
|
30
38
|
export {
|
|
@@ -43,60 +51,44 @@ export function resumeSlot(cwd) {
|
|
|
43
51
|
return slotsResumeSlot(cwd)
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
// ==========
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"[System reminder: working directory snapshot:",
|
|
50
|
-
"[Relevant memories from previous sessions",
|
|
51
|
-
]
|
|
54
|
+
// ========== core read/write ==========
|
|
55
|
+
// (legacy transient 判定 + slimForDisplay 自本区迁至 session-store.mjs——§14.5 文件表;
|
|
56
|
+
// 上方 re-export 保持既有 import 面。)
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
)
|
|
58
|
+
/** 未绑定路径的人读线全量物化(模式 F——既有语义逐字保留)。 */
|
|
59
|
+
function legacyHistory(agent) {
|
|
60
|
+
return (agent._fullHistory ?? agent.history)
|
|
61
|
+
.filter((m) => !m.transient && !isLegacyTransient(m))
|
|
62
|
+
.map(slimForDisplay)
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* the machine line and provider prefix cache):
|
|
72
|
-
* - assistant.tool_calls[].function.arguments → trimmed to 300 chars (head + …)
|
|
73
|
-
* - tool messages content → 500 chars (head + …)
|
|
74
|
-
* - multimodal user content array → keep text parts, DROP image_url base64 parts
|
|
75
|
-
* - plain string messages → untouched (not the size driver)
|
|
76
|
-
*/
|
|
77
|
-
function slimForDisplay(m) {
|
|
78
|
-
if (m && Array.isArray(m.content)) {
|
|
79
|
-
// Multimodal user message: keep text parts, drop image parts.
|
|
80
|
-
const textParts = m.content.filter((p) => p?.type !== "image_url")
|
|
81
|
-
if (textParts.length === m.content.length) return m
|
|
82
|
-
return { ...m, content: textParts }
|
|
65
|
+
/** 恢复描述符(§14.3.6——两调用点统一形态):`{ history, total, base }`。
|
|
66
|
+
* 绑定态:history = 尾窗 + ±1 页沿头一条(跨页回合标签判定用——不渲染)、total = 绝对
|
|
67
|
+
* 总条数(「N messages」标签口径)、base = history[0] 的绝对序号;未绑定(模式 F):
|
|
68
|
+
* 回退全量数组(total = 数组长)。 */
|
|
69
|
+
export function sessionDescriptor(agent, data) {
|
|
70
|
+
const store = agent?._recordStore
|
|
71
|
+
if (store) {
|
|
72
|
+
const total = store.total()
|
|
73
|
+
const win = store.page(Math.max(0, total - RECORD_WINDOW_MESSAGES), total, { margin: 1 })
|
|
74
|
+
return { history: win.messages, total, base: win.base }
|
|
83
75
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return { ...m, content: m.content.slice(0, 500) + "\n… (truncated for storage)" }
|
|
76
|
+
const full = Array.isArray(data?.history) ? data.history : []
|
|
77
|
+
return { history: full, total: full.length, base: 0 }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 槽摘要(记录存储绑定路径——history 不物化):与 slotDigest(extractSlotMeta) 同形。 */
|
|
81
|
+
function digestFromStore(fields, counters) {
|
|
82
|
+
const meta = {
|
|
83
|
+
messageCount: counters.total,
|
|
84
|
+
turnCount: counters.userReal,
|
|
85
|
+
firstMessage: counters.firstMessage,
|
|
86
|
+
activeProvider: fields.activeProvider ?? "",
|
|
87
|
+
updatedAt: fields.updatedAt ?? Date.now(),
|
|
88
|
+
title: fields.title ?? "",
|
|
98
89
|
}
|
|
99
|
-
|
|
90
|
+
if (fields.activeModel) meta.activeModel = fields.activeModel
|
|
91
|
+
return { ts: Date.now(), ...meta }
|
|
100
92
|
}
|
|
101
93
|
|
|
102
94
|
/** Save agent state to the active slot file (atomic write). `display` (the old
|
|
@@ -111,16 +103,17 @@ export function saveSession(agent) {
|
|
|
111
103
|
// history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
|
|
112
104
|
// contextHistory = machine context (possibly compacted) so CLI resume keeps the token savings
|
|
113
105
|
// Human line: transient machine injections never enter the readable record.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
106
|
+
// TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.5):绑定记录存储后 `history` 字段由
|
|
107
|
+
// saveProjectedSlot 流式拼接段原文生成(磁盘为准——不物化全量数组);未绑定
|
|
108
|
+
// (模式 F:thincoder chat/测试/未覆盖路径)→ 既有全量物化路径逐字保留(D-R6)。
|
|
117
109
|
// Machine line (contextHistory): KEEP transient messages — resume must rebuild the
|
|
118
110
|
// machine line byte-identical to what the provider cache saw. Dropping them made every
|
|
119
111
|
// process restart diverge at the first injection position (git/OS/time reminders are
|
|
120
112
|
// re-injected with FRESH content) → whole-prefix cache miss on the CLI's very first
|
|
121
113
|
// request of each session (2026-08-16 cache-hit report; Kimi review).
|
|
122
114
|
const contextHistory = agent.history.filter((m) => !isLegacyTransient(m))
|
|
123
|
-
|
|
115
|
+
// fields = 槽 JSON 数据面(history 由投影插入——键序与既有 data 形态同序,history/contextHistory 居后)
|
|
116
|
+
const fields = {
|
|
124
117
|
version: 2,
|
|
125
118
|
cwd: agent.cwd,
|
|
126
119
|
title: agent.title ?? "",
|
|
@@ -129,8 +122,6 @@ export function saveSession(agent) {
|
|
|
129
122
|
activeProvider: agent.activeProvider ?? agent.provider?.name ?? "",
|
|
130
123
|
activeModel: agent.activeModel ?? agent.provider?.model ?? null,
|
|
131
124
|
updatedAt: Date.now(),
|
|
132
|
-
history,
|
|
133
|
-
contextHistory,
|
|
134
125
|
tasks: agent.tasks ?? [],
|
|
135
126
|
planMode: agent.planMode ?? false,
|
|
136
127
|
autoApprove: agent.autoApprove ?? false,
|
|
@@ -141,13 +132,18 @@ export function saveSession(agent) {
|
|
|
141
132
|
pendingReminders: agent._pendingReminders ?? [],
|
|
142
133
|
sessionStart: agent._sessionStart ?? null,
|
|
143
134
|
}
|
|
144
|
-
// 2026-09-05 §10 D-4:首认领(_slot 为 null——如 /session
|
|
145
|
-
// 保存 fork
|
|
135
|
+
// 2026-09-05 §10 D-4:首认领(_slot 为 null——如 /session 切换后首保存、“查看对方活槽 →
|
|
136
|
+
// 保存 fork 新槽”的落盘槽跟随)写本端记录——marker = 端内最后认领者;粘性 _slot 期间
|
|
146
137
|
// 的日常保存不写(F1:保存永不参与竞争)
|
|
147
138
|
const claimedNow = agent._slot == null
|
|
148
139
|
const slot = agent._slot ??= activeSlot(agent.cwd)
|
|
149
140
|
if (claimedNow) writeEndMarker(agent.cwd, slot)
|
|
150
141
|
const p = slotPath(agent.cwd, slot)
|
|
142
|
+
// 首保存补绑(§14.3.4 表末行——兜底所有未覆盖路径):未绑定且有槽 → 绑定记录存储
|
|
143
|
+
// (baseHistory = 当前 _fullHistory 全量;identity = 当前 _sessionStart)
|
|
144
|
+
if (!agent._recordStore) {
|
|
145
|
+
bindRecordStore(agent, { slotFile: p, identity: agent._sessionStart ?? null, baseHistory: agent._fullHistory ?? [] })
|
|
146
|
+
}
|
|
151
147
|
// 2026-08-31 会诊 F2 🔴:写前校验磁盘文件的 sessionStart——与本进程会话不符(另一
|
|
152
148
|
// 进程/会话的现场)→ 先轮转 .bak 保留再写(11311 条历史被新进程覆盖的实锤场景)。
|
|
153
149
|
// 守卫自 2026-09-08 提取为 guardForeignSlotFile(session-guard.mjs——DESIGN-TOKEN-
|
|
@@ -155,13 +151,17 @@ export function saveSession(agent) {
|
|
|
155
151
|
// saveSession 与 token-ttl persistEngTokens(settle 当场落盘)共用同一份(检查按
|
|
156
152
|
// mtime 缓存 _slotMtime:自写未变跳过全量解析)。
|
|
157
153
|
const rotated = guardForeignSlotFile(agent, p, slot)
|
|
158
|
-
|
|
154
|
+
// 绑定态 → 流式投影(段原文拼接——VSC 兼容面逐字同形);未绑定 → 既有全量物化写
|
|
155
|
+
if (agent._recordStore) saveProjectedSlot(agent, p, fields, contextHistory)
|
|
156
|
+
else writeSessionFile(p, { ...fields, history: legacyHistory(agent), contextHistory })
|
|
159
157
|
// 记录我们刚写的 mtime——下次保存跳过重复解析
|
|
160
158
|
try { agent._slotMtime = statSync(p).mtimeMs } catch {}
|
|
161
159
|
// Update slot metadata in manifest
|
|
162
160
|
try {
|
|
163
161
|
const m = loadManifest(agent.cwd)
|
|
164
|
-
m.slots[slot] =
|
|
162
|
+
m.slots[slot] = agent._recordStore
|
|
163
|
+
? digestFromStore(fields, agent._recordStore.counters())
|
|
164
|
+
: slotDigest({ ...fields, history: legacyHistory(agent) })
|
|
165
165
|
saveManifest(agent.cwd, m)
|
|
166
166
|
} catch (e) {
|
|
167
167
|
// Manifest update failure is non-fatal — data is safe, metadata will lazy-recover on next listSlots
|
|
@@ -268,7 +268,7 @@ function stripTruncatedToolArgs(m) {
|
|
|
268
268
|
* 复验 validateProvider 弹重选)。
|
|
269
269
|
* 删旧支:"activeModel==null 清 stale override 回渠道默认"——前提 = 渠道默认字段——已随
|
|
270
270
|
* 三旧层删除消失——双字段恒非空故不可能触发(评审 #7)。 */
|
|
271
|
-
export function applySession(agent, data) {
|
|
271
|
+
export function applySession(agent, data, opts = {}) {
|
|
272
272
|
// 机读线语义(历史注释保留):data.history = FULL 未压缩记录(人读线);data.contextHistory =
|
|
273
273
|
// 压缩后机器线——恢复各从其源(保留压缩收益——从完整 history 重建会塞回中间过程——实测
|
|
274
274
|
// prompt 膨胀 283%)。v1 老文件(无 contextHistory)回退播种,剥离被 slimForDisplay 截断的
|
|
@@ -289,7 +289,8 @@ export function applySession(agent, data) {
|
|
|
289
289
|
agent._envResumed = full.length > 0
|
|
290
290
|
const ch = data.contextHistory
|
|
291
291
|
const machine = (Array.isArray(ch) && ch.length > 0) ? ch : full.map(stripTruncatedToolArgs)
|
|
292
|
-
|
|
292
|
+
// 绑定态(opts.slot 在场):人读线 = 记录存储尾窗(下方绑定块填充)——不复制全量 JSON 数组
|
|
293
|
+
agent._fullHistory = opts.slot != null ? [] : [...full]
|
|
293
294
|
agent.history = [...machine]
|
|
294
295
|
agent.title = data.title ?? ""
|
|
295
296
|
agent.tasks = data.tasks ?? []
|
|
@@ -316,6 +317,20 @@ export function applySession(agent, data) {
|
|
|
316
317
|
agent._verifyPassed = false
|
|
317
318
|
agent._slot = null // 粘性缓存清空——切换后重新认领(F1b)
|
|
318
319
|
agent._slotMtime = null
|
|
320
|
+
// ── 记录存储绑定(TUI-OOM-ROOTCAUSE——§14.3.4/§14.3.5)──
|
|
321
|
+
// opts.slot 在场(启动恢复 / 非占用的 /session 切换 / ACP 钉槽)→ 绑定(身份核验 + 对账 +
|
|
322
|
+
// 窗口)——人读线 = store.tail(200);未传(模式 F:被他人活进程占用的槽 / 测试 / ACP fork)
|
|
323
|
+
// → 解绑——人读线维持全量数组(D-R6 零回归)。
|
|
324
|
+
if (opts.slot != null) {
|
|
325
|
+
const store = bindRecordStore(agent, {
|
|
326
|
+
slotFile: slotPath(agent.cwd, opts.slot),
|
|
327
|
+
identity: data.sessionStart ?? null,
|
|
328
|
+
baseHistory: full,
|
|
329
|
+
})
|
|
330
|
+
agent._fullHistory = store.tail(RECORD_WINDOW_MESSAGES)
|
|
331
|
+
} else {
|
|
332
|
+
unbindRecordStore(agent)
|
|
333
|
+
}
|
|
319
334
|
// ── MODEL-MERGE-SESSION 恢复(F-4——两支)──
|
|
320
335
|
const slotProvider = data.activeProvider ? agent.providers?.find((pr) => pr.name === data.activeProvider) : null
|
|
321
336
|
if (slotProvider) {
|
|
@@ -436,6 +451,8 @@ export function resetSessionState(agent) {
|
|
|
436
451
|
agent._envResumed = false
|
|
437
452
|
agent._processRestartPending = false
|
|
438
453
|
agent._lastEngState = false
|
|
454
|
+
// /new 或切换:记录存储解绑(新槽由 cmd-new / 首保存补绑重新挂载——§14.3.4)
|
|
455
|
+
unbindRecordStore(agent)
|
|
439
456
|
}
|
|
440
457
|
|
|
441
458
|
/** Switch the manifest active pointer to a slot. Returns the slot's session data
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* text-budget.mjs — 文本额度纯函数(零依赖——TUI-OOM-ROOTCAUSE 批,AGENT-LOOP.md §23.3.1 / TUI.md §15.3.2)。
|
|
3
|
+
*
|
|
4
|
+
* 单一来源(D2):agent 侧捕获截断(spawn-child.mjs——子代理 `_capturedOutput`)与 TUI 面
|
|
5
|
+
* 载体额度(tui/display-budget.mjs)共用本体的 `capText` / `appendCappedText`——两处各自
|
|
6
|
+
* 复制截断逻辑的漂移面被消除。本模块零 import(可被任意层直接引用)。
|
|
7
|
+
*
|
|
8
|
+
* 计量口径 = **UTF-16 码元**(`String.length`——与 JS 字符串内存近似、确定、O(1) 计长;
|
|
9
|
+
* TUI.md §15.2 表 1)。截断形态 = 头保 + 中段标记 + 尾保(标记含真实省略数 N)。
|
|
10
|
+
*
|
|
11
|
+
* 标记串约定:`marker` 模板中以字面 `N` 为省略数占位(逐字进测试断言——如
|
|
12
|
+
* `… [captured output truncated: N chars omitted] …`)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 省略数占位符替换(首个 `N`——模板约定;TUI 面 capLines 等复用)。 */
|
|
16
|
+
export function fillMarker(marker, omitted) {
|
|
17
|
+
if (typeof marker === "function") return String(marker(omitted))
|
|
18
|
+
const tpl = String(marker ?? "")
|
|
19
|
+
const i = tpl.indexOf("N")
|
|
20
|
+
return i === -1 ? `${tpl} (${omitted} omitted)` : `${tpl.slice(0, i)}${omitted}${tpl.slice(i + 1)}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 头尾保真截断:`text` ≤ `max` 时零拷贝原样返回;超限 → 头 `keepHead` + 标记 + 尾 `keepTail`。
|
|
25
|
+
* keepHead/keepTail 各自夹紧到文本长度(互不重叠——头 + 尾 ≤ 文本长)。
|
|
26
|
+
* @returns {string} 原串或截断串(含 `marker` 替换后的省略数)
|
|
27
|
+
*/
|
|
28
|
+
export function capText(text, { max, keepHead = 0, keepTail = 0, marker = "" } = {}) {
|
|
29
|
+
const t = String(text ?? "")
|
|
30
|
+
if (!(max > 0) || t.length <= max) return t
|
|
31
|
+
const head = Math.max(0, Math.min(keepHead, t.length))
|
|
32
|
+
const tail = Math.max(0, Math.min(keepTail, t.length - head))
|
|
33
|
+
const omitted = t.length - head - tail
|
|
34
|
+
return t.slice(0, head) + fillMarker(marker, omitted) + (tail > 0 ? t.slice(t.length - tail) : "")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 流式累积(滞后水位——摊还 O(1)):`prev + add`;超 `hard` 时立即裁至
|
|
39
|
+
* 头 `head` + 标记 + 尾 `tail`(下一轮从截断值继续累积——稳态长度 ≤ hard)。
|
|
40
|
+
* @returns {string} 合并(可能已截断)的累积串
|
|
41
|
+
*/
|
|
42
|
+
export function appendCappedText(prev, add, { hard, head = 0, tail = 0, marker = "" } = {}) {
|
|
43
|
+
const merged = String(prev ?? "") + String(add ?? "")
|
|
44
|
+
if (!(hard > 0) || merged.length <= hard) return merged
|
|
45
|
+
return capText(merged, { max: hard, keepHead: head, keepTail: tail, marker })
|
|
46
|
+
}
|
|
@@ -16,12 +16,21 @@
|
|
|
16
16
|
* 与 log.mjs 的 THINCODER_LOG_DIR 同惯例)。
|
|
17
17
|
* - 脱敏(D-TR2):复用 log.mjs 字段名黑名单(apikey/designtoken/password/secret/
|
|
18
18
|
* token/authorization/proxyuri/proxy)+ SECRET_FORM 形态扫描(redactSecret)——对
|
|
19
|
-
* messages/content/reasoning/toolCalls/error
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* messages/content/reasoning/toolCalls/error 全字段应用;不发明新遮蔽模式。
|
|
20
|
+
* **单遍序列化(TUI-OOM-ROOTCAUSE——§23.3.2:修正轮 #2 使「不截断」表述作废)**:
|
|
21
|
+
* 脱敏内联进输出缓冲(不再构造复制图 + 二次全量字符串)——字段名/次序与既有
|
|
22
|
+
* `JSON.stringify` 形态同构(T-TR1 等价断言)。
|
|
23
|
+
* - 容量有界(N-TR1 修订):单消息字符串超 `TRACE_MESSAGE_MAX_CHARS` 截断(头 16K +
|
|
24
|
+
* 中段标记 + 尾 48K);序列化总长超 `TRACE_RECORD_MAX_CHARS` → `messages` 降级为
|
|
25
|
+
* stub 串(其余字段保留);在途写盘份数 ≤ `TRACE_PENDING_MAX`(超限丢弃并计数——
|
|
26
|
+
* 尽力面,可观测)。目录按日组织(YYYY-MM-DD),可按日/会话过滤;保留期清理见
|
|
27
|
+
* `cleanupTraces`(D-TR8)。
|
|
23
28
|
* - seq = 当日目录内最大已有 seq + 1(D-TR3——跨会话/进程重启不覆写既有旧轨迹——
|
|
24
|
-
* 18.6.1 评审 #3
|
|
29
|
+
* 18.6.1 评审 #3);**进程内 seqCache(§23.3.2——消除逐调用同步扫目录)**:首次
|
|
30
|
+
* readdirSync 后进程内递增预留,目录被清理(retention)后取下界重扫一次(防御)。
|
|
31
|
+
* 多进程语义(修正轮 #10——登记):同 cwd 多实例各自进程内缓存——seq 可撞、同
|
|
32
|
+
* sessionKey 记录并入同名文件(append 不覆写);**可容忍**(本机 traces = 诊断面、
|
|
33
|
+
* 默认 OFF、撞号不损坏数据);不做跨进程协调/落盘校验。
|
|
25
34
|
* - 日期分日按本地时区(D-TR3——2026-09-04 fix round1:初版 toISOString()=UTC——
|
|
26
35
|
* 本地 00:00-08:00 的调用会落进前一日目录——改本地日期字符串 YYYY-MM-DD——T-TR12)。
|
|
27
36
|
* - isContinuation(D-TR1——2026-09-04 fix round1):续写/重试链标记——true = 该调用
|
|
@@ -35,6 +44,7 @@ import { createHash } from "node:crypto"
|
|
|
35
44
|
import { configDir } from "../config.mjs"
|
|
36
45
|
import { redactSecret, errText, classifyErr } from "../log.mjs"
|
|
37
46
|
import { normalizeCwd } from "../session-slots.mjs"
|
|
47
|
+
import { capText } from "../text-budget.mjs"
|
|
38
48
|
|
|
39
49
|
/** 轨迹根目录:THINCODER_TRACES_DIR(测试隔离/override——同 THINCODER_LOG_DIR
|
|
40
50
|
* 惯例)> ~/.thincoder/traces(configDir——D-TR3——与 sessions/ 同域)。 */
|
|
@@ -65,10 +75,15 @@ export function localDateStr(date = new Date()) {
|
|
|
65
75
|
return `${y}-${m}-${d}`
|
|
66
76
|
}
|
|
67
77
|
|
|
68
|
-
/** Test hooks(_rateHooks 同惯例——rate.mjs
|
|
69
|
-
* UTC
|
|
78
|
+
/** Test hooks(_rateHooks 同惯例——rate.mjs):now/readdir/exists/mkdir/append 注入缝
|
|
79
|
+
* (T-TR5 计数 readdirSync;T-TR4 慢写替身;T-TR12 注入本地日 ≠ UTC 日的时刻)。
|
|
80
|
+
* 生产永远走真实实现。 */
|
|
70
81
|
export const _traceHooks = {
|
|
71
82
|
now: () => new Date(),
|
|
83
|
+
readdir: (dir) => readdirSync(dir),
|
|
84
|
+
exists: (p) => existsSync(p),
|
|
85
|
+
mkdir: (dir) => mkdir(dir, { recursive: true }),
|
|
86
|
+
append: (path, text) => appendFile(path, text, "utf8"),
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
/** 当日轨迹目录(D-TR3:traces/YYYY-MM-DD——分日组织——N-TR1)。 */
|
|
@@ -76,32 +91,71 @@ export function tracesDirFor(dateStr) {
|
|
|
76
91
|
return join(tracesRoot(), dateStr)
|
|
77
92
|
}
|
|
78
93
|
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
// ─── 额度常量(§23.3.2——编译期常量,不新增配置项 D-TR4)─────────────────────
|
|
95
|
+
|
|
96
|
+
/** 单消息字符串额度(消息内容/推理/工具参数串)——超限:头 16K + 中段标记 + 尾 48K。 */
|
|
97
|
+
export const TRACE_MESSAGE_MAX_CHARS = 65_536
|
|
98
|
+
/** 单记录总长额度——超限:`messages` 字段整体降级为 stub 串(元数据保留)。 */
|
|
99
|
+
export const TRACE_RECORD_MAX_CHARS = 4_000_000
|
|
100
|
+
/** 在途写盘份数上限(超限丢弃本记录 + 计数——不阻塞模型调用路径)。 */
|
|
101
|
+
export const TRACE_PENDING_MAX = 8
|
|
102
|
+
|
|
103
|
+
/** 内容额度标记(逐字——进测试断言;同族工具预览形态)。 */
|
|
104
|
+
const MSG_MARKER = "… [trace truncated: N chars omitted] …"
|
|
105
|
+
const HEAD_CHARS = 16_384
|
|
106
|
+
const TAIL_CHARS = 49_152
|
|
107
|
+
|
|
108
|
+
/** 受额度约束的字段名(消息内容/推理/工具参数串——§23.3.2 字面集)。 */
|
|
109
|
+
const CAPPED_KEYS = new Set(["content", "reasoning", "reasoning_content", "reasoningContent", "arguments"])
|
|
110
|
+
|
|
111
|
+
// ─── 在途计数(§23.3.2 表 4 候选 1:待写计数上限 8 + 超限丢弃计数)────────────
|
|
112
|
+
|
|
113
|
+
let _pendingWrites = 0
|
|
114
|
+
let _droppedRecords = 0
|
|
115
|
+
let _saturationNotified = false
|
|
116
|
+
|
|
117
|
+
// 进程内 seq 缓存(§23.3.2:dir → 已见/已预留 max——首次 readdirSync 后递增预留;
|
|
118
|
+
// 目录被清理(retention)后取下界重扫一次(防御))。
|
|
119
|
+
const _seqCache = new Map()
|
|
120
|
+
|
|
121
|
+
/** 在途/丢弃观测(测试缝——T-TR4)。 */
|
|
122
|
+
export function _traceStoreStats() {
|
|
123
|
+
return { pending: _pendingWrites, dropped: _droppedRecords }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 测试缝:清进程内状态(seqCache + 在途/丢弃计数)。 */
|
|
127
|
+
export function _resetTraceStateForTest() {
|
|
128
|
+
_seqCache.clear()
|
|
129
|
+
_pendingWrites = 0
|
|
130
|
+
_droppedRecords = 0
|
|
131
|
+
_saturationNotified = false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function scanMaxSeq(dir) {
|
|
135
|
+
if (!_traceHooks.exists(dir)) return 0
|
|
136
|
+
let names
|
|
137
|
+
try { names = _traceHooks.readdir(dir) } catch { return 0 }
|
|
138
|
+
let max = 0
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
const m = name.match(/-(\d+)\.jsonl$/)
|
|
141
|
+
if (m) max = Math.max(max, Number(m[1]))
|
|
142
|
+
}
|
|
143
|
+
return max
|
|
144
|
+
}
|
|
82
145
|
|
|
83
146
|
/** seq = max(当日目录最大已有 seq, 进程内已预留) + 1(D-TR3——跨会话/进程重启
|
|
84
|
-
* 不覆写——T-TR8/T-TR10;同步预留 = 异步写盘启动前的原子号位分配)。
|
|
147
|
+
* 不覆写——T-TR8/T-TR10;同步预留 = 异步写盘启动前的原子号位分配)。
|
|
148
|
+
* §23.3.2:seqCache 进程内递增——不逐调用 readdirSync(目录被清理/缺失 → 重扫一次)。 */
|
|
85
149
|
export function nextTraceSeq(dateStr) {
|
|
86
150
|
const dir = tracesDirFor(dateStr)
|
|
87
|
-
let max =
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
} catch {
|
|
93
|
-
// 目录不可读——按预留表续号(不静默撞号)
|
|
94
|
-
max = _reservedSeq.get(dir) ?? 0
|
|
95
|
-
_reservedSeq.set(dir, max + 1)
|
|
96
|
-
return max + 1
|
|
97
|
-
}
|
|
98
|
-
for (const name of names) {
|
|
99
|
-
const m = name.match(/-(\d+)\.jsonl$/)
|
|
100
|
-
if (m) max = Math.max(max, Number(m[1]))
|
|
101
|
-
}
|
|
151
|
+
let max = _seqCache.get(dir)
|
|
152
|
+
if (max === undefined || !_traceHooks.exists(dir)) {
|
|
153
|
+
// 首次调用(或目录被 retention 清理/缺失)——重扫磁盘取真实下界(防御:清除后重号安全)
|
|
154
|
+
const disk = scanMaxSeq(dir)
|
|
155
|
+
max = Math.max(disk, max ?? 0)
|
|
102
156
|
}
|
|
103
|
-
const seq =
|
|
104
|
-
|
|
157
|
+
const seq = max + 1
|
|
158
|
+
_seqCache.set(dir, seq)
|
|
105
159
|
return seq
|
|
106
160
|
}
|
|
107
161
|
|
|
@@ -111,18 +165,57 @@ export function tracesEnabled(logCtx) {
|
|
|
111
165
|
return logCtx?.traces !== false
|
|
112
166
|
}
|
|
113
167
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
168
|
+
// ─── 单遍序列化(§23.3.2 D-TR1——脱敏内联、零复制图、字段名/次序同构)────────
|
|
169
|
+
|
|
170
|
+
const OMIT = Symbol("omit") // JSON.stringify 语义:对象属性省略(数组元素 → null)
|
|
171
|
+
|
|
172
|
+
function scalarJson(key, v) {
|
|
173
|
+
if (typeof v === "string") {
|
|
174
|
+
// 额度(§23.3.2)+ 脱敏(D-TR2)单点:先限长(标记随附)再遮蔽形态扫描——
|
|
175
|
+
// 输出与既有 redactValue(key, s) 串值路径逐字同形(除外显式截断标记)。
|
|
176
|
+
if (v.length > TRACE_MESSAGE_MAX_CHARS && CAPPED_KEYS.has(key)) {
|
|
177
|
+
return JSON.stringify(redactSecret(key, capText(v, { max: TRACE_MESSAGE_MAX_CHARS, keepHead: HEAD_CHARS, keepTail: TAIL_CHARS, marker: MSG_MARKER })))
|
|
178
|
+
}
|
|
179
|
+
return JSON.stringify(redactSecret(key, v))
|
|
180
|
+
}
|
|
181
|
+
if (v === null) return "null"
|
|
182
|
+
if (typeof v === "number") return Number.isFinite(v) ? String(v) : "null" // JSON.stringify(NaN/±Infinity) === "null"
|
|
183
|
+
if (typeof v === "boolean") return v ? "true" : "false"
|
|
184
|
+
return undefined // undefined/function/symbol/bigint → 省略(JSON.stringify 同语义)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function writeValue(out, key, v) {
|
|
188
|
+
const s = scalarJson(key, v)
|
|
189
|
+
if (s !== undefined) { out.push(s); return true }
|
|
190
|
+
if (Array.isArray(v)) {
|
|
191
|
+
out.push("[")
|
|
192
|
+
for (let i = 0; i < v.length; i++) {
|
|
193
|
+
if (i) out.push(",")
|
|
194
|
+
if (!writeValue(out, key, v[i])) out.push("null") // 数组元素不可省略
|
|
195
|
+
}
|
|
196
|
+
out.push("]")
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
if (v && typeof v === "object") {
|
|
200
|
+
if (typeof v.toJSON === "function") { // Date/宿主对象:保持 JSON.stringify 等价
|
|
201
|
+
const js = JSON.stringify(v)
|
|
202
|
+
out.push(js === undefined ? "null" : js)
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
205
|
+
out.push("{")
|
|
206
|
+
let first = true
|
|
207
|
+
for (const [k, val] of Object.entries(v)) {
|
|
208
|
+
const omit = val === undefined || typeof val === "function" || typeof val === "symbol" || typeof val === "bigint"
|
|
209
|
+
if (omit) continue
|
|
210
|
+
if (!first) out.push(",")
|
|
211
|
+
first = false
|
|
212
|
+
out.push(JSON.stringify(k), ":")
|
|
213
|
+
writeValue(out, k, val)
|
|
214
|
+
}
|
|
215
|
+
out.push("}")
|
|
216
|
+
return true
|
|
124
217
|
}
|
|
125
|
-
return
|
|
218
|
+
return false // OMIT
|
|
126
219
|
}
|
|
127
220
|
|
|
128
221
|
/**
|
|
@@ -142,6 +235,16 @@ export function recordChatTrace(provider, opts = {}, result = null, error = null
|
|
|
142
235
|
if (!writeEnabled()) return
|
|
143
236
|
const logCtx = opts.logCtx ?? {}
|
|
144
237
|
if (!tracesEnabled(logCtx)) return
|
|
238
|
+
// §23.3.2 在途上界(表 4 候选 1):饱和即丢弃本记录(尽力面——丢弃可观测),
|
|
239
|
+
// 首次饱和打一行 stderr(每饱和段一次;排空后复位)。
|
|
240
|
+
if (_pendingWrites >= TRACE_PENDING_MAX) {
|
|
241
|
+
_droppedRecords++
|
|
242
|
+
if (!_saturationNotified) {
|
|
243
|
+
_saturationNotified = true
|
|
244
|
+
console.error(`[trace] pending write queue full — ${_droppedRecords} record(s) dropped`)
|
|
245
|
+
}
|
|
246
|
+
return
|
|
247
|
+
}
|
|
145
248
|
// cwd/session 经 logCtx 增补(调用点传 agent.cwd / agent._sessionStart——
|
|
146
249
|
// D-TR3 的 cwdHash/命名与 D-TR1 的 session 字段所需);无 agent 作用域的调用点
|
|
147
250
|
// 回退 process.cwd()——CLI 会话即工作区。该类点开关状态:distill 已含
|
|
@@ -154,44 +257,72 @@ export function recordChatTrace(provider, opts = {}, result = null, error = null
|
|
|
154
257
|
const dateStr = localDateStr(now)
|
|
155
258
|
const seq = nextTraceSeq(dateStr)
|
|
156
259
|
const sessionKey = traceSessionKey(cwd)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
stage: logCtx.stage ?? null,
|
|
167
|
-
kind: logCtx.kind ?? null,
|
|
168
|
-
// D-TR1(fix round1):续写/重试链标记——true = 该调用是续写链的一环
|
|
169
|
-
// (core.mjs 续写递归传出);新调用缺省 false——round 字段已删(无来源)。
|
|
170
|
-
isContinuation: logCtx.isContinuation === true,
|
|
171
|
-
messages: redactValue("messages", opts.messages ?? []),
|
|
172
|
-
content: redactValue("content", result?.content ?? null),
|
|
173
|
-
reasoning: redactValue("reasoning", result?.reasoning ?? null),
|
|
174
|
-
toolCalls: redactValue("toolCalls", result?.toolCalls ?? null),
|
|
175
|
-
usage: result?.usage ?? null,
|
|
176
|
-
finishReason: result?.finishReason ?? null,
|
|
260
|
+
|
|
261
|
+
// 单遍序列化:头段字段 + messages(独立缓冲——记录级额度降级用)+ 尾段字段。
|
|
262
|
+
const parts = ["{"]
|
|
263
|
+
let first = true
|
|
264
|
+
const put = (k, v) => {
|
|
265
|
+
if (!first) parts.push(",")
|
|
266
|
+
first = false
|
|
267
|
+
parts.push(JSON.stringify(k), ":")
|
|
268
|
+
writeValue(parts, k, v)
|
|
177
269
|
}
|
|
270
|
+
put("ts", now.toISOString())
|
|
271
|
+
put("session", logCtx.session ?? null)
|
|
272
|
+
put("cwdHash", createHash("sha1").update(normalizeCwd(cwd)).digest("hex"))
|
|
273
|
+
put("role", logCtx.role ?? null)
|
|
274
|
+
put("depth", logCtx.depth ?? null)
|
|
275
|
+
put("turn", logCtx.turn ?? null)
|
|
276
|
+
put("provider", provider?.name ?? provider?.model ?? "unknown")
|
|
277
|
+
put("model", provider?.model ?? "")
|
|
278
|
+
put("stage", logCtx.stage ?? null)
|
|
279
|
+
put("kind", logCtx.kind ?? null)
|
|
280
|
+
// D-TR1(fix round1):续写/重试链标记——true = 该调用是续写链的一环
|
|
281
|
+
// (core.mjs 续写递归传出);新调用缺省 false——round 字段已删(无来源)。
|
|
282
|
+
put("isContinuation", logCtx.isContinuation === true)
|
|
283
|
+
const msgAt = parts.length
|
|
284
|
+
const msgParts = []
|
|
285
|
+
writeValue(msgParts, "messages", opts.messages ?? [])
|
|
286
|
+
put("content", result?.content ?? null)
|
|
287
|
+
put("reasoning", result?.reasoning ?? null)
|
|
288
|
+
put("toolCalls", result?.toolCalls ?? null)
|
|
289
|
+
put("usage", result?.usage ?? null)
|
|
290
|
+
put("finishReason", result?.finishReason ?? null)
|
|
178
291
|
if (error != null) {
|
|
179
292
|
// D-TR5:错误路径轨迹——error(errText 截断 + 类别)+ finishReason:null
|
|
180
|
-
|
|
181
|
-
err:
|
|
293
|
+
put("error", {
|
|
294
|
+
err: redactSecret("err", errText(error, 500)),
|
|
182
295
|
kind: classifyErr(error, opts.signal),
|
|
183
|
-
}
|
|
296
|
+
})
|
|
184
297
|
}
|
|
298
|
+
const headStr = parts.slice(0, msgAt).join("")
|
|
299
|
+
const tailStr = parts.slice(msgAt).join("")
|
|
300
|
+
const msgStr = msgParts.join("")
|
|
301
|
+
const msgsArr = Array.isArray(opts.messages) ? opts.messages : []
|
|
302
|
+
const record = (() => {
|
|
303
|
+
const total = headStr.length + msgStr.length + tailStr.length + 1
|
|
304
|
+
if (total > TRACE_RECORD_MAX_CHARS) {
|
|
305
|
+
// 单记录额度(§23.3.2):messages 整体降级 stub——元数据字段保留、标记含计数
|
|
306
|
+
const stub = `[trace record truncated for size: ${total} chars / ${msgsArr.length} messages]`
|
|
307
|
+
return `${headStr},${JSON.stringify("messages")}:${JSON.stringify(stub)}${tailStr}}`
|
|
308
|
+
}
|
|
309
|
+
return `${headStr},${JSON.stringify("messages")}:${msgStr}${tailStr}}`
|
|
310
|
+
})()
|
|
311
|
+
|
|
185
312
|
// 真 fire-and-forget(F-TR3——模型调用路径零额外阻塞):seq 已在上面同步预留
|
|
186
313
|
// (原子号位——写盘在途并发不撞号);写盘异步(不 await——chat() 出口立即返回)。
|
|
187
314
|
// 返回值 = 落盘 promise(测试 await 用;chat() 不消费——fire-and-forget 语义)。
|
|
315
|
+
_pendingWrites++
|
|
188
316
|
return (async () => {
|
|
189
317
|
try {
|
|
190
318
|
const dir = tracesDirFor(dateStr)
|
|
191
|
-
await mkdir(dir
|
|
192
|
-
await
|
|
319
|
+
await _traceHooks.mkdir(dir) // D-TR3:写前建目录(与 sessions/tool-results 同惯例)
|
|
320
|
+
await _traceHooks.append(join(dir, `${sessionKey}-${seq}.jsonl`), record + "\n")
|
|
193
321
|
} catch {
|
|
194
322
|
// F-TR3:落盘失败静默降级——不抛错、不阻塞 chat() 返回
|
|
323
|
+
} finally {
|
|
324
|
+
_pendingWrites--
|
|
325
|
+
if (_pendingWrites === 0) _saturationNotified = false // 排空 → 下个饱和段可再报一次
|
|
195
326
|
}
|
|
196
327
|
})()
|
|
197
328
|
}
|
package/src/tui/cmd-clear.mjs
CHANGED
|
@@ -9,12 +9,14 @@ export async function handleClearCommand(ctx) {
|
|
|
9
9
|
], { defaultIndex: 1 })
|
|
10
10
|
if (e?.action === "yes") {
|
|
11
11
|
state.lines = []
|
|
12
|
+
state._linesChars = 0 // TUI-OOM-ROOTCAUSE(§15.3.2):行集清空 → 字符账同步归零
|
|
12
13
|
state.streaming = ""
|
|
13
14
|
render()
|
|
14
15
|
}
|
|
15
16
|
return
|
|
16
17
|
}
|
|
17
18
|
state.lines = []
|
|
19
|
+
state._linesChars = 0 // TUI-OOM-ROOTCAUSE(§15.3.2):行集清空 → 字符账同步归零
|
|
18
20
|
state.streaming = ""
|
|
19
21
|
render()
|
|
20
22
|
}
|
package/src/tui/cmd-new.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newSession, resetSessionState } from "../session.mjs"
|
|
1
|
+
import { newSession, resetSessionState, bindRecordStore, slotPath } from "../session.mjs"
|
|
2
2
|
import { C } from "./ansi.mjs"
|
|
3
3
|
|
|
4
4
|
/** /new command: start a new session in a fresh slot.
|
|
@@ -14,8 +14,12 @@ export async function handleNewCommand(ctx) {
|
|
|
14
14
|
// slot(实锤 .19/.3 双副本)。_slot 更新为粘性新槽位。
|
|
15
15
|
resetSessionState(agent)
|
|
16
16
|
agent._slot = slot
|
|
17
|
+
// §14.3.4:/new 绑定新槽记录存储(baseHistory 空——identity 此时为 null = 待固化;
|
|
18
|
+
// 首次保存/首条追加时补写现场身份)
|
|
19
|
+
bindRecordStore(agent, { slotFile: slotPath(agent.cwd, slot), identity: agent._sessionStart ?? null, baseHistory: [] })
|
|
17
20
|
state.tasks = []
|
|
18
21
|
state.lines = []
|
|
22
|
+
state._linesChars = 0 // TUI-OOM-ROOTCAUSE(§15.3.2):行集清空 → 字符账同步归零
|
|
19
23
|
state.streaming = ""
|
|
20
24
|
render()
|
|
21
25
|
pushLine(`New session started (slot ${slot}; /session to switch back)`, C.dim)
|