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
package/src/session.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * session.mjs — 会话持久化
3
- * 每个项目(按 cwd 哈希)最多保留 5 轮会话,按最后使用时间轮转。
4
- * 两种恢复需求分开存:agent 恢复(history)要上下文连续,用户恢复(display)要所见即所得。
2
+ * session.mjs — session persistence
3
+ * Each project (keyed by cwd hash) keeps up to 5 session slots, rotated by last-access time.
4
+ * Two recovery needs stored separately: agent recovery (history) needs context continuity, user recovery (display) needs WYSIWYG.
5
5
  *
6
- * 文件布局:{hash}.json(当前)、{hash}.json.1~5(槽位)、{hash}.json.manifest(槽位元数据)
6
+ * File layout: {hash}.json (current), {hash}.json.1~5 (slots), {hash}.json.manifest (slot metadata)
7
7
  */
8
8
 
9
9
  import { createHash } from "node:crypto"
@@ -12,18 +12,20 @@ import { join, dirname } from "node:path"
12
12
  import { configDir } from "./config.mjs"
13
13
 
14
14
  const MAX_SLOTS = 5
15
+ const CWD_HASH_LEN = 12
15
16
 
17
+ /** Derive session file path from cwd hash */
16
18
  export function sessionPath(cwd) {
17
- const hash = createHash("sha1").update(cwd).digest("hex").slice(0, 12)
19
+ const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
18
20
  return join(configDir, "sessions", `${hash}.json`)
19
21
  }
20
22
 
21
23
  function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
22
24
  function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
23
25
 
24
- /** 原子写:先写临时文件再 rename 替换,防写入中途崩溃留下截断的 JSON 丢整个会话。
25
- * rename POSIX 上原子;Windows 上目标已存在时 Node 24 MoveFileExW+REPLACE_EXISTING 也能原子替换。
26
- * 某些旧 Windows 文件系统可能抛 EPERM,重试一次。 */
26
+ /** Atomic write: write to temp file then rename to replace, preventing truncated JSON from mid-write crash.
27
+ * rename is atomic on POSIX; on Windows with existing target, Node 24 uses MoveFileExW+REPLACE_EXISTING for atomic replace.
28
+ * Some older Windows filesystems may throw EPERM — retry once. */
27
29
  function writeSessionFile(p, data) {
28
30
  mkdirSync(dirname(p), { recursive: true })
29
31
  const tmp = `${p}.tmp`
@@ -31,13 +33,19 @@ function writeSessionFile(p, data) {
31
33
  try {
32
34
  renameSync(tmp, p)
33
35
  } catch {
34
- // Windows EPERM 兜底:删目标后重试(极罕见,仅旧 NTFS/网络盘)
36
+ // Windows: rename may fail due to antivirus lock / network drive contention — delete target and retry
35
37
  try { unlinkSync(p) } catch {}
36
- renameSync(tmp, p)
38
+ try {
39
+ renameSync(tmp, p)
40
+ // rename succeeded: clean up temp file
41
+ try { unlinkSync(tmp) } catch {}
42
+ } catch {
43
+ // rename still failed: keep tmp as fallback data (next read prefers main file; if missing, tmp is at least there)
44
+ }
37
45
  }
38
46
  }
39
47
 
40
- // ========== 槽位管理 ==========
48
+ // ========== slot management ==========
41
49
 
42
50
  function loadManifest(cwd) {
43
51
  try {
@@ -51,14 +59,14 @@ function saveManifest(cwd, m) {
51
59
  writeSessionFile(manifestPath(cwd), m)
52
60
  }
53
61
 
54
- /** 归档当前会话到空闲槽位——满了踢最老;exclude 指定一个不许被踢的槽位(switchToSlot 的目标槽) */
62
+ /** Archive current session to a free slot — evict oldest if full; exclude specifies a slot that must not be evicted (switchToSlot target) */
55
63
  export function archiveCurrent(cwd, { exclude } = {}) {
56
64
  const src = sessionPath(cwd)
57
65
  if (!existsSync(src)) return
58
66
  const m = loadManifest(cwd)
59
67
 
60
68
  let slot
61
- // 只计数字 key 的槽位,排除 _currentName 等遗留非数字 key
69
+ // Only count numeric-keyed slots, exclude legacy non-numeric keys like _currentName
62
70
  const entries = Object.entries(m.slots).filter(([n]) => /^\d+$/.test(n))
63
71
  if (entries.length < MAX_SLOTS) {
64
72
  slot = 1
@@ -69,12 +77,12 @@ export function archiveCurrent(cwd, { exclude } = {}) {
69
77
  }
70
78
 
71
79
  const dst = slotPath(cwd, slot)
72
- // 复制(rename 会丢当前);走原子写,防中途崩溃留下截断的 JSON 丢归档
80
+ // Copy (rename would lose current); use atomic write to prevent truncated JSON in archive from mid-crash
73
81
  let data
74
82
  try {
75
83
  data = JSON.parse(readFileSync(src, "utf8"))
76
84
  } catch {
77
- // 会话文件损坏,放弃归档,下次保存会覆盖
85
+ // Session file corrupted, abandon archive; next save will overwrite
78
86
  return
79
87
  }
80
88
  writeSessionFile(dst, data)
@@ -84,7 +92,7 @@ export function archiveCurrent(cwd, { exclude } = {}) {
84
92
  return slot
85
93
  }
86
94
 
87
- /** 列出所有归档槽位,最新在前 */
95
+ /** List all archive slots, newest first */
88
96
  export function listSlots(cwd) {
89
97
  const m = loadManifest(cwd)
90
98
  return Object.entries(m.slots)
@@ -92,29 +100,29 @@ export function listSlots(cwd) {
92
100
  .sort((a, b) => b.timestamp - a.timestamp)
93
101
  }
94
102
 
95
- /** 切换到指定槽位:归档当前槽位文件复制到当前返回恢复数据(失败返回 null */
103
+ /** Switch to a specific slot: archive current copy slot file to current return recovered data (null on failure) */
96
104
  export function switchToSlot(cwd, slot) {
97
105
  const m = loadManifest(cwd)
98
106
  if (!m.slots[slot]) return null
99
107
 
100
- // 归档当前(内部写 manifest;之后我们的 m 已过期,需重读)
101
- // 满槽时排除目标槽:否则最老槽=目标槽,归档会把目标覆盖掉再复制回来,目标会话永久丢失
108
+ // Archive current (internally writes manifest; our `m` is stale after, must reload)
109
+ // When full, exclude target slot: otherwise oldest=target, archive would overwrite target then copy back, permanently losing the target session
102
110
  archiveCurrent(cwd, { exclude: slot })
103
111
 
104
- // 槽位文件当前(copy+unlink,不用 renameWindows rename 目标已存在会抛 EPERM
112
+ // Slot file current (copy+unlink, not rename: Windows rename on existing target throws EPERM)
105
113
  const src = slotPath(cwd, slot)
106
114
  const dst = sessionPath(cwd)
107
115
  if (!existsSync(src)) return null
108
116
  try {
109
- try { unlinkSync(dst) } catch { /* 不存在就算了 */ }
117
+ try { unlinkSync(dst) } catch { /* doesn't exist, that's fine */ }
110
118
  copyFileSync(src, dst)
111
119
  unlinkSync(src)
112
120
  } catch {
113
- // 文件操作失败(磁盘满/权限不足/锁文件),放弃切换
121
+ // File operations failed (disk full / permissions / lock), abandon switch
114
122
  return null
115
123
  }
116
124
 
117
- // 重读 manifestarchiveCurrent 改了它)
125
+ // Reload manifest (archiveCurrent modified it)
118
126
  const m2 = loadManifest(cwd)
119
127
  delete m2.slots[slot]
120
128
  saveManifest(cwd, m2)
@@ -122,7 +130,7 @@ export function switchToSlot(cwd, slot) {
122
130
  return loadSession(cwd)
123
131
  }
124
132
 
125
- // ========== 旧版 transient 前缀清理 ==========
133
+ // ========== legacy transient prefix cleanup ==========
126
134
 
127
135
  const LEGACY_TRANSIENT_PREFIXES = [
128
136
  "[System reminder: working directory snapshot:",
@@ -137,8 +145,9 @@ function isLegacyTransient(m) {
137
145
  )
138
146
  }
139
147
 
140
- // ========== 核心读写 ==========
148
+ // ========== core read/write ==========
141
149
 
150
+ /** Save agent state and display lines to the session file (atomic write) */
142
151
  export function saveSession(agent, display) {
143
152
  const history = agent.history.filter((m) => !m.transient && !isLegacyTransient(m))
144
153
  const data = {
@@ -158,22 +167,45 @@ export function saveSession(agent, display) {
158
167
  writeSessionFile(sessionPath(agent.cwd), data)
159
168
  }
160
169
 
170
+ /** Load session data from disk; returns null if missing, corrupted, or version mismatch */
161
171
  export function loadSession(cwd) {
162
- try {
163
- const p = sessionPath(cwd)
164
- if (!existsSync(p)) return null
165
- const data = JSON.parse(readFileSync(p, "utf8"))
166
- if (data?.version !== 1 && data?.version !== 2) return null
167
- if (!Array.isArray(data.history)) return null
168
- if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
169
- data.history = data.history.filter((m) => !isLegacyTransient(m))
170
- data.display = Array.isArray(data.display)
171
- ? data.display.filter((l) => l && typeof l.text === "string").map((l) => ({ text: l.text, color: l.color }))
172
- : []
173
- return data
174
- } catch { return null }
172
+ const tryLoad = (p) => {
173
+ try {
174
+ if (!existsSync(p)) return null
175
+ const data = JSON.parse(readFileSync(p, "utf8"))
176
+ if (data?.version !== 1 && data?.version !== 2) return null
177
+ if (!Array.isArray(data.history)) return null
178
+ if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
179
+ data.history = data.history.filter((m) => !isLegacyTransient(m))
180
+ data.display = Array.isArray(data.display)
181
+ ? data.display.filter((l) => l && typeof l.text === "string").map((l) => ({ text: l.text, color: l.color }))
182
+ : []
183
+ data._recovered = false
184
+ return data
185
+ } catch (e) {
186
+ return { _error: e }
187
+ }
188
+ }
189
+ const p = sessionPath(cwd)
190
+ let result = tryLoad(p)
191
+ if (result?._error) {
192
+ // Main file corrupted — try the .tmp fallback from a failed atomic write
193
+ console.error(`[session] failed to load ${p}: ${result._error.message}. Trying .tmp fallback...`)
194
+ const tmpResult = tryLoad(`${p}.tmp`)
195
+ if (tmpResult && !tmpResult._error) {
196
+ console.error(`[session] recovered from .tmp fallback`)
197
+ result = tmpResult
198
+ result._recovered = true
199
+ } else {
200
+ console.error(`[session] .tmp fallback also failed — session lost. Backing up corrupted file as .corrupted.`)
201
+ try { renameSync(p, `${p}.corrupted`) } catch {}
202
+ return null
203
+ }
204
+ }
205
+ return result && !result._error ? result : null
175
206
  }
176
207
 
208
+ /** Apply loaded session data onto an agent object; returns true if provider was switched */
177
209
  export function applySession(agent, data) {
178
210
  agent.history = data.history
179
211
  agent.tasks = data.tasks ?? []
@@ -182,9 +214,7 @@ export function applySession(agent, data) {
182
214
  agent.goal = data.goal ?? null
183
215
  agent._pendingReminders = data.pendingReminders ?? []
184
216
  agent._sessionStart = data.sessionStart ?? null
185
- // 重置轮次计数器:切换会话后不应继承旧会话的停滞/压缩状态
186
- agent._turnsSinceTaskUpdate = 0
187
- agent._turnsInPlanMode = 0
217
+ // Reset stall/compaction state on session switch
188
218
  agent._compressFailures = 0
189
219
  agent._verifyRetries = 0
190
220
  agent._verifyPassed = false
@@ -199,12 +229,13 @@ export function applySession(agent, data) {
199
229
  return false
200
230
  }
201
231
 
232
+ /** Archive current session and reset the session file to empty state */
202
233
  export function clearSession(cwd) {
203
234
  try {
204
235
  archiveCurrent(cwd)
205
236
  const p = sessionPath(cwd)
206
237
  writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, pendingReminders: [], sessionStart: null })
207
238
  } catch {
208
- // 清不掉就算了,下次保存会覆盖
239
+ // Can't clear, oh well — next save will overwrite
209
240
  }
210
241
  }
package/src/skills.mjs CHANGED
@@ -1,17 +1,17 @@
1
1
  /**
2
- * skills.mjs — 技能系统
3
- * .thincoder/skills/ 目录发现 .md 技能文件,
4
- * 注入到 system prompt agent 按需加载。
5
- * skill 工具激活指定技能,内容以 <skill-loaded> 包裹写入对话历史。
2
+ * skills.mjs — skill system
3
+ * Discovers .md skill files from .thincoder/skills/ directory,
4
+ * injects into system prompt for agent to load on demand.
5
+ * Use the skill tool to activate a specific skill; content is written into conversation history wrapped in <skill-loaded>.
6
6
  */
7
7
 
8
8
  import { readFile, readdir, stat } from "node:fs/promises"
9
9
  import { join } from "node:path"
10
10
 
11
11
  /**
12
- * 扫描 .thincoder/skills/ 目录,返回技能列表。
13
- * 每个技能:{ name, path, description } — name 取文件名(去扩展名)。
14
- * 目录不存在或无文件返回空数组。
12
+ * Scan .thincoder/skills/ directory, return skill list.
13
+ * Each skill: { name, path, description } — name is the filename (without extension).
14
+ * Returns empty array if directory is missing or empty.
15
15
  */
16
16
  export async function loadSkills(cwd) {
17
17
  const dir = join(cwd, ".thincoder", "skills")
@@ -23,13 +23,13 @@ export async function loadSkills(cwd) {
23
23
  }
24
24
  const skills = []
25
25
  for (const name of entries) {
26
- if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // readSkill 的名字校验一致,防"列得出、读不了"
26
+ if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // must match readSkill's name validation; prevents "listed but unreadable"
27
27
  const p = join(dir, name)
28
28
  try {
29
29
  const s = await stat(p)
30
30
  if (!s.isFile()) continue
31
- // 提取描述(前 400 字符里第一段非空、非标题行);文件带 frontmatter 时整块跳过,
32
- // 否则会把 frontmatter 字段行(如 "name: x")误当描述
31
+ // Extract description (first non-empty, non-heading line in first 400 chars);
32
+ // skip entire frontmatter block, otherwise frontmatter fields (e.g. "name: x") get mistaken for description
33
33
  const head = await readFile(p, "utf8")
34
34
  const body = head.slice(0, 400).split("\n")
35
35
  let desc = ""
@@ -45,16 +45,16 @@ export async function loadSkills(cwd) {
45
45
  }
46
46
  skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
47
47
  } catch {
48
- // 读失败跳过
48
+ // Read failure — skip
49
49
  }
50
50
  }
51
51
  return skills
52
52
  }
53
53
 
54
54
  /**
55
- * 生成技能列表文本,注入 system prompt
56
- * 最多 3 个(占位少),超过则标 "... and N more"
57
- * DISREGARD 开头:清单刷新(技能增删)后旧清单自动作废,无需删历史(借鉴 kimi-code)。
55
+ * Generate skill listing text for system prompt injection.
56
+ * At most 3 (small footprint); overflow marked "... and N more".
57
+ * Prefixed with DISREGARD: when listing refreshes (skills added/removed), old listings are auto-invalidated without needing to delete history (inspired by kimi-code).
58
58
  */
59
59
  export function formatSkillListing(skills) {
60
60
  if (skills.length === 0) return ""
@@ -65,11 +65,11 @@ export function formatSkillListing(skills) {
65
65
  }
66
66
 
67
67
  /**
68
- * 读取指定技能文件的完整内容。
69
- * 返回文本,找不到返回 null
68
+ * Read the full content of a specific skill file.
69
+ * Returns text, or null if not found.
70
70
  */
71
71
  export async function readSkill(cwd, name) {
72
- // 安全检查:技能名只能是字母数字 + 连字符/下划线
72
+ // Safety check: skill name must be alphanumeric + hyphens/underscores only
73
73
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null
74
74
  const p = join(cwd, ".thincoder", "skills", `${name}.md`)
75
75
  try {
@@ -1,11 +1,15 @@
1
1
  List, create, and restore workspace snapshots (checkpoints). Git repositories only.
2
2
 
3
3
  Parameters:
4
- - action (required): "list" | "create" | "rewind"
5
- - id: snapshot id (required for rewind)
4
+ - action (required): "list" | "create" | "rewind" | "cat"
5
+ - id: snapshot id (required for rewind and cat; optional for list — when given, shows the file tree inside that snapshot)
6
+ - path: for rewind — restore only this single file; for cat — read this file's content from the snapshot
6
7
 
7
8
  Notes:
8
9
  - A checkpoint is AUTO-CREATED before every user task. If uncommitted work was destroyed (by you, a git command, or a failed refactor), use action=list then action=rewind with the latest id to recover it
9
10
  - A checkpoint captures all uncommitted state: tracked-file changes (as a diff) plus copies of untracked files
10
11
  - Rewind first snapshots the current state, so rewinding is itself reversible
12
+ - Rewind AUTO-RECOVERS: if git apply fails (corrupt patch), the pre-rewind state is restored — never lose data
11
13
  - Create one manually before risky bulk operations
14
+ - list now shows which files changed (tracked + untracked); use path to recover individual files selectively
15
+ - cat reads a file's content from a snapshot without touching the worktree — useful for inspecting before rewinding
@@ -4,7 +4,8 @@ import {
4
4
  MAX_READ_LINES,
5
5
  gitDiffOne,
6
6
  autoSyntaxCheck,
7
- resolveInCwd
7
+ resolveInCwd,
8
+ resolveExternal,
8
9
  } from "./shared.mjs";
9
10
  import { mkdir } from "node:fs/promises";
10
11
  import { readFile } from "node:fs/promises";
@@ -13,6 +14,9 @@ import { writeFile } from "node:fs/promises";
13
14
  import { unlink } from "node:fs/promises";
14
15
  import { join, relative, dirname } from "node:path";
15
16
 
17
+ const MAX_FILE_READ_BYTES = 10_000_000
18
+ const MAX_IMAGE_BYTES = 15_000_000
19
+
16
20
  export const readTool = {
17
21
  name: "read",
18
22
  description: DESC("read"),
@@ -22,15 +26,16 @@ export const readTool = {
22
26
  path: { type: "string", description: "File path (relative to cwd or absolute)" },
23
27
  offset: { type: "number", description: "1-based line number to start from" },
24
28
  limit: { type: "number", description: `Max lines to return (default ${MAX_READ_LINES})` },
29
+ allowExternal: { type: "boolean", description: "Allow reading files outside the working directory. Only set true when the user explicitly provided an external path — never use this to explore beyond cwd on your own." },
25
30
  },
26
31
  required: ["path"],
27
32
  },
28
33
  readonly: true,
29
34
  async execute(args, ctx) {
30
- const abs = resolveInCwd(ctx, args.path)
31
- // 大文件保护:先查大小,超 10MB 拒绝整文件读入(offset/limit 只影响返回切片,不影响缓冲)
35
+ const abs = args.allowExternal ? resolveExternal(ctx, args.path) : resolveInCwd(ctx, args.path)
36
+ // Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
32
37
  const st = await stat(abs).catch(() => null)
33
- if (st && st.size > 10_000_000) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
38
+ if (st && st.size > MAX_FILE_READ_BYTES) throw new Error(`File too large (${Math.round(st.size / 1_000_000)}MB > 10MB limit). Use bash with head/tail or grep for targeted extraction.`)
34
39
  const content = await readFile(abs, "utf8")
35
40
  const lines = content.split("\n")
36
41
  const offset = Math.max(1, args.offset ?? 1)
@@ -57,15 +62,16 @@ export const readImageTool = {
57
62
  required: ["path"],
58
63
  },
59
64
  readonly: true,
60
- /** 返回 JSON{ text, images },供 agent 层转为多模态 user 消息 */
65
+ multimodal: true, // returns JSON { text, images } agent loop converts to multimodal user message
66
+ /** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
61
67
  async execute(args, ctx) {
62
68
  const abs = resolveInCwd(ctx, args.path)
63
69
  const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
64
70
  const mime = IMAGE_EXTENSIONS[ext]
65
71
  if (!mime) throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}`)
66
- // 先查大小再读入——防超大图片撑爆内存(20MB base64 ≈ 15MB 原始)
72
+ // Check size before reading — prevent huge images from blowing up memory (20MB base64 ≈ 15MB raw)
67
73
  const imgStat = await stat(abs).catch(() => null)
68
- if (imgStat && imgStat.size > 15_000_000) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
74
+ if (imgStat && imgStat.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
69
75
  const buf = await readFile(abs) // raw buffer, no encoding
70
76
  const b64 = buf.toString("base64")
71
77
  const bytes = buf.length
@@ -73,10 +79,10 @@ export const readImageTool = {
73
79
  text: `[read_image: ${args.path} (${mime}, ${bytes} bytes)]`,
74
80
  images: [{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } }],
75
81
  })
76
- // 粘贴产生的临时文件用完即删,不留垃圾
82
+ // Paste-created temp files: delete after use, no litter
77
83
  const basename = abs.includes("/") ? abs.slice(abs.lastIndexOf("/") + 1) : abs.slice(abs.lastIndexOf("\\") + 1)
78
84
  if (basename.startsWith(".thincoder-paste-")) {
79
- try { await unlink(abs) } catch { /* 删不掉就算了 */ }
85
+ try { await unlink(abs) } catch { /* can't delete, so be it */ }
80
86
  }
81
87
  return result
82
88
  },
@@ -103,7 +109,7 @@ export const writeTool = {
103
109
  if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
104
110
  await writeFile(abs, args.content, "utf8")
105
111
  const diff = gitDiffOne(ctx.cwd, abs)
106
- return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
112
+ return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
107
113
  },
108
114
  }
109
115
 
@@ -131,7 +137,7 @@ export const editTool = {
131
137
  const content = await readFile(abs, "utf8")
132
138
  const occurrences = content.split(args.old_string).length - 1
133
139
  if (occurrences === 0) {
134
- // 给出线索帮模型定位:首行预览 + 常见原因
140
+ // Give clues to help the model locate: first-line preview + common causes
135
141
  const preview = args.old_string.slice(0, 100).split("\n")[0]
136
142
  throw new Error(
137
143
  `old_string not found in ${args.path}\n` +
@@ -144,11 +150,11 @@ export const editTool = {
144
150
  }
145
151
  const updated = args.replace_all
146
152
  ? content.split(args.old_string).join(args.new_string)
147
- // 函数式替换:避免 new_string 里的 $ 替换模式(匹配串/前后文引用)被展开
153
+ // Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
148
154
  : content.replace(args.old_string, () => args.new_string)
149
155
  await writeFile(abs, updated, "utf8")
150
156
  const diff = gitDiffOne(ctx.cwd, abs)
151
- return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
157
+ return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
152
158
  },
153
159
  }
154
160
 
@@ -199,7 +205,7 @@ export const insertAfterTool = {
199
205
  const updated = lines.join("\n")
200
206
  await writeFile(abs, updated, "utf8")
201
207
  const diff = gitDiffOne(ctx.cwd, abs)
202
- return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
208
+ return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
203
209
  },
204
210
  }
205
211
 
package/src/tools/git.mjs CHANGED
@@ -20,7 +20,7 @@ export const gitDiffTool = {
20
20
  readonly: true,
21
21
  execute(args, ctx) {
22
22
  const ref = args.ref ?? "HEAD"
23
- // ref 由模型提供且位于 "--" 之前:校验字符集,防 "--output=..." 之类被 git 当成选项
23
+ // ref supplied by model and placed before "--": validate charset, prevent "--output=..." etc. from being treated as git options
24
24
  if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
25
25
  const flags = args.staged ? ["--staged"] : []
26
26
  const paths = args.path ? [args.path] : []
@@ -49,14 +49,14 @@ export const gitStatusTool = {
49
49
  const conflicts = []
50
50
  for (const line of porcelain.split("\n")) {
51
51
  if (!line) continue
52
- // porcelain: XY path — 2 状态字符 + 空格 + 文件路径(部分环境只 1 空格)
53
- // 去掉可能的 CRexecFileSync 在某些 Windows git 下会残留 \r 在行末但不在换行符中)
52
+ // porcelain: XY path — 2 status chars + space + file path (some environments have only 1 space)
53
+ // Strip possible CR (execFileSync on some Windows git leaves \r at end of line but not in newline)
54
54
  const clean = line.replace(/\r/g, "")
55
- // 尝试匹配 "XY path" "XY path"(可变间距)
55
+ // Try matching "XY path" or "XY path" (variable spacing)
56
56
  const m = clean.match(/^(..?)\s+(.+)$/)
57
57
  if (!m) continue
58
58
  const [, status, rawFile] = m
59
- // 重命名条目 porcelain 输出为 "R old -> new",拆开明确展示而非当成一个字面文件名
59
+ // Rename entries in porcelain output are "R old -> new", split for clarity instead of treating as a literal filename
60
60
  const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
61
61
  const idx = status[0] ?? " "
62
62
  const wt = status[1] ?? " "
@@ -128,7 +128,7 @@ export const questionTool = {
128
128
  },
129
129
  }
130
130
 
131
- /** 执行 git 命令;非 git 仓库 / git 不可用时返回空字符串 */
131
+ /** Execute a git command; returns empty string when not a git repo / git unavailable */
132
132
 
133
133
  // ---------------------------------------------------------------- checkpoint
134
134
 
@@ -138,8 +138,9 @@ export const checkpointTool = {
138
138
  parameters: {
139
139
  type: "object",
140
140
  properties: {
141
- action: { type: "string", enum: ["list", "create", "rewind"], description: "list snapshots / create one now / restore a snapshot by id" },
142
- id: { type: "string", description: "Snapshot id (required for rewind)" },
141
+ action: { type: "string", enum: ["list", "create", "rewind", "cat"], description: "list snapshots / create one now / restore a snapshot by id / read a file's content from a snapshot" },
142
+ id: { type: "string", description: "Snapshot id (required for rewind and cat; optional for list — shows file tree of that snapshot)" },
143
+ path: { type: "string", description: "Restore only this single file from the checkpoint (tracked or untracked). Other files are left untouched." },
143
144
  },
144
145
  required: ["action"],
145
146
  },
@@ -149,18 +150,88 @@ export const checkpointTool = {
149
150
  if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
150
151
  if (args.action === "create") {
151
152
  const cp = await createCheckpoint(ctx.cwd)
152
- return `Checkpoint ${cp.id} created (${cp.files} file(s) captured)`
153
+ return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
153
154
  }
154
155
  if (args.action === "rewind") {
155
156
  if (!args.id) throw new Error("id is required for rewind — use action=list to see snapshot ids")
156
- const s = await rewind(ctx.cwd, args.id)
157
- return `Rewound to checkpoint ${args.id}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored} untracked file(s) restored, ${s.deleted} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
157
+ const s = await rewind(ctx.cwd, args.id, { path: args.path })
158
+ if (args.path) {
159
+ return `Restored "${args.path}" (${s.type}) from checkpoint ${args.id}.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
160
+ }
161
+ return `Rewound to checkpoint ${args.id}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored ?? 0} untracked file(s) restored, ${s.deleted ?? 0} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
162
+ }
163
+ if (args.action === "cat") {
164
+ if (!args.id) throw new Error("id is required for cat — use action=list to see snapshot ids")
165
+ if (!args.path) throw new Error("path is required for cat — specify which file to read")
166
+ const { catFile } = await import("../git/checkpoint.mjs")
167
+ return await catFile(ctx.cwd, args.id, args.path)
158
168
  }
159
169
  if (args.action === "list") {
160
170
  const cps = await listCheckpoints(ctx.cwd)
161
171
  if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
162
- return cps.map((c) => `${c.id} ${new Date(c.time).toISOString()} ${c.untracked} untracked file(s)`).join("\n")
172
+
173
+ // Specific id: show the file tree within that snapshot
174
+ if (args.id) {
175
+ const cp = cps.find((c) => c.id === args.id)
176
+ if (!cp) throw new Error(`checkpoint ${args.id} not found`)
177
+ return formatFileTree(cp)
178
+ }
179
+
180
+ // Overview: list of all snapshots
181
+ return cps.map((c) => {
182
+ const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
183
+ if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.join(", ")}`)
184
+ if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.join(", ")}`)
185
+ return parts.join(" ")
186
+ }).join("\n")
163
187
  }
164
188
  throw new Error(`Unknown action: ${args.action}`)
165
189
  },
166
190
  }
191
+
192
+ /** Format a checkpoint's file list as a directory tree (directories first, indented display) */
193
+ function formatFileTree(cp) {
194
+ const all = [
195
+ ...(cp.tracked ?? []).map((f) => ({ path: f, type: "" })),
196
+ ...(cp.untracked ?? []).map((f) => ({ path: f, type: " (untracked)" })),
197
+ ]
198
+ if (all.length === 0) return "(empty checkpoint)"
199
+
200
+ // Sort by path (directories group naturally)
201
+ all.sort((a, b) => a.path.localeCompare(b.path))
202
+
203
+ // Build directory → file list map
204
+ const tree = new Map()
205
+ for (const { path, type } of all) {
206
+ const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
207
+ if (!tree.has(dir)) tree.set(dir, [])
208
+ tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
209
+ }
210
+
211
+ // Output sorted by directory
212
+ const lines = []
213
+ const dirs = [...tree.keys()].sort()
214
+ for (const dir of dirs) {
215
+ if (dir !== "." && !lines.includes(dir + "/")) {
216
+ // Parent directory before child directory
217
+ const parts = dir.split("/")
218
+ for (let i = 1; i <= parts.length; i++) {
219
+ const prefix = parts.slice(0, i).join("/") + "/"
220
+ if (!lines.includes(prefix)) lines.push(prefix)
221
+ }
222
+ }
223
+ }
224
+ // Ensure directories precede files
225
+ for (const dir of dirs) {
226
+ if (dir !== ".") {
227
+ for (const { name, type } of tree.get(dir)) {
228
+ lines.push(` ${dir}/${name}${type}`)
229
+ }
230
+ }
231
+ }
232
+ for (const { name, type } of tree.get(".") ?? []) {
233
+ lines.push(name + type)
234
+ }
235
+
236
+ return lines.join("\n")
237
+ }
@@ -1,4 +1,4 @@
1
- // tools/index.mjs — 后端兼容重导出
1
+ // tools/index.mjs — backend-compatible re-export
2
2
  export { toOpenAISchema } from "./shared.mjs";
3
3
 
4
4
  import { readTool, writeTool, editTool, insertAfterTool, readImageTool } from "./file.mjs";
package/src/tools/ls.md CHANGED
@@ -5,5 +5,5 @@ Parameters:
5
5
 
6
6
  Notes:
7
7
  - Shows first 500 entries
8
- - Directories are prefixed with `/` and listed before files
8
+ - Directories are suffixed with `/` and listed before files
9
9
  - Use this for a quick overview; use glob when you have a specific file pattern in mind