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 +32 -26
- package/bin/thincoder.mjs +151 -21
- package/package.json +1 -1
- package/src/agent.mjs +15 -3
- package/src/config.mjs +78 -17
- package/src/provider.mjs +7 -1
- package/src/session.mjs +5 -4
- package/src/tui.mjs +697 -53
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
thincoder chat "读一下 package.json 并总结"
|
|
69
66
|
|
|
70
67
|
# 记忆管理
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
74
|
+
thincoder sync # 拉取团队仓库并重建索引
|
|
78
75
|
|
|
79
76
|
# 从会话记录提取知识(逐条确认后入库)
|
|
80
|
-
|
|
77
|
+
thincoder distill session.txt
|
|
78
|
+
|
|
79
|
+
# 升级
|
|
80
|
+
thincoder upgrade
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
|
|
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
|
-
"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
["
|
|
43
|
-
["kimi",
|
|
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
|
-
*
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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,
|
|
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:
|
|
20
|
+
version: 2,
|
|
21
21
|
cwd: agent.cwd,
|
|
22
|
-
|
|
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
|
|
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:
|
|
50
|
+
if (existsSync(p)) writeFileSync(p, JSON.stringify({ version: 2, cwd, history: [], tasks: [] }), "utf8")
|
|
50
51
|
} catch {
|
|
51
52
|
// 清不掉就算了,下次保存会覆盖
|
|
52
53
|
}
|