thincoder 0.8.1 → 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 (98) hide show
  1. package/README.md +2 -1
  2. package/bin/thincoder.cjs +4 -0
  3. package/bin/thincoder.mjs +25 -25
  4. package/package.json +2 -2
  5. package/src/agent/dispatch.mjs +17 -12
  6. package/src/agent/helpers.mjs +22 -11
  7. package/src/agent/setup.mjs +32 -10
  8. package/src/agent-tools/goal.mjs +7 -6
  9. package/src/agent-tools/plan.mjs +3 -3
  10. package/src/agent-tools/recent-changes.mjs +3 -3
  11. package/src/agent-tools/skill.mjs +6 -6
  12. package/src/agent-tools/subagent.mjs +19 -18
  13. package/src/agent-tools/task.mjs +4 -4
  14. package/src/agent-tools/verify.mjs +21 -19
  15. package/src/agent-tools.mjs +3 -3
  16. package/src/agent.mjs +58 -37
  17. package/src/cli/distill-command.mjs +2 -2
  18. package/src/cli/make-agent.mjs +9 -9
  19. package/src/cli/memory-command.mjs +1 -1
  20. package/src/cli/permission.mjs +3 -3
  21. package/src/cli/setup-wizard.mjs +15 -15
  22. package/src/config.mjs +55 -53
  23. package/src/context.mjs +59 -56
  24. package/src/distill.mjs +35 -35
  25. package/src/embedding.mjs +17 -17
  26. package/src/git/checkpoint.mjs +211 -38
  27. package/src/git/gitmem.mjs +21 -20
  28. package/src/markdown.mjs +13 -13
  29. package/src/mcp/helpers.mjs +6 -1
  30. package/src/mcp/transport-http.mjs +2 -1
  31. package/src/mcp/transport-stdio.mjs +3 -2
  32. package/src/mcp/transport-ws.mjs +3 -2
  33. package/src/mcp.mjs +5 -2
  34. package/src/memory/code-index.mjs +16 -14
  35. package/src/memory/code-sync.mjs +36 -26
  36. package/src/memory/core.mjs +42 -35
  37. package/src/memory/docs.mjs +24 -15
  38. package/src/memory/schema.mjs +28 -27
  39. package/src/memory.mjs +2 -2
  40. package/src/prompts/coder.md +1 -1
  41. package/src/prompts/discipline.md +3 -0
  42. package/src/prompts/main.md +1 -2
  43. package/src/prompts/system.md +21 -16
  44. package/src/provider/core.mjs +11 -6
  45. package/src/provider/index.mjs +2 -2
  46. package/src/provider/rate.mjs +11 -11
  47. package/src/session.mjs +73 -40
  48. package/src/skills.mjs +17 -17
  49. package/src/tools/checkpoint.md +6 -2
  50. package/src/tools/file.mjs +16 -12
  51. package/src/tools/git.mjs +83 -12
  52. package/src/tools/index.mjs +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 +49 -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 +4 -2
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-auto.mjs +1 -1
  63. package/src/tui/cmd-clear.mjs +1 -1
  64. package/src/tui/cmd-config.mjs +2 -2
  65. package/src/tui/cmd-exit.mjs +2 -2
  66. package/src/tui/cmd-extract.mjs +1 -1
  67. package/src/tui/cmd-goal.mjs +4 -4
  68. package/src/tui/cmd-help.mjs +1 -1
  69. package/src/tui/cmd-init.mjs +3 -3
  70. package/src/tui/cmd-mcp.mjs +5 -5
  71. package/src/tui/cmd-model.mjs +1 -1
  72. package/src/tui/cmd-new.mjs +1 -1
  73. package/src/tui/cmd-plan.mjs +1 -1
  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 +2 -2
  79. package/src/tui/config-helpers.mjs +6 -6
  80. package/src/tui/distill-cmd.mjs +3 -3
  81. package/src/tui/index.mjs +131 -72
  82. package/src/tui/interaction.mjs +13 -12
  83. package/src/tui/key-handler.mjs +31 -23
  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 +14 -14
  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/distill.mjs CHANGED
@@ -1,53 +1,53 @@
1
1
  /**
2
- * distill.mjs — 从会话中提取知识候选条目(双轨制的"自动轨"
3
- * 原则(已定):手动触发、LLM 出候选、人工逐条确认后入库。
4
- * 绝不做会话结束后的全自动沉淀。
2
+ * distill.mjs — extract knowledge candidates from sessions (the "automatic track" of the dual-track system)
3
+ * Principle (settled): manually triggered, LLM produces candidates, human confirms each one before writing.
4
+ * Absolutely no automatic storage at session end.
5
5
  */
6
6
 
7
7
  import { chat } from "./provider/index.mjs"
8
8
  import { put, putMarkdown } from "./memory.mjs"
9
9
  import { commitAndPush } from "./git/gitmem.mjs"
10
10
 
11
- const DISTILL_PROMPT = `你是知识提取器。阅读下面的 agent 工作会话记录,提取值得跨会话长期记住的知识。
11
+ const DISTILL_PROMPT = `You are a knowledge extractor. Read the following agent work session log and extract knowledge worth remembering across sessions.
12
12
 
13
- 输出一个 JSON 数组(不要输出任何其他内容):
13
+ Output a JSON array (nothing else):
14
14
  [
15
15
  {
16
16
  "type": "rule | knowledge | decision | pattern",
17
- "title": "简短标题",
18
- "content": "完整内容,自包含,脱离会话上下文也能看懂",
17
+ "title": "Short title",
18
+ "content": "Full content, self-contained — understandable without session context",
19
19
  "tags": ["tag1", "tag2"],
20
20
  "scope": "personal | project"
21
21
  }
22
22
  ]
23
23
 
24
- 提取标准:
25
- - knowledge:项目的事实性知识(架构、部署、约定俗成的做法)
26
- - decision:会话中做出的技术决策及理由
27
- - pattern:调试经验、问题解法、可复用的工作模式
28
- - rule:编码规范类(谨慎!规范通常应由人手动撰写,只有会话中明确确立的才提取)
29
- - scope 判断:专属于当前项目的用 project;通用的或个人偏好用 personal
24
+ Extraction criteria:
25
+ - knowledge: factual project knowledge (architecture, deployment, conventions)
26
+ - decision: technical decisions made in the session and their rationale
27
+ - pattern: debugging experiences, solutions, reusable workflows
28
+ - rule: coding standards (caution! rules are usually best written manually; only extract rules explicitly established in the session)
29
+ - scope: use "project" for project-specific knowledge; use "personal" for general or personal preferences
30
30
 
31
- 不要提取:
32
- - 一次性的任务细节("今天改了某个文件的某行"
33
- - 会话中提到的临时状态(当前的 bug、进行中的工作)
34
- - 客套话和显而易见的事实
31
+ Do NOT extract:
32
+ - one-off task details ("changed line X in file Y today")
33
+ - transient state mentioned in the session (current bugs, work-in-progress)
34
+ - pleasantries and obvious facts
35
35
 
36
- 如果没有值得提取的内容,输出 []
37
- 如果会话太长,优先提取最后出现的、仍在生效的结论。
36
+ If nothing is worth extracting, output []
37
+ If the session is long, prioritize conclusions that appeared last and are still in effect.
38
38
 
39
- 会话记录:
39
+ Session log:
40
40
  `
41
41
 
42
42
  /**
43
- * 从会话记录提取候选条目。transcript: 纯文本会话记录。
44
- * 返回 [{ type, title, content, tags, scope }],解析失败返回 []
43
+ * Extract candidates from a session transcript. transcript: plain-text session record.
44
+ * Returns [{ type, title, content, tags, scope }], or [] on parse failure.
45
45
  */
46
46
  export async function extractCandidates(provider, transcript) {
47
47
  const res = await chat(provider, {
48
48
  messages: [{ role: "user", content: DISTILL_PROMPT + transcript }],
49
49
  })
50
- // 非贪婪匹配第一个 JSON 数组(贪婪 [\s\S]* 会跨多个数组把中间文本也吃进去)
50
+ // Non-greedy match first JSON array (greedy [\s\S]* would eat across multiple arrays including interstitial text)
51
51
  const match = res.content.match(/\[[\s\S]*?\]/)
52
52
  if (!match) return []
53
53
  try {
@@ -60,37 +60,37 @@ export async function extractCandidates(provider, transcript) {
60
60
  }
61
61
 
62
62
  /**
63
- * agent OpenAI 格式 history 转成可读的会话记录文本。
63
+ * Convert agent's OpenAI-format history to readable session transcript text.
64
64
  */
65
65
  export function historyToTranscript(history, { maxChars = 30_000 } = {}) {
66
66
  const lines = []
67
67
  for (const m of history) {
68
68
  if (m.role === "tool") {
69
- lines.push(`[工具结果] ${(m.content ?? "").slice(0, 500)}`)
69
+ lines.push(`[tool result] ${(m.content ?? "").slice(0, 500)}`)
70
70
  } else if (m.tool_calls?.length) {
71
71
  const calls = m.tool_calls.map((tc) => `${tc.function?.name ?? "?"}(${tc.function?.arguments?.slice(0, 200) ?? ""})`).join(", ")
72
- lines.push(`[assistant] ${m.content ?? ""}\n[调用工具] ${calls}`)
72
+ lines.push(`[assistant] ${m.content ?? ""}\n[called tools] ${calls}`)
73
73
  } else {
74
74
  lines.push(`[${m.role}] ${m.content ?? ""}`)
75
75
  }
76
76
  }
77
77
  const text = lines.join("\n\n")
78
- // 超长时保留头尾(最早的需求 + 最新的结论最重要)
78
+ // Overlong: keep head and tail (earliest requirements + latest conclusions are most important)
79
79
  if (text.length <= maxChars) return text
80
80
  const half = Math.floor(maxChars / 2)
81
- return text.slice(0, half) + "\n\n...[中间部分省略]...\n\n" + text.slice(-half)
81
+ return text.slice(0, half) + "\n\n...[... middle portion omitted ...]...\n\n" + text.slice(-half)
82
82
  }
83
83
 
84
84
  /**
85
- * 把确认的候选条目写入指定层。
85
+ * Write confirmed candidates to the specified layer.
86
86
  * opts: { projectDir, team: { dir } | null, author }
87
- * scope=team 需要 opts.teamproject 需要 opts.projectDir
88
- * 返回写入结果描述。
87
+ * scope=team requires opts.team; project requires opts.projectDir.
88
+ * Returns write result description.
89
89
  */
90
90
  export async function saveCandidate(memory, candidate, opts = {}) {
91
91
  const scope = candidate.scope ?? "personal"
92
- // tags 来自 LLM 输出(不可信):非数组时先 String 化再按逗号/空白切分——
93
- // 直接对非字符串调 .split 会崩,模型也常给 "a, b" 这种逗号串
92
+ // tags come from LLM output (untrusted): if not an array, stringify then split by comma/whitespace —
93
+ // calling .split on a non-string would crash, and models often produce "a, b" comma strings
94
94
  const tags = Array.isArray(candidate.tags)
95
95
  ? candidate.tags.map((t) => String(t)).filter(Boolean)
96
96
  : String(candidate.tags ?? "").split(/[\s,]+/).filter(Boolean)
@@ -100,7 +100,7 @@ export async function saveCandidate(memory, candidate, opts = {}) {
100
100
  return `personal#${id}`
101
101
  }
102
102
  if (scope === "project") {
103
- if (!opts.projectDir) throw new Error("project scope unavailable")
103
+ if (!opts.projectDir) throw new Error("project scope unavailable — no project directory configured (set memory.projectDir in ~/.thincoder/config.json)")
104
104
  const filename = await putMarkdown(memory, {
105
105
  layer: "project", dir: opts.projectDir,
106
106
  type: candidate.type, title: candidate.title, content: candidate.content,
@@ -109,7 +109,7 @@ export async function saveCandidate(memory, candidate, opts = {}) {
109
109
  return `project:${filename}`
110
110
  }
111
111
  if (scope === "team") {
112
- if (!opts.team?.dir) throw new Error("team scope not configured")
112
+ if (!opts.team?.dir) throw new Error("team scope not configured — configure memory.team in ~/.thincoder/config.json")
113
113
  const filename = await putMarkdown(memory, {
114
114
  layer: "team", dir: opts.team.dir,
115
115
  type: candidate.type, title: candidate.title, content: candidate.content,
package/src/embedding.mjs CHANGED
@@ -1,19 +1,19 @@
1
1
  /**
2
- * embedding.mjs — 向量嵌入
3
- * OpenAI 兼容 /v1/embeddingsSiliconFlow bge-m3 / Ollama / OpenAI 均可),
4
- * 复用 provider.mjs fetch + 重试模式,零依赖。
5
- * 向量在入库前归一化,之后点积即余弦相似度。
2
+ * embedding.mjs — vector embeddings
3
+ * OpenAI-compatible /v1/embeddings (SiliconFlow bge-m3 / Ollama / OpenAI all supported),
4
+ * reuses provider.mjs fetch + retry pattern, zero dependencies.
5
+ * Vectors are normalized before storage; dot product then equals cosine similarity.
6
6
  */
7
7
 
8
8
  import { RETRYABLE_STATUS } from "./provider/index.mjs"
9
9
  const MAX_RETRIES = 3
10
- const BATCH_SIZE = 32 // 单次请求的文本数上限(SiliconFlow 限制内)
10
+ const BATCH_SIZE = 32 // max texts per request (within SiliconFlow limits)
11
11
 
12
- /** 创建 embedderconfig: { baseURL, apiKey, model } */
12
+ /** Create an embedder. config: { baseURL, apiKey, model } */
13
13
  export function createEmbedder(config) {
14
- if (!config?.baseURL) throw new Error("embedding config: baseURL is required")
15
- if (!config?.apiKey) throw new Error("embedding config: apiKey is required (config file or SILICONFLOW_API_KEY env)")
16
- if (!config?.model) throw new Error("embedding config: model is required")
14
+ if (!config?.baseURL) throw new Error("embedding config: baseURL is required — configure embedding.baseURL in ~/.thincoder/config.json")
15
+ if (!config?.apiKey) throw new Error("embedding config: apiKey is required set SILICONFLOW_API_KEY env or configure embedding.apiKey in ~/.thincoder/config.json")
16
+ if (!config?.model) throw new Error("embedding config: model is required — configure embedding.model in ~/.thincoder/config.json")
17
17
  return {
18
18
  baseURL: config.baseURL.replace(/\/+$/, ""),
19
19
  apiKey: config.apiKey,
@@ -22,8 +22,8 @@ export function createEmbedder(config) {
22
22
  }
23
23
 
24
24
  /**
25
- * 批量嵌入。texts: string[] → Float32Array[](已归一化)
26
- * 自动分批,失败重试(指数退避)。
25
+ * Batch embedding. texts: string[] → Float32Array[] (normalized)
26
+ * Auto-batches, retries on failure (exponential backoff).
27
27
  */
28
28
  export async function embed(embedder, texts, { signal } = {}) {
29
29
  if (texts.length === 0) return []
@@ -31,11 +31,11 @@ export async function embed(embedder, texts, { signal } = {}) {
31
31
  for (let i = 0; i < texts.length; i += BATCH_SIZE) {
32
32
  const batch = texts.slice(i, i + BATCH_SIZE)
33
33
  const data = await requestWithRetry(embedder, batch, signal)
34
- // 数量不符直接报错——静默接受会让向量与文本错位,污染整个索引
34
+ // Mismatched count is a hard error — silently accepting would misalign vectors with texts, poisoning the entire index
35
35
  if (!Array.isArray(data.data) || data.data.length !== batch.length) {
36
36
  throw new Error(`Embedding API returned ${data.data?.length ?? 0} vectors for ${batch.length} inputs`)
37
37
  }
38
- // 规范上 data[] 顺序与输入一致,但以 index 字段为准排序(有的话),不赌服务端实现
38
+ // Spec says data[] order matches input, but sort by index field if present — don't bet on server implementation
39
39
  const items = data.data.every((d) => typeof d.index === "number")
40
40
  ? [...data.data].sort((a, b) => a.index - b.index)
41
41
  : data.data
@@ -46,7 +46,7 @@ export async function embed(embedder, texts, { signal } = {}) {
46
46
  return vectors
47
47
  }
48
48
 
49
- /** 余弦相似度(输入均已归一化,点积即余弦) */
49
+ /** Cosine similarity (inputs are normalized, dot product equals cosine) */
50
50
  export function cosine(a, b) {
51
51
  if (a.length !== b.length) return 0
52
52
  let sum = 0
@@ -55,20 +55,20 @@ export function cosine(a, b) {
55
55
  return sum
56
56
  }
57
57
 
58
- /** Float32Array → 可存 sqlite BLOB Buffer */
58
+ /** Float32Array → Buffer suitable for sqlite BLOB storage */
59
59
  export function toBlob(vec) {
60
60
  return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)
61
61
  }
62
62
 
63
63
  /** sqlite BLOB → Float32Array */
64
64
  export function fromBlob(buf) {
65
- // BLOB 可能来自 Buffer 池,byteOffset 不保证 4 对齐,直接建视图会 RangeError——先复制对齐
65
+ // BLOB may come from Buffer pool where byteOffset isn't 4-aligned; creating a view directly would RangeError — copy to align first
66
66
  if (buf.byteOffset % 4 !== 0) buf = new Uint8Array(buf)
67
67
  if (buf.byteLength % 4 !== 0) return new Float32Array(0)
68
68
  return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4)
69
69
  }
70
70
 
71
- // ---------------------------------------------------------------- 内部
71
+ // ---------------------------------------------------------------- internal
72
72
 
73
73
  async function requestWithRetry(embedder, input, signal) {
74
74
  let lastError
@@ -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