thincoder 0.1.0 → 0.2.0

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.
package/README.md CHANGED
@@ -19,7 +19,7 @@ ThinCoder 的 "Thin" 不是"功能单薄",而是**思维锐利、直击要害*
19
19
  - **子 agent 并发**:`subagent` 工具派发独立子任务(隔离上下文,只带回报告),一批多个走并行通道;普通模式子 agent 只读,AUTO 模式全放行
20
20
  - **AUTO 模式**:`/auto` 或 `chat --auto` 完全授权,长任务免确认,状态栏黄色 AUTO 标识
21
21
  - **任务跟踪**:`task` 工具让 agent 拆解多步任务并跟踪进度(pending/in_progress/done),TUI 状态栏实时显示 ▶n/m
22
- - **流式 TUI**:裸 ANSI 实现(无 UI 库),对话流 / 流式输出 / 权限确认 / 翻页 / 输入历史 / 斜杠命令
22
+ - **流式 TUI**:裸 ANSI 实现(无 UI 库),对话流 / 流式输出 / 权限确认(y/n/a,a=批准并转 AUTO)/ 翻页 / 输入历史 / 斜杠命令 Tab 补全
23
23
  - **上下文压缩**:对话超阈值时自动摘要(保留最早 2 条 + 最近 10 条,中间 LLM 摘要)
24
24
  - **三层记忆 + 团队共享**(见下)
25
25
  - **LLM 调用**:原生 `fetch` 直连 OpenAI 兼容协议(OpenAI / DeepSeek / Moonshot / Ollama),流式 SSE,指数退避重试,支持 `reasoning_content` 思考流
@@ -49,38 +49,40 @@ ThinCoder 的 "Thin" 不是"功能单薄",而是**思维锐利、直击要害*
49
49
  ## 快速开始
50
50
 
51
51
  ```bash
52
- # 配置(首次)
53
- mkdir -p ~/.thincoder
54
- cat > ~/.thincoder/config.json <<'EOF'
55
- {
56
- "provider": {
57
- "baseURL": "https://api.deepseek.com/v1",
58
- "apiKey": "sk-...",
59
- "model": "deepseek-chat"
60
- }
61
- }
62
- EOF
52
+ # 安装
53
+ npm install -g thincoder
63
54
 
64
55
  # 启动 TUI(默认命令)
65
- node bin/thincoder.mjs
56
+ thincoder
57
+ ```
58
+
59
+ 首次启动会自动进入初始配置向导:方向键选提供商(内置预设或自定义端点)→ 输入 API key → 可选填 embedding key(SiliconFlow,开启记忆向量检索,可跳过)→ 方向键选模型,全程不用手编配置文件。之后随时可用 `/provider`、`/model`、`/config embedkey` 调整。`chat`/`distill` 在终端下没配 key 时也会就地问答式配置(管道/CI 环境则报错退出并提示)。
60
+
61
+ 也可以直接手写配置 `~/.thincoder/config.json`(见下文"配置"),然后:
66
62
 
63
+ ```bash
67
64
  # 一次性问答(管道友好)
68
- node bin/thincoder.mjs chat "读一下 package.json 并总结"
65
+ thincoder chat "读一下 package.json 并总结"
69
66
 
70
67
  # 记忆管理
71
- node bin/thincoder.mjs memory put --type=rule --title="代码规范" --content="不加分号"
72
- node bin/thincoder.mjs memory search "代码规范"
73
- node bin/thincoder.mjs memory list
74
- node bin/thincoder.mjs memory remove 1
68
+ thincoder memory put --type=rule --title="代码规范" --content="不加分号"
69
+ thincoder memory search "代码规范"
70
+ thincoder memory list
71
+ thincoder memory remove 1
75
72
 
76
73
  # 团队记忆(可选,配置 memory.team 后可用)
77
- node bin/thincoder.mjs sync # 拉取团队仓库并重建索引
74
+ thincoder sync # 拉取团队仓库并重建索引
78
75
 
79
76
  # 从会话记录提取知识(逐条确认后入库)
80
- node bin/thincoder.mjs distill session.txt
77
+ thincoder distill session.txt
78
+
79
+ # 升级
80
+ thincoder upgrade
81
81
  ```
82
82
 
83
- TUI 内斜杠命令:`/help`、`/model`(查看/切换模型)、`/config`(查看配置)、`/distill`(从当前会话提取知识)、`/clear`、`/exit`。输入 `/` 时状态栏实时提示匹配命令。
83
+ 从源码运行:把上面的 `thincoder` 换成 `node bin/thincoder.mjs`。
84
+
85
+ TUI 内斜杠命令:`/help`、`/model`(方向键选择全部 provider 的全部模型;`/model <名称>` 直接切换)、`/provider`(增/删 provider、配 key,支持自定义端点)、`/think`(思维模式开关与推理强度)、`/config`(查看配置、`/config key` 配当前 key)、`/distill`(从当前会话提取知识)、`/clear`、`/exit`。输入 `/` 时状态栏实时提示匹配命令。
84
86
 
85
87
  环境变量:`THINCODER_API_KEY`(或 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`)、`THINCODER_BASE_URL`、`THINCODER_MODEL`、`SILICONFLOW_API_KEY`。
86
88
 
@@ -90,11 +92,15 @@ TUI 内斜杠命令:`/help`、`/model`(查看/切换模型)、`/config`(
90
92
 
91
93
  ```jsonc
92
94
  {
93
- "provider": {
94
- "baseURL": "https://api.deepseek.com/v1", // 任意 OpenAI 兼容端点
95
- "apiKey": "sk-...", // 或留空走环境变量
96
- "model": "deepseek-chat"
97
- },
95
+ "providers": [ // 可配多个,/model <名称> 切换
96
+ {
97
+ "name": "deepseek",
98
+ "baseURL": "https://api.deepseek.com/v1", // 任意 OpenAI 兼容端点
99
+ "apiKey": "sk-...", // 或留空走环境变量
100
+ "model": "deepseek-chat"
101
+ }
102
+ ],
103
+ "activeProvider": "deepseek", // 当前激活的 provider 名
98
104
  "embedding": { // 可选:不配则纯 FTS 检索
99
105
  "baseURL": "https://api.siliconflow.cn/v1",
100
106
  "apiKey": "sk-...", // 或 SILICONFLOW_API_KEY
package/bin/thincoder.mjs CHANGED
@@ -5,21 +5,31 @@
5
5
  * thincoder 启动 TUI
6
6
  * thincoder chat "..." 一次性 agent 问答(可调用工具,流式输出)
7
7
  * thincoder memory <sub> 记忆管理:list / search / put / remove
8
+ * thincoder upgrade 从 npm 升级到最新版
8
9
  * thincoder --help 显示帮助
9
10
  */
10
11
 
11
- import { readFileSync } from "node:fs"
12
+ import { existsSync, readFileSync } from "node:fs"
12
13
  import { createInterface } from "node:readline"
13
14
  import { execSync } from "node:child_process"
14
15
  import { join } from "node:path"
15
16
  import { createAgent, runAgent } from "../src/agent.mjs"
16
- import { loadConfig, configDir } from "../src/config.mjs"
17
+ import { loadConfig, saveConfig, configDir, configPath, PROVIDER_PRESETS } from "../src/config.mjs"
17
18
  import { createMemory, memoryTools, put, remove, search, list, syncDir } from "../src/memory.mjs"
18
- import { createProvider } from "../src/provider.mjs"
19
19
  import { builtinTools } from "../src/tools.mjs"
20
20
 
21
21
  const [command, ...args] = process.argv.slice(2)
22
22
 
23
+ // 顶层兜底:任何未捕获错误打印一行消息干净退出,不糊用户一脸 stack
24
+ process.on("uncaughtException", (error) => {
25
+ console.error(`[error] ${error.message}`)
26
+ exitSoon(1)
27
+ })
28
+ process.on("unhandledRejection", (error) => {
29
+ console.error(`[error] ${error?.message ?? error}`)
30
+ exitSoon(1)
31
+ })
32
+
23
33
  const USAGE = `thincoder - thin coding agent
24
34
 
25
35
  Usage:
@@ -36,14 +46,25 @@ Usage:
36
46
  transcript file; confirm each before saving
37
47
  thincoder upgrade Update to the latest version from npm
38
48
 
39
- Config: ~/.thincoder/config.json (provider.baseURL / provider.apiKey / provider.model)
40
- Env: THINCODER_API_KEY, THINCODER_BASE_URL, THINCODER_MODEL
49
+ Config: ~/.thincoder/config.json (providers[] + activeProvider;TUI 内用 /provider、/model 管理)
50
+ Env: THINCODER_API_KEY, THINCODER_BASE_URL, THINCODER_MODEL, THINCODER_ACTIVE_PROVIDER
41
51
  `
42
52
 
53
+ /** 缺 key 时的统一提示 */
54
+ function noKeyMessage() {
55
+ return `还没有配置 API key。运行 thincoder 进入 TUI,用 /provider add 和 /provider key 配置;或直接编辑 ${configPath}`
56
+ }
57
+
58
+ /** 延迟退出:fetch 后立刻 process.exit 在 Windows/Node 24 会触发 libuv 断言,让一句柄关完再走 */
59
+ function exitSoon(code) {
60
+ setTimeout(() => process.exit(code), 100)
61
+ }
62
+
43
63
  /** 组装一个带记忆的 agent(同步各层索引后返回) */
44
64
  async function makeAgent() {
45
65
  const config = loadConfig()
46
- const provider = createProvider(config.provider)
66
+ const provider = config.provider
67
+ const providers = config.providersList
47
68
  const memory = createMemory({ dbPath: config.memory.dbPath })
48
69
  // 向量检索:配了 embedding 就启用(惰性生成向量,首次搜索时补算)
49
70
  if (config.embedding?.apiKey) {
@@ -63,13 +84,16 @@ async function makeAgent() {
63
84
  await ensureClone(team)
64
85
  await syncDir(memory, { layer: "team", dir: team.dir })
65
86
  }
66
- return createAgent({
87
+ const agent = createAgent({
67
88
  provider,
68
89
  tools: [...builtinTools, ...memoryTools(memory, { cwd, projectDir: config.memory.projectDir, author: gitAuthor(), team })],
69
90
  config,
70
91
  cwd,
71
92
  memory,
72
93
  })
94
+ agent.providers = providers
95
+ agent.activeProvider = config.activeProvider
96
+ return agent
73
97
  }
74
98
 
75
99
  /** 读取 team 配置并补全默认目录;未配置返回 null */
@@ -95,10 +119,30 @@ switch (command) {
95
119
  const prompt = args.filter((a) => a !== "--auto").join(" ").trim()
96
120
  if (!prompt) {
97
121
  console.error('Usage: thincoder chat [--auto] "<prompt>"')
98
- process.exit(1)
122
+ exitSoon(1)
99
123
  }
100
124
 
101
125
  const agent = await makeAgent()
126
+ if (!agent.provider.apiKey) {
127
+ if (!process.stdin.isTTY) {
128
+ console.error(noKeyMessage())
129
+ exitSoon(1)
130
+ break
131
+ }
132
+ const p = await setupWizard()
133
+ if (!p) {
134
+ exitSoon(1)
135
+ break
136
+ }
137
+ agent.provider = p
138
+ agent.activeProvider = p.name
139
+ // 向导可能配了 embedding key:挂上向量检索
140
+ const fresh = loadConfig()
141
+ if (fresh.embedding?.apiKey && agent.memory && !agent.memory.embedder) {
142
+ const { createEmbedder } = await import("../src/embedding.mjs")
143
+ agent.memory.embedder = createEmbedder(fresh.embedding)
144
+ }
145
+ }
102
146
  if (auto) agent.autoApprove = true
103
147
  try {
104
148
  await runAgent(agent, prompt, {
@@ -116,7 +160,7 @@ switch (command) {
116
160
  process.stdout.write("\n")
117
161
  } catch (error) {
118
162
  console.error(`\n[error] ${error.message}`)
119
- process.exit(1)
163
+ exitSoon(1)
120
164
  }
121
165
  break
122
166
  }
@@ -141,7 +185,7 @@ switch (command) {
141
185
  if (!team) {
142
186
  console.error("Team memory not configured. Set memory.team in ~/.thincoder/config.json:")
143
187
  console.error(' "team": { "name": "myteam", "repo": "git@github.com:org/team-memory.git" }')
144
- process.exit(1)
188
+ exitSoon(1)
145
189
  }
146
190
  const memory = createMemory({ dbPath: config.memory.dbPath })
147
191
  const { ensureClone, pullTeam } = await import("../src/gitmem.mjs")
@@ -153,7 +197,7 @@ switch (command) {
153
197
  console.log(`Synced. Index: +${stats.added} ~${stats.updated} -${stats.removed}`)
154
198
  } catch (error) {
155
199
  console.error(`[error] ${error.message}`)
156
- process.exit(1)
200
+ exitSoon(1)
157
201
  }
158
202
  break
159
203
  }
@@ -169,19 +213,38 @@ switch (command) {
169
213
  const file = positional[0]
170
214
  if (!file) {
171
215
  console.error("Usage: thincoder distill <transcript-file> [--yes] [--scope=personal|project|team]")
172
- process.exit(1)
216
+ exitSoon(1)
173
217
  }
174
218
  const { readFile } = await import("node:fs/promises")
175
219
  const transcript = await readFile(file, "utf8")
176
220
 
177
221
  const config = loadConfig()
178
- const provider = createProvider(config.provider)
222
+ let provider = config.provider
223
+ if (!provider.apiKey) {
224
+ if (!process.stdin.isTTY) {
225
+ console.error(noKeyMessage())
226
+ exitSoon(1)
227
+ break
228
+ }
229
+ provider = await setupWizard()
230
+ if (!provider) {
231
+ exitSoon(1)
232
+ break
233
+ }
234
+ }
179
235
  const memory = createMemory({ dbPath: config.memory.dbPath })
180
236
  const team = teamConfig(config)
181
237
  const { extractCandidates, saveCandidate } = await import("../src/distill.mjs")
182
238
 
183
239
  console.error("[distill] extracting candidates...")
184
- const candidates = await extractCandidates(provider, transcript)
240
+ let candidates
241
+ try {
242
+ candidates = await extractCandidates(provider, transcript)
243
+ } catch (error) {
244
+ console.error(`[distill] ${error.message}`)
245
+ exitSoon(1)
246
+ break
247
+ }
185
248
  if (candidates.length === 0) {
186
249
  console.log("No distillable knowledge found in this session.")
187
250
  break
@@ -257,7 +320,7 @@ switch (command) {
257
320
  })
258
321
  } catch (error) {
259
322
  console.error(`[error] ${error.message}`)
260
- process.exit(1)
323
+ exitSoon(1)
261
324
  }
262
325
  break
263
326
  }
@@ -270,7 +333,7 @@ switch (command) {
270
333
  remote = execSync("npm view thincoder version", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim()
271
334
  } catch {
272
335
  console.error("[upgrade] 无法查询 npm registry,请确认网络和 npm 已安装")
273
- process.exit(1)
336
+ exitSoon(1)
274
337
  }
275
338
  if (remote === local) {
276
339
  console.log(`ThinCoder ${local} 已是最新。`)
@@ -291,7 +354,7 @@ switch (command) {
291
354
  default: {
292
355
  console.error(`Unknown command: ${command}\n`)
293
356
  process.stdout.write(USAGE)
294
- process.exit(1)
357
+ exitSoon(1)
295
358
  }
296
359
  }
297
360
 
@@ -318,7 +381,7 @@ async function memoryCommand(memory, args) {
318
381
  const query = positional.join(" ")
319
382
  if (!query) {
320
383
  console.error("Usage: thincoder memory search <query>")
321
- process.exit(1)
384
+ exitSoon(1)
322
385
  }
323
386
  printEntries(await search(memory, query, { limit: 10 }))
324
387
  break
@@ -326,7 +389,7 @@ async function memoryCommand(memory, args) {
326
389
  case "put": {
327
390
  if (!flags.type || !flags.title || !flags.content) {
328
391
  console.error("Usage: thincoder memory put --type=<rule|knowledge|decision|pattern> --title=<t> --content=<c> [--tags=<t>]")
329
- process.exit(1)
392
+ exitSoon(1)
330
393
  }
331
394
  const id = await put(memory, { type: flags.type, title: flags.title, content: flags.content, tags: flags.tags ?? "" })
332
395
  console.log(`Saved (id=${id})`)
@@ -336,14 +399,14 @@ async function memoryCommand(memory, args) {
336
399
  const id = Number(positional[0])
337
400
  if (!id) {
338
401
  console.error("Usage: thincoder memory remove <id>")
339
- process.exit(1)
402
+ exitSoon(1)
340
403
  }
341
404
  console.log((await remove(memory, id)) ? `Removed #${id}` : `No entry #${id}`)
342
405
  break
343
406
  }
344
407
  default:
345
408
  console.error("Usage: thincoder memory <list|search|put|remove>")
346
- process.exit(1)
409
+ exitSoon(1)
347
410
  }
348
411
  }
349
412
 
@@ -381,3 +444,70 @@ async function askPermission(name, toolArgs) {
381
444
  rl.close()
382
445
  }
383
446
  }
447
+
448
+ /** 首次使用(TTY 下的 chat/distill):问答式配置一个 provider 并落盘,返回运行时 provider;取消返回 null */
449
+ async function setupWizard() {
450
+ // 自带缓冲的提问器:rl.question 在输入被管道/快速粘贴时会丢行(问题注册前 line 已到达)
451
+ const rl = createInterface({ input: process.stdin, terminal: false })
452
+ const buffered = []
453
+ let waiter = null
454
+ rl.on("line", (line) => {
455
+ if (waiter) {
456
+ const w = waiter
457
+ waiter = null
458
+ w(line)
459
+ } else {
460
+ buffered.push(line)
461
+ }
462
+ })
463
+ const ask = (q) =>
464
+ new Promise((resolve) => {
465
+ process.stderr.write(q)
466
+ if (buffered.length) resolve(buffered.shift())
467
+ else waiter = resolve
468
+ })
469
+ try {
470
+ const presets = Object.entries(PROVIDER_PRESETS)
471
+ console.error("首次使用,先配置一个模型提供商:")
472
+ presets.forEach(([n, p], i) => console.error(` ${i + 1}. ${n.padEnd(10)} ${p.desc}`))
473
+ console.error(` ${presets.length + 1}. 自定义端点`)
474
+ const choice = Number((await ask(`选择 [1-${presets.length + 1}]: `)).trim())
475
+ let name, baseURL, model
476
+ if (choice === presets.length + 1) {
477
+ name = (await ask("名称(如 my-openai): ")).trim()
478
+ baseURL = (await ask("baseURL(如 https://api.openai.com/v1): ")).trim().replace(/\/+$/, "")
479
+ model = (await ask("模型(如 gpt-4o): ")).trim()
480
+ if (!name || !/^https?:\/\//.test(baseURL) || !model) {
481
+ console.error("输入不完整或 baseURL 不合法,已取消")
482
+ return null
483
+ }
484
+ } else if (choice >= 1 && choice <= presets.length) {
485
+ name = presets[choice - 1][0]
486
+ baseURL = presets[choice - 1][1].baseURL
487
+ model = presets[choice - 1][1].model
488
+ } else {
489
+ console.error("无效选择,已取消")
490
+ return null
491
+ }
492
+ const apiKey = (await ask(`${name} 的 API key: `)).trim()
493
+ if (!apiKey) {
494
+ console.error("key 不能为空,已取消")
495
+ return null
496
+ }
497
+ const embedKey = (await ask("可选:embedding API key(SiliconFlow,向量检索用;回车跳过): ")).trim()
498
+ const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
499
+ const providers = raw.providers?.length ? raw.providers : []
500
+ const existing = providers.find((p) => p.name === name)
501
+ if (existing) Object.assign(existing, { baseURL, model, apiKey })
502
+ else providers.push({ name, baseURL, model, apiKey })
503
+ raw.providers = providers
504
+ raw.activeProvider = name
505
+ if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
506
+ saveConfig(raw)
507
+ console.error(`配置完成:${name} / ${model}(已写入 ${configPath})`)
508
+ console.error(embedKey ? "向量检索已启用\n" : "(未配 embedding key:记忆为纯文本检索,之后在 config.json 的 embedding.apiKey 补上即可开启向量检索)\n")
509
+ return { name, baseURL, model, apiKey }
510
+ } finally {
511
+ rl.close()
512
+ }
513
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": ["ai", "agent", "coding", "cli", "llm", "deepseek", "openai", "tui"],
6
6
  "type": "module",
package/src/agent.mjs CHANGED
@@ -14,9 +14,12 @@ import { join } from "node:path"
14
14
  const DEFAULT_MAX_TURNS = 50
15
15
 
16
16
  /**
17
- * 修复断头 tool_calls:assistant 消息带了 tool_calls 但后面缺对应的 tool 结果
18
- * (进程在工具执行中途被杀、会话中断等)。为每个缺失的 tool_call_id 补一条
19
- * 中断占位消息,否则 API 会整单拒绝(invalid_request_error)。
17
+ * 修复历史里的两类毒数据(都会让 API 整单拒绝 invalid_request_error):
18
+ * 1. assistant 消息:无正文、无 tool_calls(思考流跑完正文为空、被截断时可能产生),
19
+ * 直接丢弃——"assistant must not be empty"。
20
+ * 2. 断头 tool_calls:assistant 消息带了 tool_calls 但后面缺对应的 tool 结果
21
+ * (进程在工具执行中途被杀、会话中断等)。为每个缺失的 tool_call_id 补一条
22
+ * 中断占位消息。
20
23
  * 返回修复后的新数组;无问题时返回原数组。
21
24
  */
22
25
  export function repairHistory(history) {
@@ -24,6 +27,11 @@ export function repairHistory(history) {
24
27
  let dirty = false
25
28
  for (let i = 0; i < history.length; i++) {
26
29
  const m = history[i]
30
+ // 空 assistant 消息:无正文且无 tool_calls,丢弃
31
+ if (m.role === "assistant" && !m.tool_calls?.length && !m.content) {
32
+ dirty = true
33
+ continue
34
+ }
27
35
  out.push(m)
28
36
  if (m.role !== "assistant" || !m.tool_calls?.length) continue
29
37
 
@@ -245,6 +253,10 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0 } = {})
245
253
 
246
254
  // 无工具调用:最终回答,收尾
247
255
  if (response.toolCalls.length === 0) {
256
+ // 空回复(思考流跑完正文为空、被截断等)不入历史——空 assistant 消息会毒害后续所有请求
257
+ if (!response.content) {
258
+ throw new Error("LLM 返回了空回复(可能是思考耗尽或被截断)。可 /think effort 降低推理强度后重试")
259
+ }
248
260
  agent.history.push({ role: "assistant", content: response.content })
249
261
  return response.content
250
262
  }
package/src/config.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * config.mjs — 配置加载与保存
3
- * 配置文件:~/.thincoder/config.json;API key 可用环境变量兜底。
3
+ * provider 结构:providers[] + activeProvider
4
+ * 配置文件:~/.thincoder/config.json
5
+ * API key 可用环境变量兜底(未在 providers 中配置时)。
4
6
  */
5
7
 
6
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
@@ -10,11 +12,20 @@ import { join } from "node:path"
10
12
  export const configDir = join(homedir(), ".thincoder")
11
13
  export const configPath = join(configDir, "config.json")
12
14
 
15
+ /** 内置提供商预设:/provider add <预设名>、首次启动向导共用 */
16
+ export const PROVIDER_PRESETS = {
17
+ deepseek: { baseURL: "https://api.deepseek.com/v1", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", desc: "DeepSeek" },
18
+ kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "high", desc: "Kimi / Moonshot" },
19
+ glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", desc: "智谱 GLM" },
20
+ qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen-plus", desc: "通义千问" },
21
+ }
22
+
23
+ // 默认 provider 跟 deepseek 预设保持一致(去掉 desc 展示字段)
24
+ const { desc: _presetDesc, ...deepseekPreset } = PROVIDER_PRESETS.deepseek
25
+
13
26
  const DEFAULTS = {
14
- provider: {
15
- baseURL: "https://api.deepseek.com/v1",
16
- model: "deepseek-chat",
17
- },
27
+ providers: [{ name: "deepseek", ...deepseekPreset }],
28
+ activeProvider: "deepseek",
18
29
  agent: {
19
30
  maxTurns: 50,
20
31
  compactThreshold: 100000,
@@ -39,8 +50,11 @@ const MODEL_CONTEXT_WINDOWS = [
39
50
  ["deepseek-v4-flash", 256_000],
40
51
  ["deepseek-reasoner", 64_000],
41
52
  ["deepseek-chat", 64_000],
42
- ["moonshot", 256_000],
43
- ["kimi", 256_000],
53
+ ["kimi-k3", 256_000],
54
+ ["kimi-k2", 128_000],
55
+ ["moonshot", 128_000],
56
+ ["glm-5", 1_000_000],
57
+ ["glm-4", 128_000],
44
58
  ["gpt-4.1", 1_000_000],
45
59
  ["gpt-4o", 128_000],
46
60
  ["qwen", 128_000],
@@ -63,43 +77,90 @@ export function resolveCompactThreshold(explicit, model) {
63
77
  }
64
78
 
65
79
  /**
66
- * 加载配置:文件 + 环境变量兜底(key 不明文落盘时走 env)。
67
- * 环境变量优先级:THINCODER_API_KEY > DEEPSEEK_API_KEY > OPENAI_API_KEY
80
+ * providers[] 中按 name 查找,找不到返回第一个
81
+ */
82
+ export function findProvider(providers, name) {
83
+ if (name) {
84
+ const found = providers.find((p) => p.name === name)
85
+ if (found) return found
86
+ }
87
+ return providers[0] ?? { name: "default", baseURL: "", model: "" }
88
+ }
89
+
90
+ /**
91
+ * 加载配置。
92
+ * 环境变量优先级:THINCODER_ACTIVE_PROVIDER > 配置文件 activeProvider
93
+ * THINCODER_API_KEY / THINCODER_BASE_URL / THINCODER_MODEL 覆盖当前激活 provider 的对应字段
68
94
  */
69
95
  export function loadConfig() {
70
96
  let config = {}
71
97
  if (existsSync(configPath)) {
72
- config = JSON.parse(readFileSync(configPath, "utf8"))
98
+ try {
99
+ config = JSON.parse(readFileSync(configPath, "utf8"))
100
+ } catch (error) {
101
+ throw new Error(`配置文件不是合法 JSON,请检查或删除: ${configPath}\n ${error.message}`)
102
+ }
73
103
  }
74
104
 
75
105
  const merged = {
76
106
  ...DEFAULTS,
77
107
  ...config,
78
- provider: { ...DEFAULTS.provider, ...config.provider },
108
+ providers: config.providers?.length ? config.providers : DEFAULTS.providers,
109
+ activeProvider: config.activeProvider ?? DEFAULTS.activeProvider,
79
110
  agent: { ...DEFAULTS.agent, ...config.agent },
80
111
  memory: { ...DEFAULTS.memory, ...config.memory },
81
112
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
82
113
  }
83
114
 
84
- if (!merged.provider.apiKey) {
85
- merged.provider.apiKey =
115
+ // baseURL 尾斜杠归一化(防拼出 //chat/completions)
116
+ for (const p of merged.providers) {
117
+ if (p.baseURL) p.baseURL = p.baseURL.replace(/\/+$/, "")
118
+ }
119
+
120
+ // 环境变量覆盖 activeProvider
121
+ if (process.env.THINCODER_ACTIVE_PROVIDER) {
122
+ merged.activeProvider = process.env.THINCODER_ACTIVE_PROVIDER
123
+ }
124
+
125
+ // 获取当前激活的 provider
126
+ const active = findProvider(merged.providers, merged.activeProvider)
127
+
128
+ // 构建运行时 provider 对象(供 agent.provider 使用)
129
+ const runtimeProvider = { ...active }
130
+
131
+ // 环境变量覆盖当前激活 provider 的字段
132
+ if (process.env.THINCODER_API_KEY) runtimeProvider.apiKey = process.env.THINCODER_API_KEY
133
+ if (process.env.THINCODER_BASE_URL) runtimeProvider.baseURL = process.env.THINCODER_BASE_URL
134
+ if (process.env.THINCODER_MODEL) runtimeProvider.model = process.env.THINCODER_MODEL
135
+
136
+ // apiKey 还可用通用环境变量兜底(当 providers 里没配 key 时)
137
+ if (!runtimeProvider.apiKey) {
138
+ runtimeProvider.apiKey =
86
139
  process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
87
140
  }
88
- if (process.env.THINCODER_BASE_URL) merged.provider.baseURL = process.env.THINCODER_BASE_URL
89
- if (process.env.THINCODER_MODEL) merged.provider.model = process.env.THINCODER_MODEL
141
+
142
+ // embedding apiKey
90
143
  if (!merged.embedding.apiKey) {
91
144
  merged.embedding.apiKey = process.env.SILICONFLOW_API_KEY || process.env.THINCODER_EMBEDDING_API_KEY
92
145
  }
93
146
 
94
- // 压缩阈值跟模型走:配置文件显式设置的优先,否则按模型上下文窗口自动推导
147
+ // 压缩阈值跟模型走
95
148
  const explicitThreshold = config.agent?.compactThreshold
96
- const { value, auto } = resolveCompactThreshold(explicitThreshold, merged.provider.model)
149
+ const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider.model)
97
150
  merged.agent.compactThreshold = value
98
151
  merged.agent.compactThresholdAuto = auto
99
152
 
153
+ // 回写到 merged 方便上层使用
154
+ merged.provider = runtimeProvider
155
+ merged.providersList = merged.providers
156
+
100
157
  return merged
101
158
  }
102
159
 
160
+ /**
161
+ * 保存配置。保留 providers 列表结构和 activeProvider 指针。
162
+ * providers[i].apiKey 仅在显式传入时才写入(不覆盖环境变量兜底的 key)
163
+ */
103
164
  export function saveConfig(config) {
104
165
  mkdirSync(configDir, { recursive: true })
105
166
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8")
package/src/provider.mjs CHANGED
@@ -8,7 +8,9 @@ export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504]
8
8
  const MAX_RETRIES = 3
9
9
 
10
10
  /**
11
- * 创建 provider。config: { baseURL, apiKey, model, maxTokens?, temperature? }
11
+ * 创建 provider。config: { baseURL, apiKey, model, maxTokens?, temperature?, thinking?, reasoningEffort? }
12
+ * thinking: { type: "enabled"|"disabled" } 思维模式开关
13
+ * reasoningEffort: "low"|"high"|"max" 推理强度(DeepSeek/Kimi/GLM 通用)
12
14
  */
13
15
  export function createProvider(config) {
14
16
  if (!config?.baseURL) throw new Error("provider config: baseURL is required")
@@ -20,6 +22,8 @@ export function createProvider(config) {
20
22
  model: config.model,
21
23
  maxTokens: config.maxTokens,
22
24
  temperature: config.temperature,
25
+ thinking: config.thinking,
26
+ reasoningEffort: config.reasoningEffort,
23
27
  }
24
28
  }
25
29
 
@@ -40,6 +44,8 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
40
44
  }
41
45
  if (provider.maxTokens) body.max_tokens = provider.maxTokens
42
46
  if (provider.temperature != null) body.temperature = provider.temperature
47
+ if (provider.thinking) body.thinking = provider.thinking
48
+ if (provider.reasoningEffort) body.reasoning_effort = provider.reasoningEffort
43
49
  if (tools?.length) body.tools = tools
44
50
 
45
51
  const response = await requestWithRetry(provider, body, signal)
package/src/session.mjs CHANGED
@@ -17,9 +17,9 @@ export function sessionPath(cwd) {
17
17
  /** 保存会话(同步:退出清理路径也能用) */
18
18
  export function saveSession(agent) {
19
19
  const data = {
20
- version: 1,
20
+ version: 2,
21
21
  cwd: agent.cwd,
22
- model: agent.provider.model,
22
+ activeProvider: agent.provider?.name,
23
23
  updatedAt: Date.now(),
24
24
  history: agent.history,
25
25
  tasks: agent.tasks ?? [],
@@ -35,7 +35,8 @@ export function loadSession(cwd) {
35
35
  const p = sessionPath(cwd)
36
36
  if (!existsSync(p)) return null
37
37
  const data = JSON.parse(readFileSync(p, "utf8"))
38
- if (data?.version !== 1 || !Array.isArray(data.history)) return null
38
+ if (data?.version !== 1 && data?.version !== 2) return null
39
+ if (!Array.isArray(data.history)) return null
39
40
  return data
40
41
  } catch {
41
42
  return null
@@ -46,7 +47,7 @@ export function loadSession(cwd) {
46
47
  export function clearSession(cwd) {
47
48
  try {
48
49
  const p = sessionPath(cwd)
49
- if (existsSync(p)) writeFileSync(p, JSON.stringify({ version: 1, cwd, history: [], tasks: [] }), "utf8")
50
+ if (existsSync(p)) writeFileSync(p, JSON.stringify({ version: 2, cwd, history: [], tasks: [] }), "utf8")
50
51
  } catch {
51
52
  // 清不掉就算了,下次保存会覆盖
52
53
  }