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
@@ -1,16 +1,18 @@
1
1
  /**
2
- * checkpoint.mjs — 工作区快照与回滚
3
- * 快照 = git diff HEAD 补丁 + 未跟踪文件副本(尊重 .gitignore)。
4
- * git 仓库内可用。回滚前会先打新快照(回滚可逆)。
2
+ * checkpoint.mjs — workspace snapshot and rollback
3
+ * Snapshot = git diff HEAD patch + untracked file copies (respects .gitignore).
4
+ * Only available inside git repos. Rewind creates a new snapshot first (rewind is reversible).
5
+ * rewind supports a path parameter for per-file restore (restores only the specified file).
5
6
  */
6
-
7
7
  import { execFileSync } from "node:child_process"
8
8
  import { createHash } from "node:crypto"
9
- import { existsSync } from "node:fs"
9
+ import { existsSync, readFileSync } from "node:fs"
10
10
  import { cp, mkdir, readFile, readdir, rm, writeFile, copyFile } from "node:fs/promises"
11
- import { dirname, join, relative } from "node:path"
11
+ import { dirname, join } from "node:path"
12
12
  import { configDir } from "../config.mjs"
13
13
 
14
+ const CWD_HASH_LEN = 12
15
+
14
16
  const MAX_CHECKPOINTS = 20
15
17
 
16
18
  function git(cwd, args, { allowFail = false } = {}) {
@@ -23,46 +25,71 @@ function git(cwd, args, { allowFail = false } = {}) {
23
25
  }
24
26
 
25
27
  function checkpointRoot(cwd) {
26
- const hash = createHash("sha1").update(cwd).digest("hex").slice(0, 12)
28
+ const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
27
29
  return join(configDir, "checkpoints", hash)
28
30
  }
29
31
 
30
- /** 当前目录是否 git 仓库 */
32
+ /** Extract the list of tracked files changed from a unified diff */
33
+ function trackedFilesFromPatch(patch) {
34
+ if (!patch.trim()) return []
35
+ const files = new Set()
36
+ for (const m of patch.matchAll(/^--- a\/(.+)$/gm)) files.add(m[1])
37
+ return [...files].sort()
38
+ }
39
+
40
+ /** Extract a single file's patch hunks from a unified diff */
41
+ function extractFileHunks(patch, filePath) {
42
+ // Split by file: each file block starts with "diff --git"
43
+ const sections = patch.split(/(?=^diff --git )/m)
44
+ for (const sec of sections) {
45
+ if (!sec.trim()) continue
46
+ const m = sec.match(/^diff --git a\/(.+) b\/(.+)/m)
47
+ if (!m) continue
48
+ if (m[1] === filePath || m[2] === filePath) return sec.trim()
49
+ }
50
+ return ""
51
+ }
52
+
53
+ /** Whether the current directory is a git repo */
31
54
  export function isGitRepo(cwd) {
32
55
  return git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFail: true }) === "true"
33
56
  }
34
57
 
35
58
  /**
36
- * 打快照。返回 { id, time, files } null(非 git 仓库)。
59
+ * Create a snapshot. Returns { id, time, files } or null (non-git repo).
37
60
  */
38
61
  export async function createCheckpoint(cwd) {
39
62
  if (!isGitRepo(cwd)) return null
40
63
 
41
- // 随机后缀:同一毫秒内两次快照的 id 不互撞(排序仍按时间戳前缀有序)
64
+ // Random suffix: prevents id collisions for two snapshots in the same millisecond (sorting stays ordered by timestamp prefix)
42
65
  const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6)
43
66
  const dir = join(checkpointRoot(cwd), id)
44
67
  await mkdir(join(dir, "untracked"), { recursive: true })
45
68
 
46
- // 跟踪文件的改动补丁
69
+ // Tracked file changes patch
47
70
  const patch = git(cwd, ["diff", "HEAD", "--binary"], { allowFail: true }) ?? ""
48
71
  await writeFile(join(dir, "patch.diff"), patch, "utf8")
72
+ const tracked = trackedFilesFromPatch(patch)
49
73
 
50
- // 未跟踪文件(尊重 .gitignore)→ 原样复制
74
+ // Untracked files (respects .gitignore) → copy as-is
51
75
  const untrackedRaw = git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? ""
52
76
  const untracked = untrackedRaw ? untrackedRaw.split("\n").filter(Boolean) : []
53
77
  for (const rel of untracked) {
54
78
  const src = join(cwd, rel)
55
79
  const dst = join(dir, "untracked", rel)
56
80
  await mkdir(dirname(dst), { recursive: true })
57
- await copyFile(src, dst).catch(() => {}) // 复制失败(socket/设备文件等)跳过
81
+ await copyFile(src, dst).catch(() => {}) // Copy failed (socket/device file etc.) — skip
58
82
  }
59
- await writeFile(join(dir, "meta.json"), JSON.stringify({ id, time: Date.now(), untracked }, null, 2), "utf8")
83
+
84
+ await writeFile(join(dir, "meta.json"), JSON.stringify({
85
+ id, time: Date.now(), untracked, tracked,
86
+ }, null, 2), "utf8")
60
87
 
61
88
  await pruneCheckpoints(cwd)
62
- return { id, time: Date.now(), files: untracked.length + (patch ? 1 : 0) }
89
+ return { id, time: Date.now(), files: untracked.length + tracked.length, tracked, untracked }
63
90
  }
64
91
 
65
- /** 列出快照(新→旧) */
92
+ /** List checkpoints (newest→oldest), with file change summary */
66
93
  export async function listCheckpoints(cwd) {
67
94
  const root = checkpointRoot(cwd)
68
95
  if (!existsSync(root)) return []
@@ -71,37 +98,38 @@ export async function listCheckpoints(cwd) {
71
98
  for (const id of ids) {
72
99
  try {
73
100
  const meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8"))
74
- out.push({ id, time: meta.time, untracked: meta.untracked.length })
101
+ // Compat with old checkpoints (no tracked field): extract from patch file
102
+ const tracked = meta.tracked ?? (() => {
103
+ try {
104
+ return trackedFilesFromPatch(readFileSync(join(root, id, "patch.diff"), "utf8"))
105
+ } catch { return [] }
106
+ })()
107
+ out.push({
108
+ id, time: meta.time,
109
+ untracked: meta.untracked ?? [],
110
+ tracked,
111
+ })
75
112
  } catch {
76
- // 损坏的快照跳过
113
+ // Corrupted checkpoint — skip
77
114
  }
78
115
  }
79
116
  return out
80
117
  }
81
118
 
82
- /**
83
- * 回滚到指定快照(先把当前状态存成新快照,保证回滚可逆)。
84
- * 返回恢复摘要。
85
- */
86
- export async function rewind(cwd, id) {
87
- const dir = join(checkpointRoot(cwd), id)
88
- if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
89
- const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
90
-
91
- // 回滚也可逆:先给当前状态打快照
92
- await createCheckpoint(cwd)
119
+ // ---- restore core ----
93
120
 
94
- // 1. 工作区+暂存区 HEAD,再应用快照补丁快照时状态
95
- // 必须连暂存区一起重置:checkout -- . 只从 index 恢复工作区,
96
- // 有 staged 改动时工作区留下的是 staged 版本,补丁(diff HEAD,含 staged 内容)会 apply 失败
121
+ /** Full restore of tracked files from a checkpoint: reset to HEAD → apply patch */
122
+ async function fullRestoreTracked(cwd, dir) {
97
123
  git(cwd, ["restore", "--source=HEAD", "--staged", "--worktree", "."])
98
124
  const patch = await readFile(join(dir, "patch.diff"), "utf8")
99
125
  if (patch.trim()) {
100
- const patchFile = join(dir, "patch.diff")
101
- git(cwd, ["apply", "--whitespace=nowarn", patchFile])
126
+ git(cwd, ["apply", "--whitespace=nowarn", join(dir, "patch.diff")])
102
127
  }
128
+ return Boolean(patch.trim())
129
+ }
103
130
 
104
- // 2. 快照之后新建的未跟踪文件删除
131
+ /** Full restore of untracked files from a checkpoint: delete new ones restore from snapshot */
132
+ async function fullRestoreUntracked(cwd, dir, meta) {
105
133
  const nowUntracked = (git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? "")
106
134
  .split("\n")
107
135
  .filter(Boolean)
@@ -114,7 +142,6 @@ export async function rewind(cwd, id) {
114
142
  }
115
143
  }
116
144
 
117
- // 3. 快照时存在、现在被改/被删的未跟踪文件 → 还原
118
145
  let restored = 0
119
146
  for (const rel of meta.untracked) {
120
147
  const src = join(dir, "untracked", rel)
@@ -124,11 +151,157 @@ export async function rewind(cwd, id) {
124
151
  restored++
125
152
  }
126
153
  }
154
+ return { deleted, restored }
155
+ }
156
+
157
+ /** Restore a single tracked file: checkout HEAD → apply that file's patch hunks */
158
+ async function partialRestoreTracked(cwd, patchContent, filePath) {
159
+ // Reset the file to HEAD state first
160
+ git(cwd, ["checkout", "HEAD", "--", filePath], { allowFail: true })
161
+ const hunks = extractFileHunks(patchContent, filePath)
162
+ if (!hunks) return false
163
+ const tmpFile = join(checkpointRoot(cwd), ".tmp_partial.patch")
164
+ await writeFile(tmpFile, hunks, "utf8")
165
+ try {
166
+ git(cwd, ["apply", "--whitespace=nowarn", tmpFile])
167
+ return true
168
+ } finally {
169
+ await rm(tmpFile, { force: true })
170
+ }
171
+ }
172
+
173
+ /** Restore a single untracked file */
174
+ async function partialRestoreUntracked(cwd, dir, filePath) {
175
+ const src = join(dir, "untracked", filePath)
176
+ if (!existsSync(src)) return false
177
+ await mkdir(dirname(join(cwd, filePath)), { recursive: true })
178
+ await cp(src, join(cwd, filePath), { force: true })
179
+ return true
180
+ }
181
+
182
+ // ---- rewind ----
183
+
184
+ /**
185
+ * Rewind to a specific snapshot (saves current state as a new snapshot first, making rewind reversible).
186
+ *
187
+ * Options:
188
+ * - path: restore only this single file (tracked or untracked); other files are left untouched.
189
+ * Omit for a full rewind (all changes → snapshot state).
190
+ *
191
+ * Returns summary { patchApplied, deleted?, restored? }; in path mode includes { file, type }.
192
+ */
193
+ export async function rewind(cwd, id, { path } = {}) {
194
+ const root = checkpointRoot(cwd)
195
+ const dir = join(root, id)
196
+ if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
197
+ const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
198
+ const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
199
+
200
+ // Rewind is reversible: snapshot current state first
201
+ const preRewindCp = await createCheckpoint(cwd)
202
+
203
+ // ---- per-file restore ----
204
+ if (path) {
205
+ // Determine whether it's a tracked or untracked file
206
+ const isTracked = meta.tracked?.includes(path) ?? extractFileHunks(patchContent, path) !== ""
207
+ const inUntracked = (meta.untracked ?? []).includes(path)
208
+
209
+ if (!isTracked && !inUntracked) {
210
+ throw new Error(`file "${path}" not found in checkpoint ${id} (tracked: ${(meta.tracked ?? []).join(", ") || "none"}, untracked: ${(meta.untracked ?? []).join(", ") || "none"})`)
211
+ }
212
+
213
+ let ok = false
214
+ if (isTracked) {
215
+ ok = await partialRestoreTracked(cwd, patchContent, path)
216
+ }
217
+ if (inUntracked) {
218
+ ok = await partialRestoreUntracked(cwd, dir, path) || ok
219
+ }
220
+
221
+ return { path, type: isTracked ? "tracked" : "untracked", restored: ok, patchApplied: false }
222
+ }
223
+
224
+ // ---- full rewind ----
225
+ try {
226
+ const patchApplied = await fullRestoreTracked(cwd, dir)
227
+ const { deleted, restored } = await fullRestoreUntracked(cwd, dir, meta)
228
+ return { deleted, restored, patchApplied }
229
+ } catch (e) {
230
+ // git apply failed: working tree may have been reset to HEAD by restore.
231
+ // Restore from pre-rewind snapshot to ensure no data is lost on rewind failure
232
+ const preDir = join(root, preRewindCp.id)
233
+ try {
234
+ await fullRestoreTracked(cwd, preDir)
235
+ await fullRestoreUntracked(cwd, preDir, JSON.parse(await readFile(join(preDir, "meta.json"), "utf8")))
236
+ } catch {
237
+ // Double failure: pre-rewind may also be corrupt, stop trying
238
+ }
239
+ throw new Error(
240
+ `Rewind to ${id} failed: ${e.message}. ` +
241
+ `The pre-rewind state was restored from checkpoint ${preRewindCp.id} — no work was lost.`
242
+ )
243
+ }
244
+ }
127
245
 
128
- return { deleted, restored, patchApplied: Boolean(patch.trim()) }
246
+ // ---- view ----
247
+
248
+ /**
249
+ * View a file's content from a checkpoint (does not modify the working tree).
250
+ * Tracked files: temporarily restore via checkout HEAD + apply patch hunks, read, then restore original.
251
+ * Untracked files: read the copy directly from the checkpoint directory.
252
+ * Returns the file content string.
253
+ */
254
+ export async function catFile(cwd, id, filePath) {
255
+ const root = checkpointRoot(cwd)
256
+ const dir = join(root, id)
257
+ if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
258
+ const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
259
+ const patchContent = await readFile(join(dir, "patch.diff"), "utf8")
260
+
261
+ const isTracked = meta.tracked?.includes(filePath) ?? extractFileHunks(patchContent, filePath) !== ""
262
+ const inUntracked = (meta.untracked ?? []).includes(filePath)
263
+
264
+ if (!isTracked && !inUntracked) {
265
+ throw new Error(`file "${filePath}" not in checkpoint ${id}`)
266
+ }
267
+
268
+ // Untracked file: read the copy directly
269
+ if (inUntracked && !isTracked) {
270
+ const src = join(dir, "untracked", filePath)
271
+ if (!existsSync(src)) throw new Error(`untracked file "${filePath}" copy missing in checkpoint`)
272
+ return await readFile(src, "utf8")
273
+ }
274
+
275
+ // Tracked file: temporarily restore → read → restore working tree
276
+ const abs = join(cwd, filePath)
277
+ const existed = existsSync(abs)
278
+ let saved = null
279
+ if (existed) saved = await readFile(abs, "utf8")
280
+
281
+ try {
282
+ git(cwd, ["checkout", "HEAD", "--", filePath])
283
+ const hunks = extractFileHunks(patchContent, filePath)
284
+ if (hunks) {
285
+ const tmpPatch = join(root, ".tmp_cat.patch")
286
+ await writeFile(tmpPatch, hunks, "utf8")
287
+ try {
288
+ git(cwd, ["apply", "--whitespace=nowarn", tmpPatch])
289
+ } finally {
290
+ await rm(tmpPatch, { force: true })
291
+ }
292
+ }
293
+ return await readFile(abs, "utf8")
294
+ } finally {
295
+ // Restore original working tree state
296
+ if (existed) {
297
+ await writeFile(abs, saved, "utf8")
298
+ } else {
299
+ await rm(abs, { force: true })
300
+ }
301
+ }
129
302
  }
130
303
 
131
- /** 只留最近 MAX_CHECKPOINTS */
304
+ /** Keep only the most recent MAX_CHECKPOINTS */
132
305
  async function pruneCheckpoints(cwd) {
133
306
  const root = checkpointRoot(cwd)
134
307
  const ids = (await readdir(root)).sort()
@@ -1,8 +1,9 @@
1
1
  /**
2
- * gitmem.mjs — Team 层记忆的 git 同步
3
- * 全部通过 child_process 调系统 git,零依赖。
4
- * 冲突策略(已定):不同条目天然不冲突;真冲突时中止 rebase 保持仓库干净,
5
- * 报带手动指引的错误——不做自动合并。
2
+ * gitmem.mjs — Team-layer memory git sync
3
+ * All through child_process calling system git, zero dependencies.
4
+ * Conflict strategy (decided): different entries are naturally conflict-free;
5
+ * on real conflicts abort rebase to keep the repo clean,
6
+ * report an error with manual resolution guidance — no auto-merge.
6
7
  */
7
8
 
8
9
  import { execFile } from "node:child_process"
@@ -13,7 +14,7 @@ import { promisify } from "node:util"
13
14
 
14
15
  const execFileAsync = promisify(execFile)
15
16
 
16
- /** dir 下执行 git,失败抛带 stderr 的错误 */
17
+ /** Run git in dir, throw with stderr on failure */
17
18
  async function git(dir, args) {
18
19
  try {
19
20
  const { stdout } = await execFileAsync("git", args, { cwd: dir, encoding: "utf8" })
@@ -27,7 +28,7 @@ async function git(dir, args) {
27
28
  }
28
29
  }
29
30
 
30
- /** 团队仓库不存在则 clone。返回是否发生了 clone */
31
+ /** Clone team repo if it doesn't exist. Returns whether a clone happened */
31
32
  export async function ensureClone({ repo, dir }) {
32
33
  if (existsSync(join(dir, ".git"))) return false
33
34
  await mkdir(dirname(dir), { recursive: true })
@@ -36,12 +37,12 @@ export async function ensureClone({ repo, dir }) {
36
37
  }
37
38
 
38
39
  /**
39
- * 同步:pull --rebase。远端还是空仓库时直接跳过(首次使用前)。
40
- * 冲突时中止 rebase(保持仓库干净)并抛带指引的错误。
41
- * 返回 true=拉取成功(调用方随后 syncDir 重建索引)
40
+ * Sync: pull --rebase. Skip if remote is still empty (first-time use).
41
+ * On conflict, abort rebase (keep repo clean) and throw a guided error.
42
+ * Returns true = pull succeeded (caller should then syncDir to rebuild index)
42
43
  */
43
44
  export async function pullTeam(dir) {
44
- // 远端空仓库:没有可拉取的分支(ls-remote 无输出)
45
+ // Empty remote repo: no branch to pull (ls-remote has no output)
45
46
  const refs = await git(dir, ["ls-remote", "--heads", "origin"])
46
47
  if (!refs) return false
47
48
 
@@ -52,9 +53,9 @@ export async function pullTeam(dir) {
52
53
  if (await hasConflict(dir)) {
53
54
  await git(dir, ["rebase", "--abort"]).catch(() => {})
54
55
  throw new Error(
55
- `团队记忆同步冲突:本地与远端修改了同一条目。\n` +
56
- `请到 ${dir} 手动执行 git pull 解决冲突,然后重新运行 thincoder sync。\n` +
57
- `(本地仓库已恢复到同步前状态,未丢失任何内容)`,
56
+ `Team memory sync conflict: local and remote modified the same entry.\n` +
57
+ `Please resolve manually in ${dir} with \`git pull\`, then re-run \`thincoder sync\`.\n` +
58
+ `(The local repo has been restored to its pre-sync state — nothing was lost.)`,
58
59
  )
59
60
  }
60
61
  throw error
@@ -62,28 +63,28 @@ export async function pullTeam(dir) {
62
63
  }
63
64
 
64
65
  /**
65
- * 提交并推送一个条目文件。push 被拒(远端有新提交)时 pull --rebase 后重试一次;
66
- * rebase 冲突同样中止并报错。
66
+ * Commit and push an entry file. On push rejection (remote has new commits),
67
+ * pull --rebase then retry once; rebase conflicts also abort and error out.
67
68
  */
68
69
  export async function commitAndPush(dir, filename, message) {
69
70
  await git(dir, ["add", filename])
70
- // 内容没变化时 commit 会以 exit 1 "nothing to commit"——这是正常的幂等结果,不是错误
71
+ // Nothing to commit (content unchanged) this is normal idempotent behavior, not an error
71
72
  const dirty = await git(dir, ["status", "--porcelain", "--", filename])
72
73
  if (dirty) await git(dir, ["commit", "-m", message])
73
74
  try {
74
75
  await git(dir, ["push"])
75
76
  } catch {
76
- await pullTeam(dir) // 冲突时这里会抛出带指引的错误
77
+ await pullTeam(dir) // Conflicts will throw a guided error here
77
78
  await git(dir, ["push"])
78
79
  }
79
80
  }
80
81
 
81
- /** 当前是否处于 rebase 冲突状态(存在未合并路径) */
82
+ /** Whether currently in a rebase conflict state (unmerged paths exist) */
82
83
  async function hasConflict(dir) {
83
84
  try {
84
85
  const out = await git(dir, ["status", "--porcelain"])
85
- // 未合并状态共 7 种:DD AU UD UA DU AA UU——只看 UU/AA/DD 会漏掉带 U 的四种,
86
- // 漏判就不 abort,仓库留在冲突中间态(与"保持仓库干净"的承诺相悖)
86
+ // There are 7 unmerged states: DD AU UD UA DU AA UU — only checking UU/AA/DD misses 4 states with U,
87
+ // which means the rebase is left mid-conflict (violating the "keep repo clean" promise)
87
88
  return out.split("\n").some((l) => l[0] === "U" || l[1] === "U" || l.startsWith("AA") || l.startsWith("DD"))
88
89
  } catch {
89
90
  return false
package/src/markdown.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * markdown.mjs — 记忆条目的 markdown + frontmatter 格式
3
- * 零依赖解析/序列化。条目格式见 ARCHITECTURE-v2.md
2
+ * markdown.mjs — markdown + frontmatter format for memory entries
3
+ * Zero-dependency parsing/serialization. Entry format see ARCHITECTURE-v2.md.
4
4
  */
5
5
 
6
6
  const VALID_TYPES = new Set(["rule", "knowledge", "decision", "pattern"])
7
7
 
8
8
  /**
9
- * 解析 markdown 条目。
9
+ * Parse a markdown entry.
10
10
  * → { meta: { type, title, tags, author, created, embedding? }, content }
11
- * frontmatter 或缺必要字段时抛错。
11
+ * Throws if frontmatter is missing or required fields absent.
12
12
  */
13
13
  export function parseEntry(text) {
14
14
  const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
@@ -36,13 +36,13 @@ export function parseEntry(text) {
36
36
  }
37
37
 
38
38
  /**
39
- * 序列化为 markdown 条目文本。
39
+ * Serialize to markdown entry text.
40
40
  */
41
41
  export function serializeEntry(meta, content) {
42
42
  if (!VALID_TYPES.has(meta.type)) throw new Error(`invalid type "${meta.type}"`)
43
43
  if (!meta.title) throw new Error("meta.title is required")
44
- // frontmatter 标量必须单行:title/author 含换行会注入伪 frontmatter
45
- // (如 title "x\ntype: rule" 解析时覆盖真实 type),tags 含换行/逗号同理
44
+ // frontmatter scalars must be single-line: newlines in title/author would inject fake frontmatter rows
45
+ // (e.g. title "x\ntype: rule" would override real type when parsed), same for tags with newlines/commas
46
46
  const tags = (meta.tags ?? []).map((t) => oneLine(t).replaceAll(",", " ")).join(", ")
47
47
  const lines = [
48
48
  "---",
@@ -57,7 +57,7 @@ export function serializeEntry(meta, content) {
57
57
  return lines.join("\n")
58
58
  }
59
59
 
60
- /** 标题转文件名 slug:保留中英文数字,其余转连字符 */
60
+ /** Convert title to filename slug: keep alphanumeric + CJK, convert rest to hyphens */
61
61
  export function slugify(title) {
62
62
  return title
63
63
  .trim()
@@ -67,23 +67,23 @@ export function slugify(title) {
67
67
  .slice(0, 50) || "untitled"
68
68
  }
69
69
 
70
- /** 生成条目文件名:YYYYMMDD-<slug>-<rand4>.md */
70
+ /** Generate entry filename: YYYYMMDD-<slug>-<rand4>.md */
71
71
  export function entryFilename(title, date = new Date()) {
72
72
  const ymd = date.toISOString().slice(0, 10).replaceAll("-", "")
73
73
  const rand = Math.random().toString(36).slice(2, 6)
74
74
  return `${ymd}-${slugify(title)}-${rand}.md`
75
75
  }
76
76
 
77
- // ---------------------------------------------------------------- 内部
77
+ // ---------------------------------------------------------------- internal
78
78
 
79
- /** 压成单行(frontmatter 标量用):换行折叠为空格,防注入伪字段行 */
79
+ /** Collapse to single line (for frontmatter scalars): fold newlines into spaces, prevent injecting fake field lines */
80
80
  function oneLine(v) {
81
81
  return String(v).replace(/\s*\r?\n\s*/g, " ").trim()
82
82
  }
83
83
 
84
84
  /**
85
- * 极简 YAML 子集解析:只支持 `key: value` `key: [a, b, c]`。
86
- * 我们的 frontmatter 是自己生成的,不需要完整 YAML
85
+ * Minimal YAML subset parser: only supports `key: value` and `key: [a, b, c]`.
86
+ * Our frontmatter is self-generated, no need for full YAML.
87
87
  */
88
88
  function parseFrontmatter(text) {
89
89
  const meta = {}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * mcp/helpers.mjs — MCP 共享工具函数与常量
2
+ * mcp/helpers.mjs — MCP shared utility functions and constants
3
3
  */
4
4
 
5
5
  export const INIT_TIMEOUT_MS = 30_000
@@ -7,10 +7,12 @@ export const CALL_TIMEOUT_MS = 120_000
7
7
  export const ENDPOINT_WAIT_MS = 5_000
8
8
 
9
9
  let nextRpcId = 0
10
+ /** Generate a unique incrementing RPC ID string */
10
11
  export function rpcId() {
11
12
  return String(++nextRpcId)
12
13
  }
13
14
 
15
+ /** Race a promise against a timeout, rejecting after ms milliseconds */
14
16
  export function withTimeout(promise, ms) {
15
17
  let timer
16
18
  const timeout = new Promise((_, reject) => {
@@ -20,10 +22,12 @@ export function withTimeout(promise, ms) {
20
22
  return Promise.race([promise.finally(() => clearTimeout(timer)), timeout])
21
23
  }
22
24
 
25
+ /** Quote a shell argument: wrap in double-quotes if it contains whitespace or quotes */
23
26
  export function quoteArg(s) {
24
27
  return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
25
28
  }
26
29
 
30
+ /** Append a Bearer token as a query parameter to a WebSocket URL */
27
31
  export function withAuthToken(wsUrl, authorization) {
28
32
  if (!authorization) return wsUrl
29
33
  const token = authorization.replace(/^Bearer\s+/i, "")
@@ -32,6 +36,7 @@ export function withAuthToken(wsUrl, authorization) {
32
36
  return u.href
33
37
  }
34
38
 
39
+ /** Sanitize a tool name: replace non-alphanumeric chars with underscores, cap at 64 chars */
35
40
  export function sanitizeToolName(name) {
36
41
  return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
37
42
  }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, withTimeout } from "./helpers.mjs"
5
5
 
6
+ /** Create an MCP HTTP+SSE transport for Streamable HTTP servers */
6
7
  export function httpTransport(baseURL, extraHeaders = {}) {
7
8
  const url = baseURL.replace(/\/+$/, "")
8
9
  let sessionId = null
@@ -82,7 +83,7 @@ export function httpTransport(baseURL, extraHeaders = {}) {
82
83
  pending.delete(msg.id)
83
84
  resolver(msg)
84
85
  }
85
- } catch { /* JSON,忽略 */ }
86
+ } catch { /* not JSON, ignore */ }
86
87
  }
87
88
  } catch (error) {
88
89
  if (!closed) {
@@ -4,6 +4,7 @@
4
4
  import { spawn } from "node:child_process"
5
5
  import { rpcId, CALL_TIMEOUT_MS, withTimeout, quoteArg } from "./helpers.mjs"
6
6
 
7
+ /** Create an MCP stdio transport over a spawned child process */
7
8
  export function stdioTransport(command, args) {
8
9
  const spawnOptions = { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env: { ...process.env } }
9
10
  const child =
@@ -39,7 +40,7 @@ export function stdioTransport(command, args) {
39
40
  pending.delete(msg.id)
40
41
  resolver(msg)
41
42
  }
42
- } catch { /* JSON 行忽略 */ }
43
+ } catch { /* non-JSON line, ignore */ }
43
44
  }
44
45
  })
45
46
 
@@ -77,7 +78,7 @@ export function stdioTransport(command, args) {
77
78
  if (closed) return
78
79
  try {
79
80
  child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
80
- } catch { /* 忽略 */ }
81
+ } catch { /* ignore */ }
81
82
  }
82
83
 
83
84
  return { send, notify, close: () => { if (!closed) child.kill() } }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { rpcId, INIT_TIMEOUT_MS, CALL_TIMEOUT_MS, withTimeout, withAuthToken } from "./helpers.mjs"
5
5
 
6
+ /** Create an MCP WebSocket transport */
6
7
  export function wsTransport(wsUrl, extraHeaders = {}) {
7
8
  const pending = new Map()
8
9
  let closed = false
@@ -36,7 +37,7 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
36
37
  pending.delete(msg.id)
37
38
  resolver(msg)
38
39
  }
39
- } catch { /* JSON,忽略 */ }
40
+ } catch { /* not JSON, ignore */ }
40
41
  })
41
42
 
42
43
  ws.addEventListener("error", (event) => {
@@ -80,7 +81,7 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
80
81
  const close = () => {
81
82
  closed = true
82
83
  failAll("Connection closed")
83
- try { ws?.close() } catch { /* 忽略 */ }
84
+ try { ws?.close() } catch { /* ignore */ }
84
85
  }
85
86
 
86
87
  return { send, notify, close, connect }
package/src/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * mcp.mjs — MCP (Model Context Protocol) client
3
- * config: { command, args?, name } { url, name, headers? } { wsUrl, name, headers? }
3
+ * config: { command, args?, name } or { url, name, headers? } or { wsUrl, name, headers? }
4
4
  */
5
5
  import { INIT_TIMEOUT_MS, withTimeout, sanitizeToolName } from "./mcp/helpers.mjs"
6
6
  import { stdioTransport } from "./mcp/transport-stdio.mjs"
@@ -46,6 +46,7 @@ async function doInitialize(transport, name) {
46
46
  return toolsResp.result?.tools ?? []
47
47
  }
48
48
 
49
+ /** Connect to an MCP server (stdio/http/ws), initialize, and return built tool wrappers */
49
50
  export async function connectMcpServer(config) {
50
51
  if (config.wsUrl) {
51
52
  const transport = wsTransport(config.wsUrl, config.headers ?? {})
@@ -64,7 +65,7 @@ export async function connectMcpServer(config) {
64
65
  try {
65
66
  await transport.openSSE()
66
67
  } catch {
67
- // 不支持 GET server(纯 Streamable HTTP POST):降级为无 SSE 模式
68
+ // Server doesn't support GET (pure Streamable HTTP POST): degrade to no-SSE mode
68
69
  }
69
70
  try {
70
71
  const mcpTools = await doInitialize(transport, config.name ?? config.url)
@@ -89,12 +90,14 @@ export async function connectMcpServer(config) {
89
90
  throw new Error(`MCP server "${config.name}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
90
91
  }
91
92
 
93
+ /** Close all MCP transport connections on an agent's tools */
92
94
  export function closeAllMcp(agent) {
93
95
  for (const t of agent.tools) {
94
96
  if (t._mcpTransport) t._mcpTransport.close()
95
97
  }
96
98
  }
97
99
 
100
+ /** Remove MCP tools belonging to a specific server from the agent's tool list */
98
101
  export function removeMcpTools(agent, serverName) {
99
102
  const keep = []
100
103
  for (const t of agent.tools) {