thincoder 0.8.2 → 0.8.3

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 (97) 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 +7 -6
  8. package/src/agent-tools/plan.mjs +3 -3
  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 +4 -4
  13. package/src/agent-tools/verify.mjs +21 -19
  14. package/src/agent-tools.mjs +3 -3
  15. package/src/agent.mjs +58 -37
  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 +55 -53
  22. package/src/context.mjs +59 -56
  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 +1 -1
  40. package/src/prompts/discipline.md +3 -0
  41. package/src/prompts/main.md +1 -2
  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 -40
  47. package/src/skills.mjs +17 -17
  48. package/src/tools/checkpoint.md +6 -2
  49. package/src/tools/file.mjs +16 -12
  50. package/src/tools/git.mjs +83 -12
  51. package/src/tools/index.mjs +1 -1
  52. package/src/tools/patch.mjs +20 -18
  53. package/src/tools/repomap-parse.mjs +17 -17
  54. package/src/tools/repomap.mjs +29 -29
  55. package/src/tools/shared.mjs +49 -28
  56. package/src/tools/system.mjs +163 -118
  57. package/src/tools/web.mjs +6 -6
  58. package/src/tui/agent-turn.mjs +82 -26
  59. package/src/tui/ansi.mjs +4 -2
  60. package/src/tui/clipboard.mjs +2 -2
  61. package/src/tui/cmd-auto.mjs +1 -1
  62. package/src/tui/cmd-clear.mjs +1 -1
  63. package/src/tui/cmd-config.mjs +2 -2
  64. package/src/tui/cmd-exit.mjs +2 -2
  65. package/src/tui/cmd-extract.mjs +1 -1
  66. package/src/tui/cmd-goal.mjs +4 -4
  67. package/src/tui/cmd-help.mjs +1 -1
  68. package/src/tui/cmd-init.mjs +3 -3
  69. package/src/tui/cmd-mcp.mjs +5 -5
  70. package/src/tui/cmd-model.mjs +1 -1
  71. package/src/tui/cmd-new.mjs +1 -1
  72. package/src/tui/cmd-plan.mjs +1 -1
  73. package/src/tui/cmd-reindex.mjs +2 -2
  74. package/src/tui/cmd-restore.mjs +1 -1
  75. package/src/tui/cmd-session.mjs +1 -1
  76. package/src/tui/cmd-skills.mjs +1 -1
  77. package/src/tui/cmd-think.mjs +2 -2
  78. package/src/tui/config-helpers.mjs +6 -6
  79. package/src/tui/distill-cmd.mjs +3 -3
  80. package/src/tui/index.mjs +131 -72
  81. package/src/tui/interaction.mjs +13 -12
  82. package/src/tui/key-handler.mjs +31 -23
  83. package/src/tui/layout.mjs +22 -17
  84. package/src/tui/pickers.mjs +19 -19
  85. package/src/tui/render-frame.mjs +37 -11
  86. package/src/tui/render.mjs +25 -24
  87. package/src/tui/slash-commands.mjs +14 -14
  88. package/src/tui/startup.mjs +15 -14
  89. package/src/tui/wizard.mjs +11 -11
  90. package/src/tui.mjs +2 -2
  91. package/bin/thincoder.js +0 -4
  92. package/src/tools/bash.mjs +0 -144
  93. package/src/tools/glob.mjs +0 -51
  94. package/src/tools/grep.mjs +0 -100
  95. package/src/tools/ls.mjs +0 -36
  96. package/src/tools.mjs +0 -2
  97. package/src/tui-render.mjs +0 -4
@@ -1,5 +1,5 @@
1
1
  /**
2
- * provider/core.mjs — LLM 调用核心
2
+ * provider/core.mjs — LLM call core
3
3
  * chat / listModels / createProvider / requestWithRetry / readSSE
4
4
  */
5
5
 
@@ -10,10 +10,13 @@ import {
10
10
  estimateRequestTokens, rateGate, recordRate,
11
11
  } from "./rate.mjs"
12
12
 
13
+ const FETCH_TIMEOUT_MS = 120000
14
+
15
+ /** Create a validated provider config object from raw config */
13
16
  export function createProvider(config) {
14
- if (!config?.baseURL) throw new Error("provider config: baseURL is required")
15
- if (!config?.apiKey) throw new Error("provider config: apiKey is required (config file or THINCODER_API_KEY env)")
16
- if (!config?.model) throw new Error("provider config: model is required")
17
+ if (!config?.baseURL) throw new Error("provider config: baseURL is required — configure providers in ~/.thincoder/config.json")
18
+ if (!config?.apiKey) throw new Error("provider config: apiKey is required set THINCODER_API_KEY env or configure in ~/.thincoder/config.json")
19
+ if (!config?.model) throw new Error("provider config: model is required — configure in ~/.thincoder/config.json")
17
20
  return {
18
21
  baseURL: config.baseURL.replace(/\/+$/, ""),
19
22
  apiKey: config.apiKey,
@@ -27,6 +30,7 @@ export function createProvider(config) {
27
30
  }
28
31
  }
29
32
 
33
+ /** Send a streaming chat completion request with automatic continuation on truncation */
30
34
  export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal }) {
31
35
  const spec = specForModel(provider.model)
32
36
  const body = {
@@ -108,6 +112,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
108
112
  return result
109
113
  }
110
114
 
115
+ /** List available model IDs from the provider's /models endpoint */
111
116
  export async function listModels(provider, { signal } = {}) {
112
117
  const response = await fetch(`${provider.baseURL}/models`, {
113
118
  headers: { Authorization: `Bearer ${provider.apiKey}` },
@@ -138,7 +143,7 @@ async function requestWithRetry(provider, body, signal, onWait) {
138
143
  Authorization: `Bearer ${provider.apiKey}`,
139
144
  },
140
145
  body: JSON.stringify(body),
141
- signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(120000)]) : AbortSignal.timeout(120000),
146
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS),
142
147
  })
143
148
  } catch (error) {
144
149
  if (error.name === "AbortError") throw error
@@ -233,7 +238,7 @@ async function readSSE(response, { onToken, onReasoning }) {
233
238
  }
234
239
 
235
240
  function betaBaseURL(baseURL) {
236
- // DeepSeek prefix 续写走 /beta 端点;只处理 /v1 后缀,缺 /v1 时追加 /beta
241
+ // DeepSeek prefix continuation uses /beta endpoint; only handle /v1 suffix, append /beta when /v1 is missing
237
242
  if (/\/v1$/.test(baseURL)) return baseURL.replace(/\/v1$/, "/beta")
238
243
  return baseURL.endsWith("/") ? baseURL + "beta" : baseURL + "/beta"
239
244
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * provider/index.mjs — 后端兼容重导出
3
- * import { chat } from "./provider" → 自动解析到本文件
2
+ * provider/index.mjs — backward-compatible re-export
3
+ * import { chat } from "./provider" → resolves to this file
4
4
  */
5
5
  export { chat, createProvider, listModels } from "./core.mjs"
6
6
  export { RETRYABLE_STATUS, _rateHooks, estimateText, estimateRequestTokens, rateGate, recordRate } from "./rate.mjs"
@@ -1,6 +1,6 @@
1
1
  /**
2
- * provider/rate.mjs — TPM/RPM 主动节流闸门
3
- * 滑动窗口记账,发请求前预检预算,超支则睡到窗口腾出空间。
2
+ * provider/rate.mjs — TPM/RPM proactive throttling gate
3
+ * Sliding-window accounting; pre-check budget before sending requests; sleep until window frees space when over budget.
4
4
  */
5
5
 
6
6
  import { specForModel } from "../config.mjs"
@@ -11,8 +11,8 @@ export const MAX_CONTINUATIONS = 3
11
11
  export const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
12
12
 
13
13
  /**
14
- * 测试钩子:睡眠/时钟/窗口长度可替换(离线测试不能真等 60s)。
15
- * 生产代码不要直接调 setTimeout/sleep,统一走这里。
14
+ * Test hooks: sleep/clock/window length are replaceable (offline tests can't really wait 60s).
15
+ * Production code should never call setTimeout/sleep directly — always go through these.
16
16
  */
17
17
  export const _rateHooks = {
18
18
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -23,20 +23,20 @@ export const _rateHooks = {
23
23
  const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
24
24
 
25
25
  function rateKey(provider) {
26
- // 归一化:/beta /v1 视为同一账户的同一限流窗口(DeepSeek prefix continuation /beta 端点)
26
+ // Normalize: /beta and /v1 are treated as the same account's rate-limit window (DeepSeek prefix continuation switches to /beta endpoint)
27
27
  const base = provider.baseURL.replace(/\/beta$/, "/v1")
28
28
  return `${base}|${provider.apiKey ?? ""}`
29
29
  }
30
30
 
31
- /** 粗估文本 token 数。
32
- * ASCII ~4 字符/token;非 ASCIICJK/emoji)按 ~1 字符/token(保守,实测 BPE 1.5-2.5 字/token)。 */
31
+ /** Rough estimate of text token count.
32
+ * ASCII ~4 chars/token; non-ASCII (CJK/emoji) ~1 char/token (conservative; measured BPE is 1.5-2.5 chars/token). */
33
33
  export function estimateText(s) {
34
34
  let nonAscii = 0
35
35
  for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
36
36
  return Math.ceil((s.length - nonAscii) / 4) + nonAscii
37
37
  }
38
38
 
39
- /** 本次请求的 prompt 估算 */
39
+ /** Estimated prompt tokens for this request */
40
40
  export function estimateRequestTokens(body) {
41
41
  let tokens = 0
42
42
  for (const m of body.messages ?? []) {
@@ -50,7 +50,7 @@ export function estimateRequestTokens(body) {
50
50
  return tokens
51
51
  }
52
52
 
53
- /** 闸门:超预算则睡到窗口腾出空间 */
53
+ /** Gate: sleep until window frees space when over budget */
54
54
  export async function rateGate(provider, estimated, onWait, signal) {
55
55
  const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
56
56
  const rpm = provider.rpm ?? null
@@ -87,7 +87,7 @@ export async function rateGate(provider, estimated, onWait, signal) {
87
87
  }
88
88
  }
89
89
 
90
- /** 记账:响应回来后按实测 usage */
90
+ /** Accounting: record measured usage after response returns */
91
91
  export function recordRate(provider, estimated, usage) {
92
92
  if (provider.tpm == null && provider.rpm == null) return
93
93
  const key = rateKey(provider)
@@ -98,7 +98,7 @@ export function recordRate(provider, estimated, usage) {
98
98
  w.requests = w.requests.filter((ts) => ts > cutoff)
99
99
  w.requests.push(now)
100
100
  w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
101
- // 窗口已空则删条目,防长期跨 provider 配置时 Map 无界增长
101
+ // Delete entry when window is empty, preventing unbounded Map growth across long-running provider configs
102
102
  if (w.tokens.length === 0 && w.requests.length === 0) rateWindows.delete(key)
103
103
  else rateWindows.set(key, w)
104
104
  }
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,7 +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
- // 重置轮次计数器:切换会话后不应继承旧会话的停滞/压缩状态
217
+ // Reset turn counters: after switching sessions, should not inherit old session's stall/compaction state
186
218
  agent._turnsSinceTaskUpdate = 0
187
219
  agent._turnsInPlanMode = 0
188
220
  agent._compressFailures = 0
@@ -199,12 +231,13 @@ export function applySession(agent, data) {
199
231
  return false
200
232
  }
201
233
 
234
+ /** Archive current session and reset the session file to empty state */
202
235
  export function clearSession(cwd) {
203
236
  try {
204
237
  archiveCurrent(cwd)
205
238
  const p = sessionPath(cwd)
206
239
  writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, pendingReminders: [], sessionStart: null })
207
240
  } catch {
208
- // 清不掉就算了,下次保存会覆盖
241
+ // Can't clear, oh well — next save will overwrite
209
242
  }
210
243
  }
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
@@ -13,6 +13,9 @@ import { writeFile } from "node:fs/promises";
13
13
  import { unlink } from "node:fs/promises";
14
14
  import { join, relative, dirname } from "node:path";
15
15
 
16
+ const MAX_FILE_READ_BYTES = 10_000_000
17
+ const MAX_IMAGE_BYTES = 15_000_000
18
+
16
19
  export const readTool = {
17
20
  name: "read",
18
21
  description: DESC("read"),
@@ -28,9 +31,9 @@ export const readTool = {
28
31
  readonly: true,
29
32
  async execute(args, ctx) {
30
33
  const abs = resolveInCwd(ctx, args.path)
31
- // 大文件保护:先查大小,超 10MB 拒绝整文件读入(offset/limit 只影响返回切片,不影响缓冲)
34
+ // Large file guard: check size first, reject reading entire file if >10MB (offset/limit only affect the returned slice, not buffering)
32
35
  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.`)
36
+ 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
37
  const content = await readFile(abs, "utf8")
35
38
  const lines = content.split("\n")
36
39
  const offset = Math.max(1, args.offset ?? 1)
@@ -57,15 +60,16 @@ export const readImageTool = {
57
60
  required: ["path"],
58
61
  },
59
62
  readonly: true,
60
- /** 返回 JSON{ text, images },供 agent 层转为多模态 user 消息 */
63
+ multimodal: true, // returns JSON { text, images } agent loop converts to multimodal user message
64
+ /** Returns JSON: { text, images }, for the agent layer to convert into multimodal user messages */
61
65
  async execute(args, ctx) {
62
66
  const abs = resolveInCwd(ctx, args.path)
63
67
  const ext = abs.slice(abs.lastIndexOf(".") + 1).toLowerCase()
64
68
  const mime = IMAGE_EXTENSIONS[ext]
65
69
  if (!mime) throw new Error(`Unsupported image format: .${ext}. Supported: ${Object.keys(IMAGE_EXTENSIONS).join(", ")}`)
66
- // 先查大小再读入——防超大图片撑爆内存(20MB base64 ≈ 15MB 原始)
70
+ // Check size before reading — prevent huge images from blowing up memory (20MB base64 ≈ 15MB raw)
67
71
  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)`)
72
+ if (imgStat && imgStat.size > MAX_IMAGE_BYTES) throw new Error(`Image too large: ${Math.round(imgStat.size / 1_000_000)}MB (max 15MB)`)
69
73
  const buf = await readFile(abs) // raw buffer, no encoding
70
74
  const b64 = buf.toString("base64")
71
75
  const bytes = buf.length
@@ -73,10 +77,10 @@ export const readImageTool = {
73
77
  text: `[read_image: ${args.path} (${mime}, ${bytes} bytes)]`,
74
78
  images: [{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } }],
75
79
  })
76
- // 粘贴产生的临时文件用完即删,不留垃圾
80
+ // Paste-created temp files: delete after use, no litter
77
81
  const basename = abs.includes("/") ? abs.slice(abs.lastIndexOf("/") + 1) : abs.slice(abs.lastIndexOf("\\") + 1)
78
82
  if (basename.startsWith(".thincoder-paste-")) {
79
- try { await unlink(abs) } catch { /* 删不掉就算了 */ }
83
+ try { await unlink(abs) } catch { /* can't delete, so be it */ }
80
84
  }
81
85
  return result
82
86
  },
@@ -103,7 +107,7 @@ export const writeTool = {
103
107
  if (st?.isDirectory()) throw new Error(`Path is a directory: ${args.path}`)
104
108
  await writeFile(abs, args.content, "utf8")
105
109
  const diff = gitDiffOne(ctx.cwd, abs)
106
- return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
110
+ return `Wrote ${args.content.length} chars to ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
107
111
  },
108
112
  }
109
113
 
@@ -131,7 +135,7 @@ export const editTool = {
131
135
  const content = await readFile(abs, "utf8")
132
136
  const occurrences = content.split(args.old_string).length - 1
133
137
  if (occurrences === 0) {
134
- // 给出线索帮模型定位:首行预览 + 常见原因
138
+ // Give clues to help the model locate: first-line preview + common causes
135
139
  const preview = args.old_string.slice(0, 100).split("\n")[0]
136
140
  throw new Error(
137
141
  `old_string not found in ${args.path}\n` +
@@ -144,11 +148,11 @@ export const editTool = {
144
148
  }
145
149
  const updated = args.replace_all
146
150
  ? content.split(args.old_string).join(args.new_string)
147
- // 函数式替换:避免 new_string 里的 $ 替换模式(匹配串/前后文引用)被展开
151
+ // Functional replacement: avoid $-substitution patterns in new_string (match string / backreference) being expanded
148
152
  : content.replace(args.old_string, () => args.new_string)
149
153
  await writeFile(abs, updated, "utf8")
150
154
  const diff = gitDiffOne(ctx.cwd, abs)
151
- return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
155
+ return `Edited ${args.path}: replaced ${args.replace_all ? occurrences : 1} occurrence(s)${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
152
156
  },
153
157
  }
154
158
 
@@ -199,7 +203,7 @@ export const insertAfterTool = {
199
203
  const updated = lines.join("\n")
200
204
  await writeFile(abs, updated, "utf8")
201
205
  const diff = gitDiffOne(ctx.cwd, abs)
202
- return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${autoSyntaxCheck(abs)}`
206
+ return `Inserted after line ${targetLine} in ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
203
207
  },
204
208
  }
205
209