thincoder 0.8.2 → 0.8.4

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 (98) hide show
  1. package/README.md +2 -1
  2. package/bin/thincoder.mjs +25 -25
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +17 -12
  5. package/src/agent/helpers.mjs +22 -11
  6. package/src/agent/setup.mjs +32 -10
  7. package/src/agent-tools/goal.mjs +12 -11
  8. package/src/agent-tools/plan.mjs +5 -6
  9. package/src/agent-tools/recent-changes.mjs +3 -3
  10. package/src/agent-tools/skill.mjs +6 -6
  11. package/src/agent-tools/subagent.mjs +19 -18
  12. package/src/agent-tools/task.mjs +7 -26
  13. package/src/agent-tools/verify.mjs +21 -19
  14. package/src/agent-tools.mjs +3 -3
  15. package/src/agent.mjs +82 -92
  16. package/src/cli/distill-command.mjs +2 -2
  17. package/src/cli/make-agent.mjs +9 -9
  18. package/src/cli/memory-command.mjs +1 -1
  19. package/src/cli/permission.mjs +3 -3
  20. package/src/cli/setup-wizard.mjs +15 -15
  21. package/src/config.mjs +56 -53
  22. package/src/context.mjs +59 -61
  23. package/src/distill.mjs +35 -35
  24. package/src/embedding.mjs +17 -17
  25. package/src/git/checkpoint.mjs +211 -38
  26. package/src/git/gitmem.mjs +21 -20
  27. package/src/markdown.mjs +13 -13
  28. package/src/mcp/helpers.mjs +6 -1
  29. package/src/mcp/transport-http.mjs +2 -1
  30. package/src/mcp/transport-stdio.mjs +3 -2
  31. package/src/mcp/transport-ws.mjs +3 -2
  32. package/src/mcp.mjs +5 -2
  33. package/src/memory/code-index.mjs +16 -14
  34. package/src/memory/code-sync.mjs +36 -26
  35. package/src/memory/core.mjs +42 -35
  36. package/src/memory/docs.mjs +24 -15
  37. package/src/memory/schema.mjs +28 -27
  38. package/src/memory.mjs +2 -2
  39. package/src/prompts/coder.md +0 -6
  40. package/src/prompts/discipline.md +9 -0
  41. package/src/prompts/main.md +24 -24
  42. package/src/prompts/system.md +21 -16
  43. package/src/provider/core.mjs +11 -6
  44. package/src/provider/index.mjs +2 -2
  45. package/src/provider/rate.mjs +11 -11
  46. package/src/session.mjs +73 -42
  47. package/src/skills.mjs +17 -17
  48. package/src/tools/checkpoint.md +6 -2
  49. package/src/tools/file.mjs +20 -14
  50. package/src/tools/git.mjs +83 -12
  51. package/src/tools/index.mjs +1 -1
  52. package/src/tools/ls.md +1 -1
  53. package/src/tools/patch.mjs +20 -18
  54. package/src/tools/repomap-parse.mjs +17 -17
  55. package/src/tools/repomap.mjs +29 -29
  56. package/src/tools/shared.mjs +55 -28
  57. package/src/tools/system.mjs +163 -118
  58. package/src/tools/web.mjs +6 -6
  59. package/src/tui/agent-turn.mjs +82 -26
  60. package/src/tui/ansi.mjs +5 -3
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-auto.mjs +3 -12
  63. package/src/tui/cmd-clear.mjs +1 -1
  64. package/src/tui/cmd-config.mjs +108 -23
  65. package/src/tui/cmd-exit.mjs +2 -2
  66. package/src/tui/cmd-extract.mjs +11 -3
  67. package/src/tui/cmd-goal.mjs +3 -12
  68. package/src/tui/cmd-help.mjs +2 -2
  69. package/src/tui/cmd-init.mjs +3 -3
  70. package/src/tui/cmd-mcp.mjs +38 -25
  71. package/src/tui/cmd-model.mjs +1 -1
  72. package/src/tui/cmd-new.mjs +33 -13
  73. package/src/tui/cmd-plan.mjs +3 -12
  74. package/src/tui/cmd-reindex.mjs +2 -2
  75. package/src/tui/cmd-restore.mjs +1 -1
  76. package/src/tui/cmd-session.mjs +1 -1
  77. package/src/tui/cmd-skills.mjs +1 -1
  78. package/src/tui/cmd-think.mjs +4 -11
  79. package/src/tui/config-helpers.mjs +6 -6
  80. package/src/tui/distill-cmd.mjs +4 -3
  81. package/src/tui/index.mjs +131 -72
  82. package/src/tui/interaction.mjs +13 -12
  83. package/src/tui/key-handler.mjs +34 -26
  84. package/src/tui/layout.mjs +22 -17
  85. package/src/tui/pickers.mjs +19 -19
  86. package/src/tui/render-frame.mjs +37 -11
  87. package/src/tui/render.mjs +25 -24
  88. package/src/tui/slash-commands.mjs +22 -22
  89. package/src/tui/startup.mjs +15 -14
  90. package/src/tui/wizard.mjs +11 -11
  91. package/src/tui.mjs +2 -2
  92. package/bin/thincoder.js +0 -4
  93. package/src/tools/bash.mjs +0 -144
  94. package/src/tools/glob.mjs +0 -51
  95. package/src/tools/grep.mjs +0 -100
  96. package/src/tools/ls.mjs +0 -36
  97. package/src/tools.mjs +0 -2
  98. package/src/tui-render.mjs +0 -4
@@ -13,11 +13,12 @@ import { existsSync } from "node:fs";
13
13
  import { join, relative, dirname } from "node:path";
14
14
 
15
15
  /**
16
- * 解析统一 diff(unified diff):返回 [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
17
- * @@ 头的行数计数消费 hunk 行——LLM 常把上下文空行剥成纯空行,靠计数而不是行首字符判断 hunk 边界
16
+ * Parse a unified diff: returns [{ path, isNew, hunks: [{ ops: [{type:" "|"-"|"+", text}] }] }]
17
+ * Consume hunk lines by the line counts in the @@ header LLMs often strip context blank lines to pure empty lines,
18
+ * so we use counts rather than first characters to determine hunk boundaries
18
19
  */
19
20
  function parsePatch(patch) {
20
- // 补丁文本常来自 CRLF 终端/模型输出,行尾 \r 会混进 hunk 内容导致上下文对不上,统一剥掉
21
+ // Patch text often comes from CRLF terminals/model output; trailing \r mixed into hunk content breaks context matching, strip uniformly
21
22
  const lines = patch.replace(/\r(?=\n|$)/g, "").split("\n")
22
23
  const files = []
23
24
  let cur = null
@@ -48,7 +49,7 @@ function parsePatch(patch) {
48
49
  if (i >= lines.length) throw new Error("Malformed patch: hunk truncated (line counts in @@ header not satisfied)")
49
50
  const hl = lines[i]
50
51
  if (hl.startsWith("\\")) { i++; continue } // ""
51
- const tag = hl === "" ? " " : hl[0] // 纯空行按上下文行宽容处理
52
+ const tag = hl === "" ? " " : hl[0] // pure blank line: treat leniently as context line
52
53
  const text = hl === "" ? "" : hl.slice(1)
53
54
  if (tag === " ") { hunk.ops.push({ type: " ", text }); oldNeed--; newNeed-- }
54
55
  else if (tag === "-") { hunk.ops.push({ type: "-", text }); oldNeed-- }
@@ -59,13 +60,13 @@ function parsePatch(patch) {
59
60
  cur.hunks.push(hunk)
60
61
  continue
61
62
  }
62
- i++ // diff --git / index / 空行等元信息跳过
63
+ i++ // skip diff --git / index / blank lines and other metadata
63
64
  }
64
65
  if (files.length === 0) throw new Error("No file changes found in patch (need --- / +++ headers)")
65
66
  return files
66
67
  }
67
68
 
68
- /** 在内存行数组上按序应用 hunks;任何一步失败抛错(调用方保证不落盘)。比较时忽略行尾 \r,上下文行保留原始字节 */
69
+ /** Apply hunks sequentially onto an in-memory line array; any failure throws (caller guarantees nothing is written to disk). Ignores trailing \r when comparing; context lines retain original bytes */
69
70
  function applyHunks(fileLines, hunks, eol, path) {
70
71
  const cr = eol === "\r\n" ? "\r" : ""
71
72
  for (let h = 0; h < hunks.length; h++) {
@@ -88,7 +89,7 @@ function applyHunks(fileLines, hunks, eol, path) {
88
89
  const out = []
89
90
  let src = pos
90
91
  for (const op of hunks[h].ops) {
91
- if (op.type === " ") out.push(fileLines[src++]) // 上下文保留原始行(行尾/空白原样)
92
+ if (op.type === " ") out.push(fileLines[src++]) // preserve original context line (trailing whitespace as-is)
92
93
  else if (op.type === "-") src++
93
94
  else out.push(op.text + cr)
94
95
  }
@@ -107,13 +108,13 @@ export const applyPatchTool = {
107
108
  required: ["patch"],
108
109
  },
109
110
  readonly: false,
110
- /** agent 层追踪触碰文件(多路径,替代单 path 参数) */
111
+ /** For the agent layer to track touched files (multi-path, replaces single path parameter) */
111
112
  touchedPaths(args) {
112
113
  try { return parsePatch(args.patch ?? "").map((f) => f.path) } catch { return [] }
113
114
  },
114
115
  async execute(args, ctx) {
115
116
  const files = parsePatch(args.patch ?? "")
116
- // 先全部读入内存试算:任何一个 hunk 不上就整体抛错,不写半个补丁(原子性)
117
+ // Read all into memory first for trial: if any hunk fails, abort entirely — never write a partial patch (atomicity)
117
118
  const planned = []
118
119
  for (const f of files) {
119
120
  const abs = resolveInCwd(ctx, f.path)
@@ -129,7 +130,7 @@ export const applyPatchTool = {
129
130
  planned.push({ abs, path: f.path, content: lines.join("\n"), isNew: false })
130
131
  }
131
132
  }
132
- // 多文件写:先全部写 .tmp,全部成功后再 rename——任一写失败清理已写的 .tmp 不影响已落盘的文件
133
+ // Multi-file write: write all to .tmp first, rename only after all succeed — failure cleans up written .tmp without affecting committed files
133
134
  const { rename, unlink } = await import("node:fs/promises")
134
135
  const written = []
135
136
  try {
@@ -142,17 +143,18 @@ export const applyPatchTool = {
142
143
  await rename(p.abs + ".thincoder-tmp", p.abs)
143
144
  }
144
145
  } catch (renameError) {
145
- // rename 阶段失败:清理残留 .tmp 文件
146
+ // rename phase failed: clean up leftover .tmp files
146
147
  for (const abs of written) {
147
148
  try { await unlink(abs + ".thincoder-tmp") } catch {}
148
149
  }
149
150
  throw renameError
150
151
  }
151
152
  const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
152
- const syntaxResults = planned.map((p) => {
153
- const r = autoSyntaxCheck(p.abs)
153
+ const syntaxChecks = await Promise.all(planned.map(async (p) => {
154
+ const r = await autoSyntaxCheck(p.abs)
154
155
  return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
155
- }).filter(Boolean).join("\n")
156
+ }))
157
+ const syntaxResults = syntaxChecks.filter(Boolean).join("\n")
156
158
  return `Applied patch to ${planned.length} file(s):\n${summary}${syntaxResults ? "\n\nSyntax checks:\n" + syntaxResults : ""}`
157
159
  },
158
160
  }
@@ -181,7 +183,7 @@ export const syntaxCheckTool = {
181
183
  })
182
184
  return `Syntax OK: ${args.path}`
183
185
  } catch (e) {
184
- // node --check 把错误写到 stderr
186
+ // node --check writes errors to stderr
185
187
  const msg = (e.stderr || e.stdout || e.message || "").trim()
186
188
  return `Syntax error in ${args.path}:\n${msg || "(unknown)"}`
187
189
  }
@@ -207,15 +209,15 @@ export const deleteTool = {
207
209
  if (!existsSync(abs)) throw new Error(`File not found: ${args.path}`)
208
210
  const s = await stat(abs)
209
211
  if (s.isDirectory()) throw new Error(`"${args.path}" is a directory — use bash to remove directories`)
210
- // git 跟踪文件拒绝直接删除(安全网);未跟踪的放行
211
- // 用解析后的相对路径(统一正斜杠),防反斜杠/非常规路径绕过 ls-files 匹配
212
+ // git-tracked files: refuse direct deletion (safety net); untracked: allow
213
+ // Use resolved relative path (normalized forward slashes) to prevent backslash/unusual paths from bypassing ls-files matching
212
214
  const rel = relative(ctx.cwd, abs).replace(/\\/g, "/")
213
215
  let tracked = false
214
216
  try {
215
217
  execFileSync("git", ["ls-files", "--error-unmatch", "--", rel], { cwd: ctx.cwd, stdio: "ignore" })
216
218
  tracked = true
217
219
  } catch {
218
- // 未跟踪 / git 仓库
220
+ // untracked / non-git repo
219
221
  }
220
222
  if (tracked && !args.force) throw new Error(`"${args.path}" is git-tracked. Set force=true to delete anyway.`)
221
223
  await unlink(abs)
@@ -1,14 +1,14 @@
1
1
  /**
2
- * repomap-parse.mjs — 仓库依赖图解析(零依赖,纯 regex
3
- * code_chunks 取已知文件列表,实时解析每个文件的 import/export 关系,
4
- * 构建正向依赖图 + 反向引用图。被 repomap.mjs buildSummary / buildOutline 共用。
2
+ * repomap-parse.mjs — repo dependency graph parser (zero dependencies, pure regex)
3
+ * Gets known file list from code_chunks, parses each file's import/export relationships in real time,
4
+ * builds forward dependency graph + reverse reference graph. Shared by repomap.mjs's buildSummary / buildOutline.
5
5
  */
6
6
  import { readFileSync, existsSync } from "node:fs"
7
7
  import { join } from "node:path"
8
8
 
9
9
  /**
10
- * 扫描全量文件,构建正向依赖图 + 反向引用图。
11
- * 返回 { deps, importers, fileCount } buildOutline / buildSummary 共用。
10
+ * Scan all files, build forward dependency graph + reverse reference graph.
11
+ * Returns { deps, importers, fileCount } shared by buildOutline / buildSummary.
12
12
  */
13
13
  export function buildDepGraph(db, cwd) {
14
14
  const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
@@ -34,7 +34,7 @@ export function buildDepGraph(db, cwd) {
34
34
  exports = parseExports(lines, ext)
35
35
  }
36
36
 
37
- // import 路径解析成相对路径(处理 ./ ../)
37
+ // Resolve import paths to relative paths (handle ./ ../)
38
38
  const resolved = []
39
39
  for (let imp of imports) {
40
40
  if (imp.startsWith("./")) imp = imp.slice(2)
@@ -62,17 +62,17 @@ export function buildDepGraph(db, cwd) {
62
62
  return { deps, importers, fileCount: allFiles.length }
63
63
  }
64
64
 
65
- // ---------------------------------------------------------- 内部实现
65
+ // ---------------------------------------------------------- internal implementation
66
66
 
67
67
  function normalizeExt(p) {
68
68
  return p.replace(/\.(m?js|jsx|tsx?)$/i, "")
69
69
  }
70
70
 
71
- /** 提取 JS/TS 文件的 import 路径(去掉 .ts/.js/.mjs 后缀统一) */
71
+ /** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
72
72
  function parseImports(lines, ext) {
73
73
  const imports = []
74
74
  const text = lines.join("\n")
75
- // 普通 import
75
+ // standard import
76
76
  const re = /import\s+(?:{[^}]*}|\*\s+as\s+\w+|\w+\s*,?\s*(?:{[^}]*})?)\s*from\s*['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g
77
77
  let m
78
78
  while ((m = re.exec(text))) {
@@ -90,7 +90,7 @@ function parseImports(lines, ext) {
90
90
  return [...new Set(imports)]
91
91
  }
92
92
 
93
- /** 提取 JS/TS 文件的 export 符号 */
93
+ /** Extract JS/TS file export symbols */
94
94
  function parseExports(lines, ext) {
95
95
  const exports = []
96
96
  const text = lines.join("\n")
@@ -107,17 +107,17 @@ function parseExports(lines, ext) {
107
107
  if (name) exports.push(name)
108
108
  else if (!exports.some((e) => e === "default")) exports.push("default")
109
109
  }
110
- // export { a, b as c } —— 优先取 as 后的导出名
110
+ // export { a, b as c } prefer the "as" alias as the exported name
111
111
  const braceRe = /export\s*\{([^}]+)\}/g
112
112
  while ((m = braceRe.exec(text))) {
113
113
  for (const name of m[1].split(",")) {
114
114
  const parts = name.trim().split(/\s+/)
115
- // "a as b" → b(导出名),"a" → a
115
+ // "a as b" → b (exported name), "a" → a
116
116
  const exported = parts.length >= 3 ? parts[2] : parts[0]
117
117
  if (exported) exports.push(exported)
118
118
  }
119
119
  }
120
- // export const { a, b } = ...(解构导出)
120
+ // export const { a, b } = ... (destructured export)
121
121
  const destructRe = /export\s+(?:const|let|var)\s*\{([^}]+)\}\s*=/g
122
122
  while ((m = destructRe.exec(text))) {
123
123
  for (const name of m[1].split(",")) {
@@ -129,7 +129,7 @@ function parseExports(lines, ext) {
129
129
  return [...new Set(exports)]
130
130
  }
131
131
 
132
- /** 提取 Python import 和顶层 def/class */
132
+ /** Extract Python imports and top-level def/class */
133
133
  function parsePyOutline(lines) {
134
134
  const imports = []
135
135
  const symbols = []
@@ -155,9 +155,9 @@ function parsePyOutline(lines) {
155
155
  }
156
156
 
157
157
  /**
158
- * Python 相对导入相对文件路径:
159
- * 前导 n 个点表示上溯 n-1 层("."=当前包),模块点号转路径分隔符。
160
- * 非相对导入(不以 . 开头)或纯包导入("from . import x")返回 null
158
+ * Python relative import relative file path:
159
+ * Leading n dots mean go up n-1 levels ("." = current package), module dots become path separators.
160
+ * Non-relative imports (not starting with .) or bare package imports ("from . import x") return null.
161
161
  */
162
162
  function pyRelPath(mod) {
163
163
  if (!mod?.startsWith(".")) return null
@@ -1,16 +1,16 @@
1
1
  /**
2
- * repomap.mjs — 仓库依赖大纲(零依赖,纯 regex
3
- * 实时解析 import/export 关系,生成紧凑文本给 LLM 理解代码结构。
4
- * 不存索引——每次调用读文件解析,~50ms 完成。
2
+ * repomap.mjs — repo dependency outline (zero dependencies, pure regex)
3
+ * Real-time import/export parsing, generates compact text for LLMs to understand code structure.
4
+ * No index stored — reads and parses files on each call, ~50ms.
5
5
  */
6
6
  import { readFileSync, existsSync } from "node:fs"
7
7
  import { join } from "node:path"
8
8
 
9
- /** 提取 JS/TS 文件的 import 路径(去掉 .ts/.js/.mjs 后缀统一) */
9
+ /** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
10
10
  function parseImports(lines, ext) {
11
11
  const imports = []
12
12
  const text = lines.join("\n")
13
- // 普通 import
13
+ // standard import
14
14
  const re = /import\s+(?:{[^}]*}|\*\s+as\s+\w+|\w+\s*,?\s*(?:{[^}]*})?)\s*from\s*['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g
15
15
  let m
16
16
  while ((m = re.exec(text))) {
@@ -28,7 +28,7 @@ function parseImports(lines, ext) {
28
28
  return [...new Set(imports)]
29
29
  }
30
30
 
31
- /** 提取 JS/TS 文件的 export 符号 */
31
+ /** Extract JS/TS file export symbols */
32
32
  function parseExports(lines, ext) {
33
33
  const exports = []
34
34
  const text = lines.join("\n")
@@ -45,17 +45,17 @@ function parseExports(lines, ext) {
45
45
  if (name) exports.push(name)
46
46
  else if (!exports.some((e) => e === "default")) exports.push("default")
47
47
  }
48
- // export { a, b as c } —— 优先取 as 后的导出名
48
+ // export { a, b as c } prefer the "as" alias as the exported name
49
49
  const braceRe = /export\s*\{([^}]+)\}/g
50
50
  while ((m = braceRe.exec(text))) {
51
51
  for (const name of m[1].split(",")) {
52
52
  const parts = name.trim().split(/\s+/)
53
- // "a as b" → b(导出名),"a" → a
53
+ // "a as b" → b (exported name), "a" → a
54
54
  const exported = parts.length >= 3 ? parts[2] : parts[0]
55
55
  if (exported) exports.push(exported)
56
56
  }
57
57
  }
58
- // export const { a, b } = ...(解构导出)
58
+ // export const { a, b } = ... (destructured export)
59
59
  const destructRe = /export\s+(?:const|let|var)\s*\{([^}]+)\}\s*=/g
60
60
  while ((m = destructRe.exec(text))) {
61
61
  for (const name of m[1].split(",")) {
@@ -67,7 +67,7 @@ function parseExports(lines, ext) {
67
67
  return [...new Set(exports)]
68
68
  }
69
69
 
70
- /** 提取 Python import 和顶层 def/class */
70
+ /** Extract Python imports and top-level def/class */
71
71
  function parsePyOutline(lines) {
72
72
  const imports = []
73
73
  const symbols = []
@@ -93,9 +93,9 @@ function parsePyOutline(lines) {
93
93
  }
94
94
 
95
95
  /**
96
- * Python 相对导入相对文件路径:
97
- * 前导 n 个点表示上溯 n-1 层("."=当前包),模块点号转路径分隔符。
98
- * 非相对导入(不以 . 开头)或纯包导入("from . import x")返回 null
96
+ * Python relative import relative file path:
97
+ * Leading n dots mean go up n-1 levels ("." = current package), module dots become path separators.
98
+ * Non-relative imports (not starting with .) or bare package imports ("from . import x") return null.
99
99
  */
100
100
  function pyRelPath(mod) {
101
101
  if (!mod?.startsWith(".")) return null
@@ -110,8 +110,8 @@ function normalizeExt(p) {
110
110
  }
111
111
 
112
112
  /**
113
- * 内部:扫描全量文件,构建正向依赖图 + 反向引用图。
114
- * 返回 { deps, importers, fileCount } buildOutline / buildSummary 共用。
113
+ * Internal: scan all files, build forward dependency graph + reverse reference graph.
114
+ * Returns { deps, importers, fileCount } shared by buildOutline / buildSummary.
115
115
  */
116
116
  function _buildDepGraph(db, cwd) {
117
117
  const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
@@ -137,7 +137,7 @@ function _buildDepGraph(db, cwd) {
137
137
  exports = parseExports(lines, ext)
138
138
  }
139
139
 
140
- // import 路径解析成相对路径(处理 ./ ../)
140
+ // Resolve import paths to relative paths (handle ./ ../)
141
141
  const resolved = []
142
142
  for (let imp of imports) {
143
143
  if (imp.startsWith("./")) imp = imp.slice(2)
@@ -166,12 +166,12 @@ function _buildDepGraph(db, cwd) {
166
166
  }
167
167
 
168
168
  /**
169
- * 生成紧凑架构摘要(替换旧的全量注入)。
170
- * 三层信息,每层信息密度递减:
171
- * 1. 目录级依赖(多目录项目才有意义,单目录跳过)
172
- * 2. 枢纽文件 Top-12(被 import 最多的文件——架构骨架)
173
- * 3. 入口文件(无人 import 的文件——启动/顶层入口)
174
- * 输出天然有界(~1000-2000 字符),不再需要 OUTLINE_INJECT_MAX 硬截断。
169
+ * Generate a compact architecture summary (replaces the old full-dump injection).
170
+ * Three layers, each with decreasing information density:
171
+ * 1. Directory-level dependencies (meaningful only for multi-directory projects, skipped for single-directory)
172
+ * 2. Hub files Top-12 (most-imported files — architecture skeleton)
173
+ * 3. Entry points (files with no importers — startup/top-level entry points)
174
+ * Output is naturally bounded (~1000-2000 chars), no more OUTLINE_INJECT_MAX hard truncation.
175
175
  */
176
176
  export function buildSummary(db, cwd) {
177
177
  const graph = _buildDepGraph(db, cwd)
@@ -181,7 +181,7 @@ export function buildSummary(db, cwd) {
181
181
  const out = []
182
182
  out.push(`${fileCount} source files indexed.`)
183
183
 
184
- // 1) 目录级依赖
184
+ // 1) Directory-level dependencies
185
185
  const dirDeps = new Map() // dir → Set<imported-dir>
186
186
  const dirSet = new Set()
187
187
  for (const [rel, d] of deps) {
@@ -204,7 +204,7 @@ export function buildSummary(db, cwd) {
204
204
  }
205
205
  }
206
206
 
207
- // 2) 枢纽文件 Top-12:按被 import 次数降序
207
+ // 2) Hub files Top-12: sorted by import count descending
208
208
  const HUB_LIMIT = 12
209
209
  const hubScores = []
210
210
  for (const [rel] of deps) {
@@ -227,7 +227,7 @@ export function buildSummary(db, cwd) {
227
227
  }
228
228
  }
229
229
 
230
- // 3) 入口文件:无人 import 的(叶子/入口)
230
+ // 3) Entry points: files not imported by others (leaf/entry)
231
231
  const entries = []
232
232
  for (const [rel] of deps) {
233
233
  const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
@@ -247,7 +247,7 @@ export function buildSummary(db, cwd) {
247
247
  return out.join("\n")
248
248
  }
249
249
 
250
- /** code_chunks 取已知文件列表(复用索引),按路径解析生成大纲文本 */
250
+ /** Get known file list from code_chunks (reuse index), parse by path to generate outline text */
251
251
  export function buildOutline(db, cwd, focusPath) {
252
252
  const graph = _buildDepGraph(db, cwd)
253
253
  if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
@@ -260,7 +260,7 @@ export function buildOutline(db, cwd, focusPath) {
260
260
  const d = deps.get(rel)
261
261
  if (!d) continue
262
262
  const parts = []
263
- // imported by(匹配时去掉扩展名,因为 import 路径通常不含 .mjs/.js 后缀)
263
+ // imported by (strip extension when matching, since import paths usually don't include .mjs/.js suffix)
264
264
  const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
265
265
  const rev = importers.get(key)
266
266
  if (rev?.size) parts.push(`← imported by: ${[...rev].join(", ")}`)
@@ -281,8 +281,8 @@ export function buildOutline(db, cwd, focusPath) {
281
281
  }
282
282
 
283
283
  /**
284
- * 生成 repo_outline 工具(只读)。
285
- * 需要 memory.db(复用 code_chunks 文件列表)和 cwd
284
+ * Build the repo_outline tool (read-only).
285
+ * Requires memory.db (reuses code_chunks file list) and cwd.
286
286
  */
287
287
  export function repoOutlineTool(db, cwd) {
288
288
  return {
@@ -1,9 +1,9 @@
1
1
  /**
2
- * tools/shared.mjs — 共享工具函数、常量、OpenAI schema 转换
3
- * tools/file.mjs / system.mjs / web.mjs / git.mjs 导入
2
+ * tools/shared.mjs — shared tool utilities, constants, OpenAI schema conversion
3
+ * Imported by tools/file.mjs / system.mjs / web.mjs / git.mjs
4
4
  */
5
5
 
6
- import { spawn, execFileSync } from "node:child_process"
6
+ import { spawn, execFileSync, execFile } from "node:child_process"
7
7
  import { readFileSync, existsSync, realpathSync } from "node:fs"
8
8
  import { dirname, join, resolve, relative, isAbsolute, sep } from "node:path"
9
9
  import { fileURLToPath } from "node:url"
@@ -13,11 +13,14 @@ export const DESC = (name) => readFileSync(join(__dirname, "..", "tools", `${nam
13
13
 
14
14
  export const MAX_READ_LINES = 2000
15
15
  export const MAX_OUTPUT_CHARS = 200_000
16
+
17
+ const ENCODING_DETECT_MAX_TRIM = 3
18
+ const SYNTAX_CHECK_TIMEOUT = 10000
16
19
  export const BASH_TIMEOUT_MS = 120_000
17
20
  export const MAX_RESPONSE_BODY_BYTES = 5_000_000
18
21
  export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
19
22
 
20
- /** 转成 OpenAI tools 参数格式 */
23
+ /** Convert to OpenAI tools parameter format */
21
24
  export function toOpenAISchema(tool) {
22
25
  return {
23
26
  type: "function",
@@ -29,7 +32,7 @@ export function toOpenAISchema(tool) {
29
32
  }
30
33
  }
31
34
 
32
- /** 剥离 ANSI 转义序列 */
35
+ /** Strip ANSI escape sequences */
33
36
  export function sanitizeOutput(s) {
34
37
  return s
35
38
  .replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
@@ -37,12 +40,13 @@ export function sanitizeOutput(s) {
37
40
  .replace(/\r/g, "\n")
38
41
  }
39
42
 
43
+ /** Truncate text to max chars, appending a truncation notice */
40
44
  export function truncate(text, max = MAX_OUTPUT_CHARS) {
41
45
  if (text.length <= max) return text
42
46
  return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
43
47
  }
44
48
 
45
- /** 限量读取响应体 */
49
+ /** Read response body with a byte limit */
46
50
  export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
47
51
  if (!response.body) return ""
48
52
  const reader = response.body.getReader()
@@ -59,7 +63,8 @@ export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
59
63
  return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
60
64
  }
61
65
 
62
- /** 流解码器:编码嗅探 ASCII→UTF-8→GBK */
66
+ /** Streaming decoder: encoding sniffing ASCII→UTF-8→GBK.
67
+ * Each call creates an independent decoder instance — must not be shared across parallel streams (internal decoder state accumulates). */
63
68
  export function makeDecoder() {
64
69
  let decoder = null
65
70
  let pending = Buffer.alloc(0)
@@ -68,7 +73,7 @@ export function makeDecoder() {
68
73
  if (!decoder) {
69
74
  const hasHighByte = pending.some((b) => b >= 0x80)
70
75
  if (!hasHighByte) { const s = pending.toString("ascii"); pending = Buffer.alloc(0); return s }
71
- for (let trim = 0; trim <= 3 && !decoder; trim++) {
76
+ for (let trim = 0; trim <= ENCODING_DETECT_MAX_TRIM && !decoder; trim++) {
72
77
  try { new TextDecoder("utf-8", { fatal: true }).decode(pending.subarray(0, pending.length - trim)); decoder = new TextDecoder("utf-8") }
73
78
  catch { /* continue */ }
74
79
  }
@@ -80,7 +85,7 @@ export function makeDecoder() {
80
85
  }
81
86
  }
82
87
 
83
- /** 单文件 git diff,失败静默返回空。大 diff maxBuffer 时截断而非吞掉 */
88
+ /** Single-file git diff. Silently returns empty on failure. Large diffs that exceed maxBuffer are truncated rather than swallowed. */
84
89
  export function gitDiffOne(cwd, abs) {
85
90
  try {
86
91
  const diff = execFileSync("git", ["--no-pager", "diff", "--no-color", "--", abs], {
@@ -91,7 +96,7 @@ export function gitDiffOne(cwd, abs) {
91
96
  if (lines.length <= 200) return diff
92
97
  return lines.slice(0, 200).join("\n") + `\n... (${lines.length - 200} more diff lines)`
93
98
  } catch (e) {
94
- // maxBuffer 溢出时 e.stdout 含已收集的部分;其他错误(非 git 仓库等)返回空
99
+ // maxBuffer overflow: e.stdout contains partial collected output; other errors (non-git repo etc.) return empty
95
100
  if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
96
101
  const lines = e.stdout.toString().split("\n")
97
102
  return lines.slice(0, 200).join("\n") + `\n... (diff too large, showing first 200 of more lines)`
@@ -100,19 +105,32 @@ export function gitDiffOne(cwd, abs) {
100
105
  }
101
106
  }
102
107
 
103
- /** 文件变更后自动语法检查 */
104
- export function autoSyntaxCheck(abs) {
108
+ /** Auto syntax check after file modification */
109
+ export async function autoSyntaxCheck(abs) {
105
110
  if (!/\.(m?js)$/i.test(abs)) return ""
106
111
  try {
107
- execFileSync("node", ["--check", abs], { stdio: ["ignore", "pipe", "pipe"], timeout: 10000 })
112
+ await new Promise((resolve, reject) => {
113
+ const child = execFile("node", ["--check", abs], { timeout: SYNTAX_CHECK_TIMEOUT, stdio: ["ignore", "pipe", "pipe"] })
114
+ let stderr = ""
115
+ child.stderr.on("data", (d) => { stderr += d.toString() })
116
+ child.on("error", reject)
117
+ child.on("close", (code) => {
118
+ if (code === 0) resolve()
119
+ else {
120
+ const err = new Error(stderr.trim() || `node --check exited with code ${code}`)
121
+ err.stderr = stderr
122
+ reject(err)
123
+ }
124
+ })
125
+ })
108
126
  return "\nSyntax: OK"
109
127
  } catch (e) {
110
128
  const err = (e.stderr || e.stdout || e.message || "").toString().split("\n").slice(0, 3).join("\n")
111
- return `\nSyntax: FAILED — ${err}`
129
+ return `\nSyntax: FAILED — ${err}\n(If this file was corrupted by a bad edit, recover it from a checkpoint: checkpoint action=list then action=rewind with the latest id.)`
112
130
  }
113
131
  }
114
132
 
115
- /** 逐级向上找真实路径 */
133
+ /** Resolve realpath by walking up the directory tree */
116
134
  export function realpathNearest(abs) {
117
135
  let cur = abs
118
136
  const tail = []
@@ -127,11 +145,13 @@ export function realpathNearest(abs) {
127
145
  }
128
146
 
129
147
  const realCwdCache = new Map()
148
+ /** Resolve cwd to realpath, cached */
130
149
  export function realCwd(cwd) {
131
150
  if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
132
151
  return realCwdCache.get(cwd)
133
152
  }
134
153
 
154
+ /** Assert that a resolved path is inside cwd; throws on escape */
135
155
  export function assertInside(cwd, resolved, p) {
136
156
  const rel = relative(cwd, resolved)
137
157
  if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
@@ -139,6 +159,7 @@ export function assertInside(cwd, resolved, p) {
139
159
  }
140
160
  }
141
161
 
162
+ /** Resolve a user-supplied path relative to cwd, asserting it stays within cwd */
142
163
  export function resolveInCwd(ctx, p) {
143
164
  const cwd = realCwd(ctx.cwd)
144
165
  const resolved = resolve(cwd, p)
@@ -148,25 +169,31 @@ export function resolveInCwd(ctx, p) {
148
169
  return resolved
149
170
  }
150
171
 
151
- /** 破坏性预检用的粗切分(也切 > >> <,使段内破坏性检测在重定向时仍生效) */
172
+ /** Resolve a path relative to cwd without boundary check — use only when the user explicitly provides an external path */
173
+ export function resolveExternal(ctx, p) {
174
+ const cwd = realCwd(ctx.cwd)
175
+ return resolve(cwd, p)
176
+ }
177
+
178
+ /** Coarse segmentation for destructive pre-check (also splits on > >> < so destructive detection still works through redirection) */
152
179
  export function shellSegments(command) {
153
180
  return command.split(/&&|\|\||>>|\$\(|[;|\n<>]|`|[(]/)
154
181
  }
155
182
 
156
- /** 检测 shell 输出/输入重定向(> >> < 后跟文件名)——引号内未排除,保守拦截 */
183
+ /** Detect shell output/input redirection (> >> < followed by filename) — not excluded inside quotes, conservative block */
157
184
  export function hasFileRedirection(command) {
158
185
  return /(^|[\s;&|])>{1,2}\s*\S/.test(command) || /(^|[\s;&|])<\s*\S/.test(command)
159
186
  }
160
187
 
161
- /** 单命令段是否为破坏性非 git 命令(保守:宁可误拦) */
188
+ /** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
162
189
  export function isDestructiveCommand(seg) {
163
190
  const s = seg
164
- // rm 同时带递归(-r/-R)与强制(-f)标志:-rf / -fr / -r -f / -Rf
191
+ // rm with both recursive (-r/-R) and force (-f) flags: -rf / -fr / -r -f / -Rf etc.
165
192
  if (/\brm\b/.test(s) && /\s-\S*r/i.test(s) && /\s-\S*f/i.test(s)) return true
166
193
  if (/\brmdir\b/i.test(s)) return true
167
194
  if (/\bdel\b/i.test(s) && /\/f\b/i.test(s)) return true
168
195
  if (/\brd\b/i.test(s) && /\/s\b/i.test(s)) return true
169
- // format 作为命令调用(排除 --format= 之类的选项误报)
196
+ // format called as a command (exclude --format= option false positives)
170
197
  if (/\bformat\b\s+\S/i.test(s) && !/--format\b/i.test(s)) return true
171
198
  if (/\bshred\b/i.test(s)) return true
172
199
  if (/\bdd\b/.test(s) && /\bof=/i.test(s)) return true
@@ -176,7 +203,7 @@ export function isDestructiveCommand(seg) {
176
203
  return false
177
204
  }
178
205
 
179
- /** 单命令段是否销毁未提交改动 */
206
+ /** Whether a single command segment destroys uncommitted changes */
180
207
  export function isDestructiveGitSegment(seg) {
181
208
  if (!/^\s*git\s/.test(seg)) return false
182
209
  if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
@@ -186,7 +213,7 @@ export function isDestructiveGitSegment(seg) {
186
213
  return false
187
214
  }
188
215
 
189
- /** cwd 是否在 git 仓库内 */
216
+ /** Whether cwd is inside a git repository */
190
217
  export function insideGitRepo(cwd) {
191
218
  try {
192
219
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
@@ -196,7 +223,7 @@ export function insideGitRepo(cwd) {
196
223
  } catch { return false }
197
224
  }
198
225
 
199
- /** glob 转正则 */
226
+ /** Convert glob pattern to regex */
200
227
  export function globToRegex(pattern) {
201
228
  const DS = "\u0001", DP = "\u0002"
202
229
  const escaped = pattern
@@ -208,7 +235,7 @@ export function globToRegex(pattern) {
208
235
  return new RegExp(`^${escaped}$`)
209
236
  }
210
237
 
211
- /** HTML 标签 */
238
+ /** Strip HTML tags */
212
239
  export function stripTags(html) {
213
240
  return html
214
241
  .replace(/<[^>]+>/g, "")
@@ -223,7 +250,7 @@ export function stripTags(html) {
223
250
  .trim()
224
251
  }
225
252
 
226
- /** HTML → 粗文本:去脚本样式、块级标签换行、剥标签、解码实体、压缩空行 */
253
+ /** HTML → plain text: strip scripts/styles, newline block tags, strip tags, decode entities, compress blank lines */
227
254
  export function htmlToText(html) {
228
255
  return html
229
256
  .replace(/<script[\s\S]*?<\/script>/gi, "")
@@ -239,18 +266,18 @@ export function htmlToText(html) {
239
266
  .replace(/&lt;/g, "<")
240
267
  .replace(/&gt;/g, ">")
241
268
  .replace(/&quot;/g, '"')
242
- .replace(/&amp;/g, "&") // &amp; 必须最后解码,否则 &amp;lt; 会被二次解码成 <
269
+ .replace(/&amp;/g, "&") // &amp; must be decoded last, otherwise &amp;lt; gets double-decoded to <
243
270
  .replace(/[ \t]+/g, " ")
244
271
  .replace(/\n\s*\n\s*\n+/g, "\n\n")
245
272
  .trim()
246
273
  }
247
274
 
248
- /** 执行 git 命令。maxBuffer 10MB 防大 diff/log 溢出;溢出时返回截断的部分输出而非空。 */
275
+ /** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty. */
249
276
  export function runGit(cwd, cmdArgs) {
250
277
  try {
251
278
  return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
252
279
  } catch (e) {
253
- // ERR_CHILD_PROCESS_STDIO_MAXBUFFER e.stdout 含部分输出,截取前 200 行返回
280
+ // ERR_CHILD_PROCESS_STDIO_MAXBUFFER: e.stdout contains partial output, return first 200 lines
254
281
  if (e.stdout) return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
255
282
  return ""
256
283
  }