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
@@ -2,9 +2,9 @@ import { existsSync, readFileSync } from "node:fs"
2
2
  import { createInterface } from "node:readline"
3
3
  import { configPath, saveConfig, PROVIDER_PRESETS } from "../config.mjs"
4
4
 
5
- /** 首次使用(TTY 下的 chat/distill):问答式配置一个 provider 并落盘,返回运行时 provider;取消返回 null */
5
+ /** First-time setup (TTY chat / distill): ask a few questions to configure a provider, save to disk, return runtime provider. Cancel returns null. */
6
6
  export async function setupWizard() {
7
- // 自带缓冲的提问器:rl.question 在输入被管道/快速粘贴时会丢行(问题注册前 line 已到达)
7
+ // Buffered asker: rl.question loses lines when input is piped/fast-pasted (line arrives before question is registered)
8
8
  const rl = createInterface({ input: process.stdin, terminal: false })
9
9
  const buffered = []
10
10
  let waiter = null
@@ -25,17 +25,17 @@ export async function setupWizard() {
25
25
  })
26
26
  try {
27
27
  const presets = Object.entries(PROVIDER_PRESETS)
28
- console.error("首次使用,先配置一个模型提供商:")
28
+ console.error("First time using ThinCoder — let's configure a model provider:")
29
29
  presets.forEach(([n, p], i) => console.error(` ${i + 1}. ${n.padEnd(10)} ${p.desc}`))
30
- console.error(` ${presets.length + 1}. 自定义端点`)
31
- const choice = Number((await ask(`选择 [1-${presets.length + 1}]: `)).trim())
30
+ console.error(` ${presets.length + 1}. Custom endpoint`)
31
+ const choice = Number((await ask(`Pick [1-${presets.length + 1}]: `)).trim())
32
32
  let name, baseURL, model
33
33
  if (choice === presets.length + 1) {
34
- name = (await ask("名称(如 my-openai): ")).trim()
35
- baseURL = (await ask("baseURL(如 https://api.openai.com/v1): ")).trim().replace(/\/+$/, "")
36
- model = (await ask("模型(如 gpt-4o): ")).trim()
34
+ name = (await ask("Name (e.g. my-openai): ")).trim()
35
+ baseURL = (await ask("baseURL (e.g. https://api.openai.com/v1): ")).trim().replace(/\/+$/, "")
36
+ model = (await ask("Model (e.g. gpt-4o): ")).trim()
37
37
  if (!name || !/^https?:\/\//.test(baseURL) || !model) {
38
- console.error("输入不完整或 baseURL 不合法,已取消")
38
+ console.error("Incomplete input or invalid baseURL — cancelled")
39
39
  return null
40
40
  }
41
41
  } else if (choice >= 1 && choice <= presets.length) {
@@ -43,15 +43,15 @@ export async function setupWizard() {
43
43
  baseURL = presets[choice - 1][1].baseURL
44
44
  model = presets[choice - 1][1].model
45
45
  } else {
46
- console.error("无效选择,已取消")
46
+ console.error("Invalid choice — cancelled")
47
47
  return null
48
48
  }
49
- const apiKey = (await ask(`${name} 的 API key: `)).trim()
49
+ const apiKey = (await ask(`API key for ${name}: `)).trim()
50
50
  if (!apiKey) {
51
- console.error("key 不能为空,已取消")
51
+ console.error("API key cannot be empty — cancelled")
52
52
  return null
53
53
  }
54
- const embedKey = (await ask("可选:embedding API keySiliconFlow,向量检索用;回车跳过): ")).trim()
54
+ const embedKey = (await ask("Optional: embedding API key (SiliconFlow, for vector search; press Enter to skip): ")).trim()
55
55
  const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
56
56
  const providers = raw.providers?.length ? raw.providers : []
57
57
  const existing = providers.find((p) => p.name === name)
@@ -61,8 +61,8 @@ export async function setupWizard() {
61
61
  raw.activeProvider = name
62
62
  if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
63
63
  saveConfig(raw)
64
- console.error(`配置完成:${name} / ${model}(已写入 ${configPath})`)
65
- console.error(embedKey ? "向量检索已启用\n" : "(未配 embedding key:记忆为纯文本检索,之后在 config.json embedding.apiKey 补上即可开启向量检索)\n")
64
+ console.error(`Configured: ${name} / ${model} (saved to ${configPath})`)
65
+ console.error(embedKey ? "Vector search enabled\n" : "(No embedding key configured: memory search will use text-only FTS. Add embedding.apiKey to config.json to enable vector search later.)\n")
66
66
  return { name, baseURL, model, apiKey }
67
67
  } finally {
68
68
  rl.close()
package/src/config.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * config.mjs — 配置加载与保存
3
- * provider 结构:providers[] + activeProvider
4
- * 配置文件:~/.thincoder/config.json
5
- * API key 可用环境变量兜底(未在 providers 中配置时)。
2
+ * config.mjs — configuration loading and saving
3
+ * Multi-provider structure: providers[] + activeProvider
4
+ * Config file: ~/.thincoder/config.json
5
+ * API key can fall back to environment variables (when not configured in providers).
6
6
  */
7
7
 
8
8
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
@@ -12,7 +12,7 @@ import { join } from "node:path"
12
12
  export const configDir = join(homedir(), ".thincoder")
13
13
  export const configPath = join(configDir, "config.json")
14
14
 
15
- /** 内置提供商预设:/provider add <预设名>、首次启动向导共用 */
15
+ /** Built-in provider presets: shared by /provider add <preset> and first-run wizard */
16
16
  export const PROVIDER_PRESETS = {
17
17
  deepseek: { baseURL: "https://api.deepseek.com/v1", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
18
18
  kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
@@ -21,14 +21,15 @@ export const PROVIDER_PRESETS = {
21
21
  minimax: { baseURL: "https://api.minimax.chat/v1", chatPath: "/text/chatcompletion_v2", model: "MiniMax-M3", maxTokens: 131072, desc: "MiniMax" },
22
22
  }
23
23
 
24
- // 默认 provider deepseek 预设保持一致(去掉 desc 展示字段)
25
- const { desc: _presetDesc, ...deepseekPreset } = PROVIDER_PRESETS.deepseek
24
+ // Default provider matches deepseek preset (strip the desc display field)
25
+ const { desc: _, ...deepseekPreset } = PROVIDER_PRESETS.deepseek
26
26
 
27
27
  const DEFAULTS = {
28
28
  providers: [{ name: "deepseek", ...deepseekPreset }],
29
29
  activeProvider: "deepseek",
30
30
  agent: {
31
31
  maxTurns: 100,
32
+ subagentTurns: 100,
32
33
  compactThreshold: 100000,
33
34
  },
34
35
  memory: {
@@ -46,60 +47,60 @@ const DEFAULTS = {
46
47
  }
47
48
 
48
49
  /**
49
- * 已知模型的能力规格表(前缀匹配,长的在前)。
50
- * 用于压缩阈值推导、截断续写协议选择、能力感知优化。
50
+ * Known model capability spec table (prefix match, longer first).
51
+ * Used for compaction threshold derivation, continuation protocol selection, and capability-aware optimization.
51
52
  *
52
- * context: 上下文窗口(tokens
53
- * maxOutput: 最大输出 tokens(默认 context
54
- * thinking: 是否支持思考/推理模式
55
- * partialMode: Kimi/Qwen Partial Mode 截断续写(assistant 消息带 partial:true
56
- * prefixMode: DeepSeek Prefix Completion 截断续写(走 /beta 端点,带 prefix:true
57
- * multimodal: 是否多模态(支持图片/视觉输入)
58
- * cacheMode: 上下文缓存方式:"auto"=自动/"prompt"=需显式/"none"=不支持
59
- * thinkApi: 思考模式 API 类型:"type"=thinking.type 字段 / "effort"=reasoning_effort 字段
60
- * reasoningEcho: reasoning_content 跨轮回传策略:"required"=必须回传(缺失报错)/"optional"=回传可选(默认不回传)
61
- * reasoningEffortEnum: reasoning_effort 合法枚举值(未声明则不校验,原样透传)
62
- * tempRange: temperature 合法范围 [min, max](未声明则不裁剪)
53
+ * context: context window (tokens)
54
+ * maxOutput: max output tokens (defaults to context)
55
+ * thinking: whether thinking/reasoning mode is supported
56
+ * partialMode: Kimi/Qwen Partial Mode truncation continuation (assistant message with partial:true)
57
+ * prefixMode: DeepSeek Prefix Completion truncation continuation (uses /beta endpoint, with prefix:true)
58
+ * multimodal: whether multimodal (image/vision input supported)
59
+ * cacheMode: context caching mode: "auto"=automatic / "prompt"=needs explicit / "none"=unsupported
60
+ * thinkApi: thinking API type: "type"=thinking.type field / "effort"=reasoning_effort field
61
+ * reasoningEcho: reasoning_content cross-turn echo strategy: "required"=must echo (error if missing) / "optional"=echo optional (default: don't echo)
62
+ * reasoningEffortEnum: valid reasoning_effort enum values (if undeclared, no validation — passed through as-is)
63
+ * tempRange: valid temperature range [min, max] (if undeclared, no clamping)
63
64
  */
64
65
  const MODEL_SPECS = [
65
- // DeepSeek V4 系列
66
+ // DeepSeek V4 series
66
67
  ["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
67
68
  ["deepseek-v4-flash", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
68
69
  ["deepseek-reasoner", { context: 256_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
69
70
  ["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
70
- // Kimi 系列
71
+ // Kimi series
71
72
  ["kimi-k3", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "prompt", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
72
73
  ["kimi-k2", { context: 256_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none" }],
73
74
  ["moonshot", { context: 128_000, maxOutput: 32_000, thinking: false, cacheMode: "none" }],
74
- // GLM 系列
75
+ // GLM series
75
76
  ["glm-5.2", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["max", "xhigh", "high", "medium", "low", "minimal", "none"], tempRange: [0, 1] }],
76
77
  ["glm-5", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["max", "xhigh", "high", "medium", "low", "minimal", "none"], tempRange: [0, 1] }],
77
78
  ["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", tempRange: [0, 1] }],
78
- // GPT 系列
79
+ // GPT series
79
80
  ["gpt-4.1", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "prompt" }],
80
81
  ["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
81
- // Qwen 系列
82
+ // Qwen series
82
83
  ["qwen3.8-max-preview", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
83
84
  ["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
84
85
  ["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
85
86
  ["qwen-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
86
87
  ["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
87
88
  ["qwen", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
88
- // MiniMax 系列
89
+ // MiniMax series
89
90
  ["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", tempRange: [0, 2] }],
90
91
  ["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", tempRange: [0, 2] }],
91
92
  ["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto" }],
92
93
  ]
93
94
  const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
94
- // 窗口利用率上限:0.8DeepSeek 内部即全窗口;压缩本身要花一次 LLM 调用,过早压缩是纯浪费。
95
- // 20% 余量给压缩后的尾部增长与输出 token)
96
- // 1M 窗口模型按 0.8 = 80 token,历史涨到那么大才压缩会打爆 TPM 预算、
97
- // 压缩请求本身也可能 429。加 cap:不超过 maxOutput 8 倍(128K×8≈100万→实际仍偏大但合理),
98
- // 不超过 30 万(大窗口模型的合理工作上限,再大缓存命中率下降)
95
+ // Window utilization cap: 0.8 (DeepSeek internally uses full window; compaction itself costs an LLM call, premature compaction is pure waste.
96
+ // Reserve 20% headroom for post-compaction tail growth and output tokens)
97
+ // But for 1M-window models, 0.8 = 800K tokens waiting until history grows that large would blow the TPM budget,
98
+ // and the compaction request itself might 429. Add caps: no more than 8× maxOutput (128K×8≈1M → still large but reasonable),
99
+ // no more than 300K (reasonable working ceiling for large-window models; beyond that cache hit rates drop)
99
100
  const COMPACT_RATIO = 0.8
100
101
  const COMPACT_CAP_TOKENS = 300_000
101
102
 
102
- /** 按模型名前缀查规格(大小写不敏感),未知模型给保守默认 */
103
+ /** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
103
104
  export function specForModel(model) {
104
105
  const m = (model ?? "").toLowerCase()
105
106
  for (const [prefix, spec] of [...MODEL_SPECS].sort((a,b) => b[0].length - a[0].length)) {
@@ -108,24 +109,25 @@ export function specForModel(model) {
108
109
  return DEFAULT_SPEC
109
110
  }
110
111
 
112
+ /** Return the context window size for a given model name */
111
113
  export function contextWindowForModel(model) {
112
114
  return specForModel(model).context
113
115
  }
114
116
 
115
- /** 推导压缩阈值;explicit 为配置文件中显式设置的值(优先),否则按模型自动算 */
117
+ /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model */
116
118
  export function resolveCompactThreshold(explicit, model) {
117
119
  if (explicit != null) return { value: explicit, auto: false }
118
120
  const spec = specForModel(model)
119
121
  const ratioBased = Math.floor(spec.context * COMPACT_RATIO)
120
- // 大窗口模型(1M)按比例算出来太大,用 cap 限制——宁可早压缩也别让历史涨到打爆 TPM
122
+ // Large-window models (1M) produce too-large ratio-based values; cap them — better to compact early than let history grow until it blows the TPM budget
121
123
  const value = Math.min(ratioBased, COMPACT_CAP_TOKENS)
122
124
  return { value, auto: true }
123
125
  }
124
126
 
125
127
  /**
126
- * providers[] 中按 name 查找。
127
- * name 非空但找不到时抛错——activeProvider 打错字静默落到第一个 provider,会拿错 key 打错端点。
128
- * name 为空时返回第一个。
128
+ * Find provider by name in providers[].
129
+ * Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
130
+ * Returns the first provider when name is empty.
129
131
  */
130
132
  export function findProvider(providers, name) {
131
133
  if (name) {
@@ -138,9 +140,9 @@ export function findProvider(providers, name) {
138
140
  }
139
141
 
140
142
  /**
141
- * 加载配置。
142
- * 环境变量优先级:THINCODER_ACTIVE_PROVIDER > 配置文件 activeProvider
143
- * THINCODER_API_KEY / THINCODER_BASE_URL / THINCODER_MODEL 覆盖当前激活 provider 的对应字段
143
+ * Load configuration.
144
+ * Env var priority: THINCODER_ACTIVE_PROVIDER > config file activeProvider
145
+ * THINCODER_API_KEY / THINCODER_BASE_URL / THINCODER_MODEL override the current active provider's corresponding fields
144
146
  */
145
147
  export function loadConfig() {
146
148
  let config = {}
@@ -162,29 +164,29 @@ export function loadConfig() {
162
164
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
163
165
  }
164
166
 
165
- // baseURL 尾斜杠归一化(防拼出 //chat/completions
167
+ // Normalize baseURL trailing slash (prevents //chat/completions)
166
168
  for (const p of merged.providers) {
167
169
  if (p.baseURL) p.baseURL = p.baseURL.replace(/\/+$/, "")
168
170
  }
169
171
 
170
- // 环境变量覆盖 activeProvider
172
+ // Env var overrides activeProvider
171
173
  if (process.env.THINCODER_ACTIVE_PROVIDER) {
172
174
  merged.activeProvider = process.env.THINCODER_ACTIVE_PROVIDER
173
175
  }
174
176
 
175
- // 获取当前激活的 provider
177
+ // Get the currently active provider
176
178
  const active = findProvider(merged.providers, merged.activeProvider)
177
179
 
178
- // 构建运行时 provider 对象(供 agent.provider 使用)
180
+ // Build runtime provider object (for agent.provider usage)
179
181
  const runtimeProvider = { ...active }
180
182
 
181
- // 环境变量覆盖当前激活 provider 的字段
183
+ // Env vars override current active provider's fields
182
184
  if (process.env.THINCODER_API_KEY) runtimeProvider.apiKey = process.env.THINCODER_API_KEY
183
185
  if (process.env.THINCODER_BASE_URL) runtimeProvider.baseURL = process.env.THINCODER_BASE_URL
184
186
  if (process.env.THINCODER_MODEL) runtimeProvider.model = process.env.THINCODER_MODEL
185
187
 
186
- // apiKey 还可用环境变量兜底(当 providers 里没配 key 时)
187
- // 提供商专用的环境变量只对同名 provider 生效,避免 key 串到错误的端点
188
+ // apiKey also falls back to env vars (when providers doesn't include a key)
189
+ // Provider-specific env vars only apply to the matching provider name, preventing keys from leaking to wrong endpoints
188
190
  if (!runtimeProvider.apiKey?.trim()) {
189
191
  const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
190
192
  const keyVar = envMap[merged.activeProvider]
@@ -196,13 +198,13 @@ export function loadConfig() {
196
198
  merged.embedding.apiKey = process.env.SILICONFLOW_API_KEY || process.env.THINCODER_EMBEDDING_API_KEY
197
199
  }
198
200
 
199
- // 压缩阈值跟模型走
201
+ // Compaction threshold follows the model
200
202
  const explicitThreshold = config.agent?.compactThreshold
201
203
  const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider.model)
202
204
  merged.agent.compactThreshold = value
203
205
  merged.agent.compactThresholdAuto = auto
204
206
 
205
- // 回写到 merged 方便上层使用
207
+ // Write back to merged for convenient access by upper layers
206
208
  merged.provider = runtimeProvider
207
209
  merged.providersList = merged.providers
208
210
 
@@ -210,12 +212,12 @@ export function loadConfig() {
210
212
  }
211
213
 
212
214
  /**
213
- * 保存配置。保留 providers 列表结构和 activeProvider 指针。
214
- * providers[i].apiKey 仅在显式传入时才写入(不覆盖环境变量兜底的 key)
215
+ * Save configuration. Preserves providers list structure and activeProvider pointer.
216
+ * providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
215
217
  */
216
218
  export function saveConfig(config) {
217
219
  mkdirSync(configDir, { recursive: true })
218
- // 0600config.json API key,不能世界可读(POSIX;Windows chmod 尽力而为)
220
+ // 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
219
221
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
220
- try { chmodSync(configPath, 0o600) } catch { /* Windows 上可能失败,忽略 */ }
222
+ try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
221
223
  }
package/src/context.mjs CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
- * context.mjs — 上下文管理与压缩
3
- * token 无实测值时用估算兜底(ASCII/4 + ASCII/1,不引 tokenizer 依赖);
4
- * 有实测值(响应 usage.prompt_tokens)以实测为准——估算对 CJK 低估 3-4 倍,靠它触发可能永远来不及压缩。
5
- * 压缩策略:保留最早 2 + 最近 N 条,中间由 LLM 摘要成一条(学 kimi-code,简化版)。
2
+ * context.mjs — Context management and compaction
3
+ * When no measured token count is available, use estimation as fallback (ASCII/4 + non-ASCII/1, no tokenizer dependency).
4
+ * When a measured value exists (response usage.prompt_tokens), trust it — estimation underestimates CJK by 3-4x and relying solely on it may never trigger compaction.
5
+ * Compaction strategy: keep earliest 2 + latest N messages, summarize the middle into one via LLM (inspired by kimi-code, simplified).
6
6
  */
7
7
 
8
8
  import { chat } from "./provider/index.mjs"
9
9
  import { estimateText } from "./provider/rate.mjs"
10
10
 
11
- /** 粗估一组消息的 token 数(正文 + 思考链 + tool_calls 参数) */
11
+ const IMAGE_TOKEN_ESTIMATE = 256 // rough estimate for image placeholder tokens
12
+
13
+ /** Rough token count for a list of messages (body + reasoning + tool_calls params) */
12
14
  export function estimateTokens(messages) {
13
15
  let tokens = 0
14
16
  for (const m of messages) {
@@ -16,7 +18,7 @@ export function estimateTokens(messages) {
16
18
  else if (Array.isArray(m.content)) {
17
19
  for (const part of m.content) {
18
20
  if (part.type === "text") tokens += estimateText(part.text)
19
- else if (part.type === "image_url") tokens += 256 // 图片占位估算
21
+ else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
20
22
  }
21
23
  }
22
24
  if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
@@ -27,59 +29,59 @@ export function estimateTokens(messages) {
27
29
  return tokens
28
30
  }
29
31
 
30
- const KEEP_HEAD = 2 // 最早的用户意图,不能丢
31
- const KEEP_TAIL = 10 // 最近的工作现场,不能丢
32
+ const KEEP_HEAD = 2 // Keep the earliest user intent — must not lose it
33
+ const KEEP_TAIL = 10 // Keep the most recent work context — must not lose it
32
34
 
33
- const SUMMARIZE_PROMPT = `你是一个对话压缩器。把下面的 agent 工作记录压缩成一份紧凑的摘要,供后续对话作为上下文使用。
34
- 要求:
35
- - 用第一人称、现在时书写——这是""的交接笔记,延续自己的思路
36
- - 最重要的:保留设计决策与原因——架构选择、API 约定、命名规范、取舍理由。这是后续代码不能偏离的锚点
37
- - 保留:用户的原始需求、修改过的文件及原因、未解决的问题、下一步计划
38
- - 丢弃:客套话、重复内容、工具输出的细枝末节
39
- - 诚实标注不确定项:没有实际验证过的事必须写"未验证",不要把猜测写成事实
40
- - 用条目式输出,以信息完整为目标,不要硬卡字数(旧 500 字限制已作废,1M 上下文时代宁长勿缺)
35
+ const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the following agent work log into a compact summary for use as context in the ongoing conversation.
36
+ Requirements:
37
+ - Write in first person, present tense — these are "my" handover notes, continuing my own train of thought
38
+ - Most important: preserve design decisions and their reasons — architecture choices, API contracts, naming conventions, trade-off rationale. These are the anchors the subsequent code must not deviate from
39
+ - Keep: the user's original request, files modified and why, unresolved issues, next steps
40
+ - Drop: pleasantries, repetition, fine-grained tool output details
41
+ - Honestly mark uncertain items: anything not actually verified must say "unverified"; do not present guesses as facts
42
+ - Use bullet-point output; aim for information completeness, not a hard word limit (old 500-char cap is deprecated; in a 1M-context era, err on the long side)
41
43
 
42
- 工作记录:
44
+ Work log:
43
45
  `
44
46
 
45
- /** 压缩后的上下文前缀,告知 agent 发生了什么 */
47
+ /** Context prefix after compaction, informing the agent what happened */
46
48
  const COMPACTION_PREFIX =
47
49
  "[Context was automatically compacted. Below is a summary of earlier work. " +
48
50
  "Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
49
51
  "but re-verify transient state (open files, running processes) with tools before relying on them. " +
50
52
  "Design decisions made earlier may be summarized — if you recall a decision that is missing from the summary, check memory_search or re-examine the code.]\n\n"
51
53
 
52
- /** 压缩摘要调用连续失败达到此次数后,降级为确定性截断(丢信息好过任务被 400 打死) */
54
+ /** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
53
55
  export const COMPRESS_FAILURE_LIMIT = 3
54
56
 
55
- /** task 回注提醒前缀(压缩后重新注入前,先清掉历史里的旧版本,保持单一信息源) */
57
+ /** Task re-injection reminder prefix (after compaction, clear old versions from history first for a single source of truth) */
56
58
  const TASK_REINJECT_PREFIX = "[System reminder: your current task list after compaction:"
57
59
 
58
- /** 截断兜底笔记(摘要 LLM 连续失败时用,无 LLM 调用) */
60
+ /** Truncation fallback note (used when the summary LLM fails repeatedly; no LLM call) */
59
61
  const FALLBACK_NOTE =
60
62
  "[Context was truncated after repeated summarization failures. " +
61
63
  "The middle portion of earlier work was dropped WITHOUT a summary. " +
62
64
  "Re-verify any state you need with tools before relying on it.]\n\n"
63
65
 
64
66
  /**
65
- * 切分 head / middle(被摘要) / tail;没有可压缩的中间段返回 null
66
- * head 终点必须避开断头 tool_callsassistant 带了 tool_calls 时其 tool 响应必须留在 head
67
- * 否则响应被摘要成纯文本后协议校验 400(tool_calls must be followed by tool messages)。
68
- * tail 起点必须包含 tool 结果对应的 assistant——tool tailassistant middle 时,
69
- * 摘要会把 assistant 吞掉,留下 orphan tool 结果协议 400
67
+ * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
68
+ * The head boundary must avoid orphan tool_calls: when an assistant message has tool_calls, all its tool responses must stay in head,
69
+ * otherwise compressing them to plain text violates the protocol (tool_calls must be followed by tool messages).
70
+ * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
71
+ * the summary swallows it, leaving orphan tool resultsprotocol 400.
70
72
  */
71
73
  function splitHistory(history) {
72
74
  if (history.length <= KEEP_HEAD + KEEP_TAIL + 1) return null
73
75
  let headEnd = KEEP_HEAD
74
- // head 不能以断头 tool_calls 结尾:assistant 声明了 tool_calls,其 tool 结果必须全部留在 head
75
- // 并行调用时一个 assistant 后面跟多条 tool 消息——只收一条照样 400,必须一次收完
76
+ // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
77
+ // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
76
78
  if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
77
79
  while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
78
80
  }
79
81
  let tailStart = history.length - KEEP_TAIL
80
82
 
81
- // tail 区域内的 tool 消息对应的 assistant tool_calls 若在 middle 里,摘要会把 assistant 吞掉,
82
- // 剩下 orphan tool 结果协议 400。从 tail 收集 tool_call_id,往前找回所属 assistant 拉进 tail
83
+ // Tool messages in the tail region whose assistant tool_calls are in the middle: the summary would swallow the assistant,
84
+ // leaving orphan tool resultsprotocol 400. Collect tool_call_ids from the tail, find their owner assistants and pull them into tail
83
85
  const tailToolIds = new Set()
84
86
  for (let i = tailStart; i < history.length; i++) {
85
87
  if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
@@ -100,7 +102,7 @@ function splitHistory(history) {
100
102
  return { headEnd, tailStart }
101
103
  }
102
104
 
103
- /** 用一条笔记替换 middle,并回注 task/plan 状态(LLM 摘要与截断兜底共用) */
105
+ /** Replace middle with a note, then re-inject task/plan state (shared by LLM summary and truncation fallback) */
104
106
  function applyCompression(agent, headEnd, tailStart, note) {
105
107
  const head = agent.history.slice(0, headEnd)
106
108
  const tail = agent.history.slice(tailStart)
@@ -110,13 +112,13 @@ function applyCompression(agent, headEnd, tailStart, note) {
110
112
  { role: "assistant", content: "Understood. I'll continue from these notes, re-verifying anything transient." },
111
113
  ...tail,
112
114
  ]
113
- // 实测 token 基准随旧历史一起失效(prompt_tokens 对应的是压缩前的上下文),退回估算直到下次响应
115
+ // Measured token baseline is invalidated along with old history (prompt_tokens were for pre-compaction context), fall back to estimation until next response
114
116
  agent._lastPromptTokens = null
115
117
  agent._usageAtLen = null
116
118
 
117
- // 压缩后回注 task 列表(agent 需要知道自己做到哪了)。
118
- // 单一信息源:先清掉 tail 里残留的旧回注,再注入最新版本——
119
- // 不再嵌入摘要正文(会与这里重复且逐渐过时)
119
+ // After compaction, re-inject the task list (the agent needs to know what it was doing).
120
+ // Single source of truth: first remove any stale re-injections from the tail, then inject the latest version —
121
+ // no longer embedded in the summary body (would duplicate and grow stale)
120
122
  agent.history = agent.history.filter(
121
123
  (m) => !(m.role === "user" && typeof m.content === "string" && m.content.startsWith(TASK_REINJECT_PREFIX))
122
124
  )
@@ -128,11 +130,11 @@ function applyCompression(agent, headEnd, tailStart, note) {
128
130
  })
129
131
  }
130
132
 
131
- // 重置跟踪计数器(上下文已重建,从头开始计数)
133
+ // Reset tracking counters (context rebuilt, start counting from scratch)
132
134
  agent._turnsSinceTaskUpdate = 0
133
135
  agent._turnsInPlanMode = 0
134
136
 
135
- // plan mode 中压缩:重新注入 plan 模式引导
137
+ // Plan mode compaction: re-inject plan mode guidance
136
138
  if (agent.planMode) {
137
139
  agent.history.push({
138
140
  role: "user",
@@ -142,14 +144,14 @@ function applyCompression(agent, headEnd, tailStart, note) {
142
144
  }
143
145
 
144
146
  /**
145
- * 如果历史超长则压缩。返回是否发生了压缩。
146
- * 只在循环的安全点调用(history 末尾是 user tool 消息——完整交换的边界)。
147
- * 压缩后自动回注 task 列表状态。
147
+ * If history exceeds threshold, compact it. Returns whether compaction happened.
148
+ * Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
149
+ * Automatically re-injects task list state after compaction.
148
150
  */
149
151
  export async function compressIfNeeded(agent, threshold) {
150
152
  const history = agent.history
151
- // 真实基准优先:上次响应的 prompt_tokens 是完整上下文(system+tools+history)的实测值,
152
- // 之后追加的消息用估算补增量;无实测(首轮/恢复后/刚压缩完)退化为纯估算
153
+ // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
154
+ // Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
153
155
  const tokens =
154
156
  agent._lastPromptTokens != null
155
157
  ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
@@ -158,23 +160,24 @@ export async function compressIfNeeded(agent, threshold) {
158
160
 
159
161
  const split = splitHistory(history)
160
162
  if (!split) {
161
- // 历史太短(≤13 条)切不出中间段,但 token 已超阈值——典型是一条巨型消息
162
- // (大段粘贴/超大注入)。摘要无路可走时退化为确定性瘦身,保证上下文总能减下去
163
+ // History is too short (≤13 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
164
+ // (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
163
165
  return shrinkOversized(agent)
164
166
  }
165
167
 
166
168
  const middle = history.slice(split.headEnd, split.tailStart)
167
169
  const serialized = middle
168
170
  .map((m) => {
169
- const toolNote = m.tool_calls ? ` [调用了工具: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
170
- // user 消息放宽到 8000:用户粘贴的长需求被切掉会让摘要丢失原始意图;tool/assistant 2000 足够
171
+ const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
172
+ // user messages get a wider cap (8000): cutting off a long user-pasted requirement loses original intent; tool/assistant capped at 2000 is enough
171
173
  const cap = m.role === "user" ? 8000 : 2000
172
174
  const content = typeof m.content === "string" ? m.content.slice(0, cap) : ""
173
175
  return `[${m.role}]${toolNote} ${content}`
174
176
  })
175
177
  .join("\n")
176
178
 
177
- const summary = await chat(agent.provider, {
179
+ // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
180
+ const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
178
181
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
179
182
  })
180
183
 
@@ -183,8 +186,8 @@ export async function compressIfNeeded(agent, threshold) {
183
186
  }
184
187
 
185
188
  /**
186
- * 确定性截断兜底:摘要 LLM 连续失败时调用,不碰网络。
187
- * 丢掉 middle 换任务能继续跑。返回是否发生了截断。
189
+ * Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
190
+ * Drops the middle so the task can continue. Returns whether truncation happened.
188
191
  */
189
192
  export function compressFallback(agent) {
190
193
  const split = splitHistory(agent.history)
@@ -193,21 +196,21 @@ export function compressFallback(agent) {
193
196
  return true
194
197
  }
195
198
 
196
- /** 单条消息正文的硬截断长度:超过且在压缩无法切分时截断换桩(防一条巨消息卡死压缩) */
199
+ /** Hard truncation limit for a single message body: when exceeded and the splitter can't find a middle section, truncate to a stub (prevents one giant message from blocking compaction) */
197
200
  const OVERSIZE_CONTENT_LIMIT = 8_000
198
201
 
199
202
  /**
200
- * 确定性瘦身:splitHistory 切不出中间段(历史太短)但已超阈值时的最后手段,无 LLM 调用。
201
- * 把超过 OVERSIZE_CONTENT_LIMIT 的 user/tool 正文截断换桩(保留首尾);
202
- * 不动 reasoning_contentDeepSeek/Kimi 回传协议)与 tool_calls 配对结构,无协议 400 风险。
203
- * 只在 compressIfNeeded 判定超阈值后调用。返回是否有消息被截断。
203
+ * Deterministic shrinking: last resort when splitHistory can't find a middle section (history too short) but threshold is exceeded. No LLM call.
204
+ * Truncates user/tool message bodies exceeding OVERSIZE_CONTENT_LIMIT to a stub (keeps head + tail);
205
+ * does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400 risk.
206
+ * Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
204
207
  */
205
208
  export function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
206
209
  let shrunk = false
207
210
  for (const m of agent.history) {
208
211
  if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") continue
209
212
  if (m.content.length <= limit) continue
210
- // 截断保留首尾,中间换桩说明;keepHead/keepTail 按比例但不超过 limit 50%/25%
213
+ // Truncate keeping head + tail, insert stub in between; keepHead/keepTail proportional but not exceeding 50%/25% of limit
211
214
  const keepHead = Math.min(Math.floor(limit * 0.5), 4000)
212
215
  const keepTail = Math.min(Math.floor(limit * 0.25), 2000)
213
216
  m.content =
@@ -217,7 +220,7 @@ export function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
217
220
  shrunk = true
218
221
  }
219
222
  if (shrunk) {
220
- // 与压缩同理:实测 token 基准随被改动的历史失效,退回估算直到下次响应
223
+ // Same as compaction: measured token baseline is invalidated by the changed history, fall back to estimation until next response
221
224
  agent._lastPromptTokens = null
222
225
  agent._usageAtLen = null
223
226
  }