thincoder 0.12.58 → 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.
Files changed (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +467 -193
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. package/src/tools/exec-prelude.mjs +0 -84
@@ -0,0 +1,21 @@
1
+ Read a PDF file and extract its text as plain text — page by page, in reading order (row/column layout, `--- Page N ---` separators). Use this instead of `read` for `.pdf` files: read decodes PDFs as UTF-8 garbage.
2
+
3
+ **Route to read_pdf instead of bash:** `pdftotext`, python `pypdf`/`pdfminer`, node pdf libraries → read_pdf (zero-dependency built-in extractor).
4
+
5
+ Parameters:
6
+ - path (required): PDF file path (relative to cwd or absolute).
7
+ - pages: which pages to extract, e.g. `"1-3,5"` (1-based PDF page numbers, ranges inclusive). Default: all pages; at most 50 pages per call (larger documents need a `pages` selection).
8
+
9
+ What it handles:
10
+ - Text PDFs from real producers: Chrome/Edge print, Word, LibreOffice, LaTeX, Quartz — xref tables and xref streams, object streams, Flate + PNG predictors, Type0/CID fonts with ToUnicode CMaps (bfchar/bfrange), simple fonts via WinAnsi/StandardEncoding/MacRoman (+ /Differences), TJ kerning, ligature ActualText spans, and double/triple-column layout (light x-cluster column detection; tables are NOT reconstructed — table cell text reads in row/column flow).
11
+ - Scanned/image-only pages (no text operators): the page image is returned via the multimodal channel (`{ text, images }` JSON, like read_image) so a vision-capable model can read it. DCTDecode (JPEG) images pass through; 8-bit Flate gray/RGB images are converted to PNG. This needs a multimodal (vision) provider — under a text-only model such pages degrade to a hint telling you to switch providers.
12
+
13
+ Limits & refusals (explicit, never silent):
14
+ - Text-only results follow the read-family pipeline: extraction over 64KB is saved to the standard offload file (preview + path returned — page the file with read). When scanned-page images are attached, the JSON envelope rides the multimodal channel inline; text is capped at 60KB and images at 25MB cumulative per call (per-image 15MB) — narrow the pages range to read large scans in batches.
15
+ - Encrypted PDFs (/Encrypt) are refused with a clear message — decrypt first.
16
+ - Unsupported forms (Type3 glyph runs without ToUnicode, symbolic fonts without mappings, LZW-compressed streams, CMap-preset fonts without ToUnicode, JBIG2/JPX/CCITT images, palette/CMYK images) produce warnings or inline notes — output is best-effort and never silently wrong.
17
+ - Files >100MB refused; results never inflate past 512MB per stream (hostile-PDF guards: xref chains, recursion, operand floods).
18
+
19
+ Notes:
20
+ - Extraction is text-layer only — it does not OCR. Scanned pages without a text layer rely on the multimodal image channel.
21
+ - Layout is best-effort for irregular designs (rotated text, complex tables, text boxes).
@@ -297,7 +297,7 @@ export function repoOutlineTool(db, cwd) {
297
297
  return {
298
298
  name: "repo_outline",
299
299
  description:
300
- "Show the project's file dependency outline: which files import/export from which, and what symbols they export. Use when you need to understand the project structure, find where a function is defined, or see what files depend on a module. Pass a path to focus on a single file's relationships.",
300
+ "Show the project's file dependency outline: which files import/export from which, and what symbols they export. Use when you need to understand the project structure, find where a function is defined, or see what files depend on a module. Pass a path to focus on a single file's relationships. Find code by keyword or snippet with code_search.",
301
301
  parameters: {
302
302
  type: "object",
303
303
  properties: {
@@ -296,8 +296,8 @@ export function shellSegments(command) {
296
296
 
297
297
  /**
298
298
  * Blank out quoted regions (single/double/backtick) with spaces, preserving length.
299
- * Lets safety checks ignore shell metacharacters inside quoted script bodies —
300
- * e.g. `node -e "if (a > b) …"` comparisons are not redirections.
299
+ * Lets detectDanger ignore shell metacharacters inside quoted script bodies —
300
+ * e.g. `node -e "if (a > b) …"` comparisons are not danger signals.
301
301
  */
302
302
  function blankQuoted(command) {
303
303
  let out = ""
@@ -308,8 +308,8 @@ function blankQuoted(command) {
308
308
  if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
309
309
  if (ch === quote) { quote = null; out += " "; continue }
310
310
  // Backticks are COMMAND SUBSTITUTION — the content executes, so it must
311
- // stay visible to the redirection check (echo `cat > /tmp/x` writes a
312
- // file). Only ' and " are literal regions.
311
+ // stay visible to detectDanger (echo `cat > /tmp/x` still runs). Only '
312
+ // and " are literal regions.
313
313
  out += quote === "`" ? ch : " "
314
314
  } else if (ch === "'" || ch === '"' || ch === "`") {
315
315
  quote = ch
@@ -321,14 +321,6 @@ function blankQuoted(command) {
321
321
  return out
322
322
  }
323
323
 
324
- /** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions.
325
- * Backtick contents count (command substitution executes); fd-prefixed forms
326
- * (2> file, 1>> file) count too. */
327
- export function hasFileRedirection(command) {
328
- const bare = blankQuoted(command)
329
- return /(^|[\s;&|0-9])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|0-9])<\s*\S/.test(bare)
330
- }
331
-
332
324
  /**
333
325
  * Whether a single command segment is destructive — ALWAYS FALSE (deliberate).
334
326
  *
@@ -6,7 +6,6 @@ import {
6
6
  BASH_TIMEOUT_MS,
7
7
  IGNORED_DIRS,
8
8
  resolveInCwd,
9
- hasFileRedirection,
10
9
  globToRegex,
11
10
  normalizeEOL,
12
11
  } from "./shared.mjs";
@@ -51,20 +50,6 @@ function posixSyntaxHint(command) {
51
50
  // bash — command execution with safety gates
52
51
  // ====================================================================
53
52
 
54
- /**
55
- * Pre-execution safety checks for bash commands.
56
- * Layers: file redirection (guides toward structured tools, not a security gate).
57
- * Destructive commands (rm -rf, DROP TABLE, ...) are deliberately NOT rejected:
58
- * a determined model bypasses text matching anyway — real security is at the
59
- * tool approval layer plus snapshot backups (gitGuardSnapshot / checkpoint).
60
- * Git destructive ops: snapshot-then-proceed, never block.
61
- */
62
- function checkBashSafety(command, cwd) {
63
- if (hasFileRedirection(command)) {
64
- throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
65
- }
66
- }
67
-
68
53
  /**
69
54
  * Build environment for child process.
70
55
  * Passes through all parent env vars, with non-interactive overrides (EDITOR/PAGER/TERM).
@@ -113,11 +98,12 @@ function killProcessTree(child) {
113
98
  * cannot help (it was taken before the code was written); only a snapshot taken
114
99
  * immediately before the destructive command can.
115
100
  *
116
- * This is layer 1 (defense in depth): a WIDE match that snapshots before the command
117
- * runs. Layer 2 (checkBashSafety) then rejects the command when uncommitted changes
118
- * exist but a rejection alone is not enough: the model may retry a variant that
119
- * slips through the exact matcher (e.g. `git checkout HEAD -- .`), or run git outside
120
- * the bash tool. The snapshot taken here survives all of those paths.
101
+ * This is the defense-in-depth guard: a WIDE match that snapshots before the command
102
+ * runs the command itself is NEVER blocked (a determined model bypasses text
103
+ * matching anyway; the real gate is the approval layer). A snapshot alone is not
104
+ * enough: the model may retry a variant that slips through the exact matcher
105
+ * (e.g. `git checkout HEAD -- .`), or run git outside the bash tool. The snapshot
106
+ * taken here survives all of those paths.
121
107
  *
122
108
  * Matching is intentionally WIDE (false positives are harmless — one extra snapshot;
123
109
  * a missed match is a data-loss disaster).
@@ -266,7 +252,6 @@ export const bashTool = {
266
252
  // guard anyway. Instead: snapshot every uncommitted file first, then ALLOW
267
253
  // the command. The snapshot makes the rollback reversible (defense in depth:
268
254
  // the wide matcher also covers variants like `git checkout HEAD -- .`).
269
- checkBashSafety(args.command, ctx.cwd)
270
255
  const guard = await gitGuardSnapshot(args.command, ctx.cwd)
271
256
  const result = await runBash(args.command, ctx.cwd, {
272
257
  timeout: args.timeout ?? BASH_TIMEOUT_MS,
package/src/tools/tree.md CHANGED
@@ -10,4 +10,5 @@ Parameters:
10
10
  Notes:
11
11
  - Capped at 200 entries.
12
12
  - Directories end with `/`; tree-drawing uses `├──`/`└──`/`│`.
13
- - Use depth for a shallow overview; use `ls` for one directory, `glob` for a specific file pattern.
13
+ - Use depth for a shallow overview; use `ls` for one directory, `glob` for a specific file pattern.
14
+ - Returns the directory text tree — directories first (`dir/`), files after, both sorted, capped at 200 entries.
package/src/tools/web.mjs CHANGED
@@ -4,6 +4,8 @@ import { proxyFetch } from "../proxy.mjs";
4
4
 
5
5
  export const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
6
6
  const FETCH_TIMEOUT = 15_000
7
+ // §14 D-TF3:网络失败错误文本追加 proxy 提示——纯文本提示,不自动路由(2026-08-31 裁定:proxy 显式传不自动应用)
8
+ const PROXY_HINT = "network failure — retry with proxy: 'http://host:port' if the target is blocked"
7
9
 
8
10
  // ── Web search (Bing; direct by default, through proxy when configured and web toggle on) ──
9
11
 
@@ -204,19 +206,19 @@ export const fetchTool = {
204
206
  const r = resolveRedirectTarget(loc, args.url)
205
207
  if (r.error) throw new Error(`fetch failed: ${r.error}`)
206
208
  const r2 = await proxyFetch(r.target, { headers: { "User-Agent": UA } }, proxyUri)
207
- if (!r2.ok) throw new Error(`fetch failed: HTTP ${r2.status}`)
209
+ if (!r2.ok) throw new Error(`fetch failed: HTTP ${r2.status}\n${PROXY_HINT}`)
208
210
  const ct2 = headerOf(r2, "content-type") ?? ""
209
211
  const b2 = await r2.text()
210
212
  if (ct2.includes("text/html")) { const t = htmlToText(b2); return truncate(t + detectSparseHtml(b2, t)) }
211
213
  return truncate(b2)
212
214
  }
213
215
  }
214
- throw new Error(`fetch failed: HTTP ${response.status}`)
216
+ throw new Error(`fetch failed: HTTP ${response.status}\n${PROXY_HINT}`)
215
217
  }
216
218
  const ct = headerOf(response, "content-type") ?? ""
217
219
  const body = await response.text()
218
220
  if (ct.includes("text/html")) { const t = htmlToText(body); return truncate(t + detectSparseHtml(body, t)) }
219
221
  return truncate(body)
220
- } catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}`, { cause: e }) }
222
+ } catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}\n${PROXY_HINT}`, { cause: e }) }
221
223
  },
222
224
  }
@@ -8,7 +8,8 @@ Parameters:
8
8
  - proxy: http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling); Bing/foreign sites usually need a proxy, domestic targets don't
9
9
 
10
10
  Notes:
11
- - Before searching the web, call `memory_search` first — you may already know the answer from a previous session. Only reach for websearch if memory comes up empty.
11
+ - Before searching the web, call the memory tool (action: search) first — you may already know the answer from a previous session. Only reach for websearch if memory comes up empty.
12
+ - Runs synchronously — returns when the search completes (all selected engines run in parallel).
12
13
  - Use this for information that is NOT in the local codebase — current docs, error messages, API references
13
14
  - Follow up with `fetch` to read full pages from the results
14
15
  - **Weak engine warning**: Bing's index is noisy for technical queries — if a first websearch returns irrelevant/townhall-grade results, DO NOT retry the same query. Configure a search MCP tool (e.g. `glm-websearch` via the MCP config) for technical lookups; websearch is the fallback.
@@ -5,5 +5,7 @@ Parameters:
5
5
  - content (required): Full content to write
6
6
 
7
7
  Notes:
8
+ - write replaces the WHOLE file — read it first and confirm you intend to rewrite it entirely; for a small change use edit / insert_after.
9
+ - Returns `Wrote N chars to <path>` + git diff + syntax-check note.
8
10
  - This overwrites the entire file — use `edit` for targeted changes
9
11
  - The file is atomic: it either writes completely or fails
@@ -0,0 +1,224 @@
1
+ /**
2
+ * trace-store.mjs — §18.6 完整轨迹存档(AGENT-LOOP.md §18.6 D-TR1..TR8 权威规格)。
3
+ *
4
+ * 完整请求-响应轨迹落盘:每次 chat() 调用一个 JSONL 文件到
5
+ * ~/.thincoder/traces/YYYY-MM-DD/<sessionKey>-<seq>.jsonl。采集点唯一 = core.mjs
6
+ * chat() 导出出口(所有模型调用——主回合/消化轮/compress/distill/advisor/子代理/
7
+ * auto-think/consult——都经该函数)——续写/重试在出口已合并(reasoning 全量)。
8
+ *
9
+ * 纪律(与 log.mjs 同源惯例):
10
+ * - 真 fire-and-forget(F-TR3——模型调用路径零额外阻塞):seq 同步预留(原子号位),
11
+ * 写盘异步(node:fs/promises——不 await);落盘失败静默降级(不抛错、不阻塞
12
+ * chat() 返回)。recordChatTrace 返回落盘 promise——仅供测试/显式消费方 await。
13
+ * - 测试隔离:node --test 进程(NODE_TEST_CONTEXT)默认不写盘——防测试事件污染真实
14
+ * 轨迹目录(既有测试跑真实 agent 管线会产生数百次 chat 调用);显式设置
15
+ * THINCODER_TRACES_DIR 强制写入该目录(traces.test.mjs 用它隔离临时目录——
16
+ * 与 log.mjs 的 THINCODER_LOG_DIR 同惯例)。
17
+ * - 脱敏(D-TR2):复用 log.mjs 字段名黑名单(apikey/designtoken/password/secret/
18
+ * token/authorization/proxyuri/proxy)+ SECRET_FORM 形态扫描(redactSecret)——对
19
+ * messages/content/reasoning/toolCalls/error 全字段递归应用;不发明新遮蔽模式。
20
+ * - 容量有界(N-TR1):单条轨迹一个文件,大小不限("成本不是问题"——不截断、不
21
+ * 脱漏行);目录按日组织(YYYY-MM-DD),可按日/会话过滤;不自动清理(D-TR8——
22
+ * 清理机制记 docs/TODO.md 技术组)。
23
+ * - seq = 当日目录内最大已有 seq + 1(D-TR3——跨会话/进程重启不覆写既有旧轨迹——
24
+ * 18.6.1 评审 #3)。
25
+ * - 日期分日按本地时区(D-TR3——2026-09-04 fix round1:初版 toISOString()=UTC——
26
+ * 本地 00:00-08:00 的调用会落进前一日目录——改本地日期字符串 YYYY-MM-DD——T-TR12)。
27
+ * - isContinuation(D-TR1——2026-09-04 fix round1):续写/重试链标记——true = 该调用
28
+ * 是续写链的一环(core.mjs 续写递归传出 logCtx.isContinuation);新调用 false。
29
+ * 记录不输出 round 字段(全设计无 round 定义——删——不发明无来源字段)。
30
+ */
31
+ import { readdirSync, existsSync } from "node:fs"
32
+ import { appendFile, mkdir, readdir, stat, unlink, rmdir } from "node:fs/promises"
33
+ import { join } from "node:path"
34
+ import { createHash } from "node:crypto"
35
+ import { configDir } from "../config.mjs"
36
+ import { redactSecret, errText, classifyErr } from "../log.mjs"
37
+ import { normalizeCwd } from "../session-slots.mjs"
38
+
39
+ /** 轨迹根目录:THINCODER_TRACES_DIR(测试隔离/override——同 THINCODER_LOG_DIR
40
+ * 惯例)> ~/.thincoder/traces(configDir——D-TR3——与 sessions/ 同域)。 */
41
+ export function tracesRoot() {
42
+ return process.env.THINCODER_TRACES_DIR ?? join(configDir, "traces")
43
+ }
44
+
45
+ /** 写门(测试隔离):test runner 进程(NODE_TEST_CONTEXT)默认跳过——除显式
46
+ * THINCODER_TRACES_DIR override(traces.test.mjs 隔离临时目录)。 */
47
+ function writeEnabled() {
48
+ if (process.env.NODE_TEST_CONTEXT && !process.env.THINCODER_TRACES_DIR) return false
49
+ return true
50
+ }
51
+
52
+ /** sessionKey = sha1(normalizeCwd(cwd))[:12](D-TR3——与 session-slots sessionPath
53
+ * 同算法同 cwd 归一——两端 hash 一致)。 */
54
+ export function traceSessionKey(cwd) {
55
+ return createHash("sha1").update(normalizeCwd(cwd)).digest("hex").slice(0, 12)
56
+ }
57
+
58
+ /** 本地时区日期字符串 YYYY-MM-DD(D-TR3——2026-09-04 fix round1:初版
59
+ * toISOString()=UTC 日期——本地 00:00-08:00 落前一日目录;本地日期才是用户视角的
60
+ * "今天"——T-TR12)。recordChatTrace 与测试读取 helpers 共用同一实现——不双写。 */
61
+ export function localDateStr(date = new Date()) {
62
+ const y = date.getFullYear()
63
+ const m = String(date.getMonth() + 1).padStart(2, "0")
64
+ const d = String(date.getDate()).padStart(2, "0")
65
+ return `${y}-${m}-${d}`
66
+ }
67
+
68
+ /** Test hooks(_rateHooks 同惯例——rate.mjs:测试可替换 now——T-TR12 注入本地日 ≠
69
+ * UTC 日的时刻验证分日;生产永远走真实时钟)。 */
70
+ export const _traceHooks = {
71
+ now: () => new Date(),
72
+ }
73
+
74
+ /** 当日轨迹目录(D-TR3:traces/YYYY-MM-DD——分日组织——N-TR1)。 */
75
+ export function tracesDirFor(dateStr) {
76
+ return join(tracesRoot(), dateStr)
77
+ }
78
+
79
+ // 进程内 seq 预留表:同步预留(原子——异步写盘在途时并发调用不撞号);键 = 目录。
80
+ // 跨进程/重启由磁盘 max 兜底(同键无预留时磁盘值即事实——T-TR10)。
81
+ const _reservedSeq = new Map()
82
+
83
+ /** seq = max(当日目录最大已有 seq, 进程内已预留) + 1(D-TR3——跨会话/进程重启
84
+ * 不覆写——T-TR8/T-TR10;同步预留 = 异步写盘启动前的原子号位分配)。 */
85
+ export function nextTraceSeq(dateStr) {
86
+ const dir = tracesDirFor(dateStr)
87
+ let max = 0
88
+ if (existsSync(dir)) {
89
+ let names
90
+ try {
91
+ names = readdirSync(dir)
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
+ }
102
+ }
103
+ const seq = Math.max(max, _reservedSeq.get(dir) ?? 0) + 1
104
+ _reservedSeq.set(dir, seq)
105
+ return seq
106
+ }
107
+
108
+ /** 开关(D-TR6):traces.enabled 缺省 on(默认全采集);logCtx.traces === false
109
+ * 显式关闭(调用点读取 agent.config.traces.enabled——D-TR6)。 */
110
+ export function tracesEnabled(logCtx) {
111
+ return logCtx?.traces !== false
112
+ }
113
+
114
+ /** 递归脱敏(D-TR2):对 messages/content/reasoning/toolCalls/error 全字段应用——
115
+ * 字段名命中黑名单 → 遮蔽;字符串内容命中密钥形态(sk-/Bearer/-key=)→ 截断到
116
+ * 形态前 + 标记。数组/对象递归(消息 content 可为 parts 数组、toolCalls 嵌套)。 */
117
+ function redactValue(fieldKey, value) {
118
+ if (typeof value === "string") return redactSecret(fieldKey, value)
119
+ if (Array.isArray(value)) return value.map((v) => redactValue(fieldKey, v))
120
+ if (value && typeof value === "object") {
121
+ const out = {}
122
+ for (const [k, v] of Object.entries(value)) out[k] = redactValue(k, v)
123
+ return out
124
+ }
125
+ return value
126
+ }
127
+
128
+ /**
129
+ * 收集一次 chat 调用轨迹(D-TR1 字段集)——fire-and-forget(F-TR3)。
130
+ * 由 core.mjs chat() 导出出口调用(唯一采集点——N-TR2);签名零参数膨胀——
131
+ * 数据全部来自调用方已传入的 opts(logCtx 元数据——D-TR4)+ provider + result/error。
132
+ *
133
+ * @param {Object} provider chat() 的 provider 参数(provider/model 字段来源)
134
+ * @param {Object} opts chat() 原始 opts —— messages(输入)与 logCtx(元数据:
135
+ * role/depth/kind/session/cwd/stage/turn/traces——调用点增补,D-TR4)
136
+ * @param {Object|null} result chatImpl 返回值(成功路径——content/reasoning/
137
+ * toolCalls/usage/finishReason;N-TR3:出口汇总——续写/重试后全量)
138
+ * @param {Error|null} error chat() 抛出的错误(失败路径——D-TR5:error(errText
139
+ * 截断 + 类别)+ finishReason:null——失败轨迹恰是分析纠结点最有效的材料)
140
+ */
141
+ export function recordChatTrace(provider, opts = {}, result = null, error = null) {
142
+ if (!writeEnabled()) return
143
+ const logCtx = opts.logCtx ?? {}
144
+ if (!tracesEnabled(logCtx)) return
145
+ // cwd/session 经 logCtx 增补(调用点传 agent.cwd / agent._sessionStart——
146
+ // D-TR3 的 cwdHash/命名与 D-TR1 的 session 字段所需);无 agent 作用域的调用点
147
+ // 回退 process.cwd()——CLI 会话即工作区。该类点开关状态:distill 已含
148
+ // traces 开关(D-TR6 fix round1);auto-think 已闭环(D-TS12——chat 调用点
149
+ // logCtx 全字段补传:traces/session/cwd/role/depth/kind——无残留点)。
150
+ const cwd = logCtx.cwd ?? process.cwd()
151
+ // D-TR3(fix round1):分日按本地时区(_traceHooks.now——T-TR12 注入点);
152
+ // ts 与 dateStr 同源(同一时刻)——记录时间戳与目录日不撕裂。
153
+ const now = _traceHooks.now()
154
+ const dateStr = localDateStr(now)
155
+ const seq = nextTraceSeq(dateStr)
156
+ const sessionKey = traceSessionKey(cwd)
157
+ const record = {
158
+ ts: now.toISOString(),
159
+ session: logCtx.session ?? null,
160
+ cwdHash: createHash("sha1").update(normalizeCwd(cwd)).digest("hex"),
161
+ role: logCtx.role ?? null,
162
+ depth: logCtx.depth ?? null,
163
+ turn: logCtx.turn ?? null,
164
+ provider: provider?.name ?? provider?.model ?? "unknown",
165
+ model: provider?.model ?? "",
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,
177
+ }
178
+ if (error != null) {
179
+ // D-TR5:错误路径轨迹——error(errText 截断 + 类别)+ finishReason:null
180
+ record.error = {
181
+ err: redactValue("err", errText(error, 500)),
182
+ kind: classifyErr(error, opts.signal),
183
+ }
184
+ }
185
+ // 真 fire-and-forget(F-TR3——模型调用路径零额外阻塞):seq 已在上面同步预留
186
+ // (原子号位——写盘在途并发不撞号);写盘异步(不 await——chat() 出口立即返回)。
187
+ // 返回值 = 落盘 promise(测试 await 用;chat() 不消费——fire-and-forget 语义)。
188
+ return (async () => {
189
+ try {
190
+ const dir = tracesDirFor(dateStr)
191
+ await mkdir(dir, { recursive: true }) // D-TR3:写前建目录(与 sessions/tool-results 同惯例)
192
+ await appendFile(join(dir, `${sessionKey}-${seq}.jsonl`), JSON.stringify(record) + "\n", "utf8")
193
+ } catch {
194
+ // F-TR3:落盘失败静默降级——不抛错、不阻塞 chat() 返回
195
+ }
196
+ })()
197
+ }
198
+
199
+ /**
200
+ * D-TR10(2026-09-05 用户裁定——发布隐私 + 磁盘卫生):启动清理——删除 traces 根下
201
+ * mtime 超过保留期的轨迹文件(保留期 = config.traces.retentionHours,默认 24h);
202
+ * 删空的日期目录(YYYY-MM-DD)。目录里非 .jsonl 文件不碰。CLI 启动点 fire-and-forget
203
+ * 调用(不 await——不阻塞启动——失败静默——与轨迹写盘同纪律)。返回删除文件数。
204
+ */
205
+ export async function cleanupTraces({ dir = tracesRoot(), retentionHours = 24 } = {}) {
206
+ const cutoff = Date.now() - retentionHours * 3_600_000
207
+ let days
208
+ try { days = await readdir(dir) } catch { return 0 } // 目录不存在/不可读 → 无事可做
209
+ let removed = 0
210
+ for (const day of days) {
211
+ const dayDir = join(dir, day)
212
+ try { if (!(await stat(dayDir)).isDirectory()) continue } catch { continue }
213
+ let names
214
+ try { names = await readdir(dayDir) } catch { continue }
215
+ for (const n of names) {
216
+ if (!n.endsWith(".jsonl")) continue
217
+ try {
218
+ if ((await stat(join(dayDir, n))).mtimeMs < cutoff) { await unlink(join(dayDir, n)); removed++ }
219
+ } catch { /* 单个文件失败不影响其余 */ }
220
+ }
221
+ try { if ((await readdir(dayDir)).length === 0) await rmdir(dayDir) } catch {}
222
+ }
223
+ return removed
224
+ }