thincoder 0.1.0 → 0.3.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 +59 -35
- package/bin/thincoder.mjs +194 -23
- package/package.json +1 -1
- package/src/SYSTEM_PROMPT.md +30 -0
- package/src/agent.mjs +471 -61
- package/src/coder-overlay.md +14 -0
- package/src/config.mjs +91 -20
- package/src/context.mjs +41 -6
- package/src/explore-overlay.md +10 -0
- package/src/mcp.mjs +359 -0
- package/src/provider.mjs +7 -1
- package/src/session.mjs +7 -4
- package/src/skills.mjs +75 -0
- package/src/tools/bash.md +13 -0
- package/src/tools/delete.md +9 -0
- package/src/tools/edit.md +12 -0
- package/src/tools/fetch.md +10 -0
- package/src/tools/git_diff.md +11 -0
- package/src/tools/git_log.md +10 -0
- package/src/tools/git_status.md +8 -0
- package/src/tools/glob.md +11 -0
- package/src/tools/grep.md +12 -0
- package/src/tools/ls.md +9 -0
- package/src/tools/question.md +10 -0
- package/src/tools/read.md +10 -0
- package/src/tools/websearch.md +10 -0
- package/src/tools/write.md +9 -0
- package/src/tools.mjs +191 -20
- package/src/tui.mjs +1181 -122
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
You are a coding subagent. The parent agent dispatched you to handle a self-contained coding task. The parent CANNOT see your context — it only sees your final report.
|
|
2
|
+
|
|
3
|
+
Guidelines:
|
|
4
|
+
- Work independently: read files, make edits, run tests
|
|
5
|
+
- Be thorough: include what you did, which files you changed, why, and any caveats
|
|
6
|
+
- If the task is ambiguous, note the ambiguity in your report; do not ask the user
|
|
7
|
+
- BEFORE finishing, verify your changes:
|
|
8
|
+
1. Run the project's tests — confirm they pass
|
|
9
|
+
2. Read every file you changed — catch leftover debug code, stale comments, or incomplete edits
|
|
10
|
+
3. Check that comments and docstrings match what the code actually does
|
|
11
|
+
- Your last message IS the report the parent sees — make it complete and self-contained
|
|
12
|
+
- List every file you changed (with paths), why you changed it, and whether tests passed
|
|
13
|
+
|
|
14
|
+
IMPORTANT — Tool permissions: when you see "permission denied by user" for a tool, it means the parent has not granted that tool. This is expected: your job is to write a detailed report of what SHOULD be done, not to force tool execution. Describe the needed changes clearly in your report so the parent agent can apply them.
|
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,13 +12,22 @@ 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
|
-
maxTurns:
|
|
30
|
+
maxTurns: 100,
|
|
20
31
|
compactThreshold: 100000,
|
|
21
32
|
},
|
|
22
33
|
memory: {
|
|
@@ -28,6 +39,9 @@ const DEFAULTS = {
|
|
|
28
39
|
baseURL: "https://api.siliconflow.cn/v1",
|
|
29
40
|
model: "BAAI/bge-m3",
|
|
30
41
|
},
|
|
42
|
+
mcp: {
|
|
43
|
+
servers: [],
|
|
44
|
+
},
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
/**
|
|
@@ -39,14 +53,19 @@ const MODEL_CONTEXT_WINDOWS = [
|
|
|
39
53
|
["deepseek-v4-flash", 256_000],
|
|
40
54
|
["deepseek-reasoner", 64_000],
|
|
41
55
|
["deepseek-chat", 64_000],
|
|
42
|
-
["
|
|
43
|
-
["kimi",
|
|
56
|
+
["kimi-k3", 256_000],
|
|
57
|
+
["kimi-k2", 128_000],
|
|
58
|
+
["moonshot", 128_000],
|
|
59
|
+
["glm-5", 1_000_000],
|
|
60
|
+
["glm-4", 128_000],
|
|
44
61
|
["gpt-4.1", 1_000_000],
|
|
45
62
|
["gpt-4o", 128_000],
|
|
46
63
|
["qwen", 128_000],
|
|
47
64
|
]
|
|
48
65
|
const DEFAULT_CONTEXT_WINDOW = 128_000
|
|
49
|
-
|
|
66
|
+
// 窗口利用率上限:0.8(DeepSeek 内部即全窗口;压缩本身要花一次 LLM 调用,过早压缩是纯浪费。
|
|
67
|
+
// 留 20% 余量给压缩后的尾部增长与输出 token)
|
|
68
|
+
const COMPACT_RATIO = 0.8
|
|
50
69
|
|
|
51
70
|
export function contextWindowForModel(model) {
|
|
52
71
|
const m = (model ?? "").toLowerCase()
|
|
@@ -63,43 +82,95 @@ export function resolveCompactThreshold(explicit, model) {
|
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/**
|
|
66
|
-
*
|
|
67
|
-
|
|
85
|
+
* 从 providers[] 中按 name 查找,找不到返回第一个
|
|
86
|
+
*/
|
|
87
|
+
export function findProvider(providers, name) {
|
|
88
|
+
if (name) {
|
|
89
|
+
const found = providers.find((p) => p.name === name)
|
|
90
|
+
if (found) return found
|
|
91
|
+
}
|
|
92
|
+
return providers[0] ?? { name: "default", baseURL: "", model: "" }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 加载配置。
|
|
97
|
+
* 环境变量优先级:THINCODER_ACTIVE_PROVIDER > 配置文件 activeProvider
|
|
98
|
+
* THINCODER_API_KEY / THINCODER_BASE_URL / THINCODER_MODEL 覆盖当前激活 provider 的对应字段
|
|
68
99
|
*/
|
|
69
100
|
export function loadConfig() {
|
|
70
101
|
let config = {}
|
|
71
102
|
if (existsSync(configPath)) {
|
|
72
|
-
|
|
103
|
+
try {
|
|
104
|
+
config = JSON.parse(readFileSync(configPath, "utf8"))
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new Error(`配置文件不是合法 JSON,请检查或删除: ${configPath}\n ${error.message}`)
|
|
107
|
+
}
|
|
73
108
|
}
|
|
74
109
|
|
|
75
110
|
const merged = {
|
|
76
111
|
...DEFAULTS,
|
|
77
112
|
...config,
|
|
78
|
-
|
|
113
|
+
providers: config.providers?.length ? config.providers : DEFAULTS.providers,
|
|
114
|
+
activeProvider: config.activeProvider ?? DEFAULTS.activeProvider,
|
|
79
115
|
agent: { ...DEFAULTS.agent, ...config.agent },
|
|
80
116
|
memory: { ...DEFAULTS.memory, ...config.memory },
|
|
81
117
|
embedding: { ...DEFAULTS.embedding, ...config.embedding },
|
|
82
118
|
}
|
|
83
119
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
120
|
+
// baseURL 尾斜杠归一化(防拼出 //chat/completions)
|
|
121
|
+
for (const p of merged.providers) {
|
|
122
|
+
if (p.baseURL) p.baseURL = p.baseURL.replace(/\/+$/, "")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 环境变量覆盖 activeProvider
|
|
126
|
+
if (process.env.THINCODER_ACTIVE_PROVIDER) {
|
|
127
|
+
merged.activeProvider = process.env.THINCODER_ACTIVE_PROVIDER
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 获取当前激活的 provider
|
|
131
|
+
const active = findProvider(merged.providers, merged.activeProvider)
|
|
132
|
+
|
|
133
|
+
// 构建运行时 provider 对象(供 agent.provider 使用)
|
|
134
|
+
const runtimeProvider = { ...active }
|
|
135
|
+
|
|
136
|
+
// 环境变量覆盖当前激活 provider 的字段
|
|
137
|
+
if (process.env.THINCODER_API_KEY) runtimeProvider.apiKey = process.env.THINCODER_API_KEY
|
|
138
|
+
if (process.env.THINCODER_BASE_URL) runtimeProvider.baseURL = process.env.THINCODER_BASE_URL
|
|
139
|
+
if (process.env.THINCODER_MODEL) runtimeProvider.model = process.env.THINCODER_MODEL
|
|
140
|
+
|
|
141
|
+
// apiKey 还可用环境变量兜底(当 providers 里没配 key 时)
|
|
142
|
+
// 提供商专用的环境变量只对同名 provider 生效,避免 key 串到错误的端点
|
|
143
|
+
if (!runtimeProvider.apiKey) {
|
|
144
|
+
const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
|
|
145
|
+
const keyVar = envMap[merged.activeProvider]
|
|
146
|
+
if (keyVar && process.env[keyVar]) runtimeProvider.apiKey = process.env[keyVar]
|
|
87
147
|
}
|
|
88
|
-
if (
|
|
89
|
-
|
|
148
|
+
if (!runtimeProvider.apiKey) {
|
|
149
|
+
runtimeProvider.apiKey = process.env.THINCODER_API_KEY
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// embedding apiKey
|
|
90
153
|
if (!merged.embedding.apiKey) {
|
|
91
154
|
merged.embedding.apiKey = process.env.SILICONFLOW_API_KEY || process.env.THINCODER_EMBEDDING_API_KEY
|
|
92
155
|
}
|
|
93
156
|
|
|
94
|
-
//
|
|
157
|
+
// 压缩阈值跟模型走
|
|
95
158
|
const explicitThreshold = config.agent?.compactThreshold
|
|
96
|
-
const { value, auto } = resolveCompactThreshold(explicitThreshold,
|
|
159
|
+
const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider.model)
|
|
97
160
|
merged.agent.compactThreshold = value
|
|
98
161
|
merged.agent.compactThresholdAuto = auto
|
|
99
162
|
|
|
163
|
+
// 回写到 merged 方便上层使用
|
|
164
|
+
merged.provider = runtimeProvider
|
|
165
|
+
merged.providersList = merged.providers
|
|
166
|
+
|
|
100
167
|
return merged
|
|
101
168
|
}
|
|
102
169
|
|
|
170
|
+
/**
|
|
171
|
+
* 保存配置。保留 providers 列表结构和 activeProvider 指针。
|
|
172
|
+
* providers[i].apiKey 仅在显式传入时才写入(不覆盖环境变量兜底的 key)
|
|
173
|
+
*/
|
|
103
174
|
export function saveConfig(config) {
|
|
104
175
|
mkdirSync(configDir, { recursive: true })
|
|
105
176
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8")
|
package/src/context.mjs
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import { chat } from "./provider.mjs"
|
|
8
8
|
|
|
9
|
-
/** 粗估一组消息的 token 数(正文 + tool_calls 参数) */
|
|
9
|
+
/** 粗估一组消息的 token 数(正文 + 思考链 + tool_calls 参数) */
|
|
10
10
|
export function estimateTokens(messages) {
|
|
11
11
|
let chars = 0
|
|
12
12
|
for (const m of messages) {
|
|
13
13
|
if (typeof m.content === "string") chars += m.content.length
|
|
14
|
+
if (typeof m.reasoning_content === "string") chars += m.reasoning_content.length
|
|
14
15
|
for (const tc of m.tool_calls ?? []) {
|
|
15
16
|
chars += (tc.function?.name?.length ?? 0) + (tc.function?.arguments?.length ?? 0)
|
|
16
17
|
}
|
|
@@ -30,9 +31,16 @@ const SUMMARIZE_PROMPT = `你是一个对话压缩器。把下面的 agent 工
|
|
|
30
31
|
工作记录:
|
|
31
32
|
`
|
|
32
33
|
|
|
34
|
+
/** 压缩后的上下文前缀,告知 agent 发生了什么 */
|
|
35
|
+
const COMPACTION_PREFIX =
|
|
36
|
+
"[Context was automatically compacted. Below is a summary of earlier work. " +
|
|
37
|
+
"Trust its conclusions — don't redo what it reports as done — but re-verify " +
|
|
38
|
+
"transient state (open files, running processes) with tools.]\n\n"
|
|
39
|
+
|
|
33
40
|
/**
|
|
34
41
|
* 如果历史超长则压缩。返回是否发生了压缩。
|
|
35
42
|
* 只在循环的安全点调用(history 末尾是 user 消息时)。
|
|
43
|
+
* 压缩后自动回注 task 列表状态。
|
|
36
44
|
*/
|
|
37
45
|
export async function compressIfNeeded(agent, threshold) {
|
|
38
46
|
const history = agent.history
|
|
@@ -63,14 +71,41 @@ export async function compressIfNeeded(agent, threshold) {
|
|
|
63
71
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
64
72
|
})
|
|
65
73
|
|
|
74
|
+
// 摘要正文内嵌 task 快照(对齐 kimi-code 的 postProcessSummary)——
|
|
75
|
+
// 否则二次压缩时 task 列表会随旧提醒消息一起被摘要器丢掉
|
|
76
|
+
let compacted = COMPACTION_PREFIX + summary.content
|
|
77
|
+
if (agent.tasks.length > 0) {
|
|
78
|
+
const taskSummary = agent.tasks.map((t) => `- [${t.status}] ${t.title}`).join("\n")
|
|
79
|
+
compacted += `\n\n## Task List\n${taskSummary}`
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
agent.history = [
|
|
67
83
|
...head,
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
content: `[前文摘要:以下是更早对话的压缩记录]\n${summary.content}`,
|
|
71
|
-
},
|
|
72
|
-
{ role: "assistant", content: "了解,我会基于这份摘要和最近的对话继续工作。" },
|
|
84
|
+
{ role: "user", content: compacted },
|
|
85
|
+
{ role: "assistant", content: "Understood. I'll continue from this summary, re-verifying anything transient." },
|
|
73
86
|
...tail,
|
|
74
87
|
]
|
|
88
|
+
|
|
89
|
+
// 压缩后回注 task 列表(agent 需要知道自己做到哪了)
|
|
90
|
+
if (agent.tasks.length > 0) {
|
|
91
|
+
const taskSummary = agent.tasks.map((t) => `- [${t.status}] ${t.title}`).join("\n")
|
|
92
|
+
agent.history.push({
|
|
93
|
+
role: "user",
|
|
94
|
+
content: `[System reminder: your current task list after compaction:\n${taskSummary}\nContinue from where you left off.]`,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 重置跟踪计数器(上下文已重建,从头开始计数)
|
|
99
|
+
agent._turnsSinceTaskUpdate = 0
|
|
100
|
+
agent._turnsInPlanMode = 0
|
|
101
|
+
|
|
102
|
+
// plan mode 中压缩:重新注入 plan 模式引导
|
|
103
|
+
if (agent.planMode) {
|
|
104
|
+
agent.history.push({
|
|
105
|
+
role: "user",
|
|
106
|
+
content: "[System reminder: plan mode is active. Explore the codebase read-only, design your solution, then call plan with action='exit' to present it for user approval.]",
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
75
110
|
return true
|
|
76
111
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
You are a codebase exploration specialist — an explore subagent. Your role is to search, read, and analyze. You do NOT have file editing tools.
|
|
2
|
+
|
|
3
|
+
Guidelines:
|
|
4
|
+
- On start, quickly orient yourself: run `git branch --show-current`, `git status --short`, and `git log -5 --oneline` to understand the repo state
|
|
5
|
+
- Use Glob for file discovery, Grep for content search, Read for known paths
|
|
6
|
+
- Run read-only shell commands (git log, git diff, ls, find) when helpful
|
|
7
|
+
- Use WebSearch or Fetch when external context is needed (docs, error messages)
|
|
8
|
+
- Issue parallel tool calls whenever possible — read multiple files at once
|
|
9
|
+
- Complete the search efficiently and report findings in a structured format
|
|
10
|
+
- If something is ambiguous, note it in your report; do not ask the user
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp.mjs — MCP (Model Context Protocol) client
|
|
3
|
+
* 零依赖:stdio transport (spawn + JSON-RPC) + HTTP transport (fetch + SSE)。
|
|
4
|
+
* config: { command, args?, name } 或 { url, name, headers? }
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from "node:child_process"
|
|
8
|
+
|
|
9
|
+
const INIT_TIMEOUT_MS = 30_000
|
|
10
|
+
const CALL_TIMEOUT_MS = 120_000
|
|
11
|
+
|
|
12
|
+
// ---- JSON-RPC helpers ----
|
|
13
|
+
|
|
14
|
+
let nextRpcId = 0
|
|
15
|
+
function rpcId() {
|
|
16
|
+
return String(++nextRpcId) // 自增:随机数可能碰撞串响应
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---- stdio transport ----
|
|
20
|
+
|
|
21
|
+
function stdioTransport(command, args) {
|
|
22
|
+
// Windows 上 npx 等命令是 .cmd,Node 不带 shell 拒 spawn(EINVAL);
|
|
23
|
+
// shell:true 又触发 DEP0190 且不转义参数——显式走 cmd.exe 并自己加引号;
|
|
24
|
+
// windowsVerbatimArguments 防止 Node 把内层引号转义成 \"(cmd 不认,会把引号当字面量传下去)
|
|
25
|
+
const spawnOptions = { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env: { ...process.env } }
|
|
26
|
+
const child =
|
|
27
|
+
process.platform === "win32" && !/\.exe$/i.test(command)
|
|
28
|
+
? spawn("cmd.exe", ["/d", "/s", "/c", [command, ...(args ?? [])].map(quoteArg).join(" ")], {
|
|
29
|
+
...spawnOptions,
|
|
30
|
+
windowsVerbatimArguments: true,
|
|
31
|
+
})
|
|
32
|
+
: spawn(command, args ?? [], spawnOptions)
|
|
33
|
+
|
|
34
|
+
const pending = new Map()
|
|
35
|
+
let buffer = ""
|
|
36
|
+
let stderrTail = "" // 诊断用:server 起不来时给用户一点线索
|
|
37
|
+
let spawnError = null
|
|
38
|
+
let closed = false
|
|
39
|
+
|
|
40
|
+
const failAll = (message) => {
|
|
41
|
+
for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
|
|
42
|
+
pending.clear()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
child.stdout.on("data", (chunk) => {
|
|
46
|
+
buffer += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
|
47
|
+
const lines = buffer.split("\n")
|
|
48
|
+
buffer = lines.pop() ?? ""
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
if (!line.trim()) continue
|
|
51
|
+
try {
|
|
52
|
+
const msg = JSON.parse(line)
|
|
53
|
+
const resolver = pending.get(msg.id)
|
|
54
|
+
if (resolver) {
|
|
55
|
+
pending.delete(msg.id)
|
|
56
|
+
resolver(msg)
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// 非 JSON 行忽略
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
child.stderr.on("data", (chunk) => {
|
|
65
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-2000)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// spawn 失败(命令不存在/EINVAL):没有这个监听,error 事件会崩掉整个进程
|
|
69
|
+
child.on("error", (error) => {
|
|
70
|
+
spawnError = error
|
|
71
|
+
closed = true
|
|
72
|
+
failAll(`spawn failed: ${error.message}`)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
child.on("close", () => {
|
|
76
|
+
closed = true
|
|
77
|
+
const lastLine = stderrTail.trim().split("\n").pop()
|
|
78
|
+
failAll(`Connection closed${lastLine ? ` | stderr: ${lastLine}` : ""}`)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const send = (method, params) => {
|
|
82
|
+
if (spawnError) return Promise.resolve({ id: null, error: { code: -32000, message: `spawn failed: ${spawnError.message}` } })
|
|
83
|
+
if (closed) return Promise.reject(new Error("MCP connection closed"))
|
|
84
|
+
const id = rpcId()
|
|
85
|
+
const promise = new Promise((resolve) => pending.set(id, resolve))
|
|
86
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
|
|
87
|
+
return withTimeout(promise, CALL_TIMEOUT_MS).finally(() => pending.delete(id))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// notification:无 id,不期待响应(协议要求)
|
|
91
|
+
const notify = (method, params) => {
|
|
92
|
+
if (!closed) child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { send, notify, close: () => { if (!closed) child.kill() } }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- HTTP + SSE transport (Streamable HTTP) ----
|
|
99
|
+
|
|
100
|
+
function httpTransport(baseURL, extraHeaders = {}) {
|
|
101
|
+
const url = baseURL.replace(/\/+$/, "")
|
|
102
|
+
let sessionId = null
|
|
103
|
+
let closed = false
|
|
104
|
+
let eventSource = null
|
|
105
|
+
let abortController = null
|
|
106
|
+
|
|
107
|
+
const headers = () => {
|
|
108
|
+
const h = { "Content-Type": "application/json", Accept: "text/event-stream, application/json", ...extraHeaders }
|
|
109
|
+
if (sessionId) h["Mcp-Session-Id"] = sessionId
|
|
110
|
+
return h
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const pending = new Map()
|
|
114
|
+
|
|
115
|
+
// SSE 解析器:从 response body 逐行读,处理 data: / event: / id: / 空行(dispatch)
|
|
116
|
+
async function* parseSSE(response) {
|
|
117
|
+
const reader = response.body.getReader()
|
|
118
|
+
const decoder = new TextDecoder()
|
|
119
|
+
let buf = ""
|
|
120
|
+
let current = { data: "", event: "message" }
|
|
121
|
+
try {
|
|
122
|
+
while (true) {
|
|
123
|
+
const { done, value } = await reader.read()
|
|
124
|
+
if (done) break
|
|
125
|
+
buf += decoder.decode(value, { stream: true })
|
|
126
|
+
const lines = buf.split("\n")
|
|
127
|
+
buf = lines.pop() ?? ""
|
|
128
|
+
for (const raw of lines) {
|
|
129
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw
|
|
130
|
+
if (line === "") {
|
|
131
|
+
if (current.data) {
|
|
132
|
+
yield { event: current.event, data: current.data.trimEnd() }
|
|
133
|
+
current = { data: "", event: "message" }
|
|
134
|
+
}
|
|
135
|
+
} else if (line.startsWith("data:")) {
|
|
136
|
+
current.data += (current.data ? "\n" : "") + line.slice(5).replace(/^ /, "")
|
|
137
|
+
} else if (line.startsWith("event:")) {
|
|
138
|
+
current.event = line.slice(6).trim()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
reader.releaseLock()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 打开 SSE 长连接(用于接收服务端推送)
|
|
148
|
+
async function openSSE() {
|
|
149
|
+
if (closed) return
|
|
150
|
+
abortController?.abort()
|
|
151
|
+
abortController = new AbortController()
|
|
152
|
+
const resp = await fetch(url + "/sse", {
|
|
153
|
+
method: "GET",
|
|
154
|
+
headers: { Accept: "text/event-stream" },
|
|
155
|
+
signal: abortController.signal,
|
|
156
|
+
})
|
|
157
|
+
if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
|
|
158
|
+
eventSource = parseSSE(resp)
|
|
159
|
+
|
|
160
|
+
// 后台消费 SSE 事件并分发到 pending
|
|
161
|
+
;(async () => {
|
|
162
|
+
try {
|
|
163
|
+
for await (const { data } of eventSource) {
|
|
164
|
+
if (closed) break
|
|
165
|
+
try {
|
|
166
|
+
const msg = JSON.parse(data)
|
|
167
|
+
const resolver = pending.get(msg.id)
|
|
168
|
+
if (resolver) {
|
|
169
|
+
pending.delete(msg.id)
|
|
170
|
+
resolver(msg)
|
|
171
|
+
}
|
|
172
|
+
// 没有 pending resolver 的可能是通知,忽略
|
|
173
|
+
} catch { /* 非 JSON,忽略 */ }
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!closed) {
|
|
177
|
+
for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: `SSE error: ${error.message}` } })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
})()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// POST JSON-RPC 请求,同时监听响应
|
|
184
|
+
async function postRequest(method, params) {
|
|
185
|
+
const id = rpcId()
|
|
186
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params })
|
|
187
|
+
|
|
188
|
+
// 如果有活跃的 SSE 连接,服务器会通过 SSE 推回响应
|
|
189
|
+
if (eventSource) {
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
pending.set(id, resolve)
|
|
192
|
+
fetch(url + "/messages", { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
|
|
193
|
+
.catch((e) => {
|
|
194
|
+
pending.delete(id)
|
|
195
|
+
resolve({ id, error: { code: -32000, message: `POST failed: ${e.message}` } })
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 没有 SSE:纯 HTTP POST,响应就是 JSON-RPC
|
|
201
|
+
const resp = await fetch(url + "/messages", {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: headers(),
|
|
204
|
+
body,
|
|
205
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
206
|
+
})
|
|
207
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
208
|
+
|
|
209
|
+
const ct = resp.headers.get("content-type") ?? ""
|
|
210
|
+
const newSessionId = resp.headers.get("Mcp-Session-Id")
|
|
211
|
+
if (newSessionId) sessionId = newSessionId
|
|
212
|
+
|
|
213
|
+
if (ct.includes("text/event-stream")) {
|
|
214
|
+
// 服务器返回 SSE:第一个事件是响应
|
|
215
|
+
const sse = parseSSE(resp)
|
|
216
|
+
for await (const { data } of sse) {
|
|
217
|
+
try {
|
|
218
|
+
const msg = JSON.parse(data)
|
|
219
|
+
if (msg.id === id) return msg
|
|
220
|
+
// 可能是通知
|
|
221
|
+
} catch { /* skip */ }
|
|
222
|
+
}
|
|
223
|
+
return { id, error: { code: -32000, message: "No JSON-RPC response in SSE stream" } }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 纯 JSON 响应
|
|
227
|
+
return resp.json()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const send = async (method, params) => withTimeout(postRequest(method, params), CALL_TIMEOUT_MS)
|
|
231
|
+
|
|
232
|
+
// notification:无 id,不期待响应(协议要求)
|
|
233
|
+
const notify = (method, params) => {
|
|
234
|
+
fetch(url + "/messages", {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: headers(),
|
|
237
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params }),
|
|
238
|
+
signal: AbortSignal.timeout(10_000),
|
|
239
|
+
}).catch(() => {})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const close = () => {
|
|
243
|
+
closed = true
|
|
244
|
+
abortController?.abort()
|
|
245
|
+
for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: "Connection closed" } })
|
|
246
|
+
pending.clear()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { send, notify, close, openSSE, url, headers: extraHeaders }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- MCP lifecycle ----
|
|
253
|
+
|
|
254
|
+
function buildTools(mcpTools, transport, config) {
|
|
255
|
+
const prefix = config.name ? `${config.name}_` : "mcp_"
|
|
256
|
+
return mcpTools.map((t) => ({
|
|
257
|
+
name: prefix + sanitizeToolName(t.name),
|
|
258
|
+
description: t.description ?? `MCP tool: ${t.name}`,
|
|
259
|
+
parameters: t.inputSchema ?? { type: "object", properties: {} },
|
|
260
|
+
readonly: false,
|
|
261
|
+
async execute(args) {
|
|
262
|
+
const resp = await transport.send("tools/call", { name: t.name, arguments: args })
|
|
263
|
+
if (resp.error) throw new Error(`MCP tool "${t.name}": ${resp.error.message}`)
|
|
264
|
+
const content = resp.result?.content ?? []
|
|
265
|
+
return content
|
|
266
|
+
.map((c) => (c.type === "text" ? c.text : c.type === "resource" ? `[resource: ${c.resource?.uri}]` : JSON.stringify(c)))
|
|
267
|
+
.join("\n") || "(no output)"
|
|
268
|
+
},
|
|
269
|
+
_mcpTransport: transport,
|
|
270
|
+
_mcpName: config.name,
|
|
271
|
+
}))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function doInitialize(transport, name) {
|
|
275
|
+
const initResp = await withTimeout(
|
|
276
|
+
transport.send("initialize", {
|
|
277
|
+
protocolVersion: "2024-11-05",
|
|
278
|
+
capabilities: {},
|
|
279
|
+
clientInfo: { name: "thincoder", version: "1.0.0" },
|
|
280
|
+
}),
|
|
281
|
+
INIT_TIMEOUT_MS,
|
|
282
|
+
)
|
|
283
|
+
if (initResp.error) throw new Error(`initialize error: ${initResp.error.message}`)
|
|
284
|
+
transport.notify?.("notifications/initialized", {})
|
|
285
|
+
|
|
286
|
+
const toolsResp = await transport.send("tools/list", {})
|
|
287
|
+
if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
|
|
288
|
+
return toolsResp.result?.tools ?? []
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 连接一个 MCP server。
|
|
293
|
+
* stdio: { name, command, args? }
|
|
294
|
+
* http: { name, url, headers? }
|
|
295
|
+
*/
|
|
296
|
+
export async function connectMcpServer(config) {
|
|
297
|
+
if (config.url) {
|
|
298
|
+
const transport = httpTransport(config.url, config.headers ?? {})
|
|
299
|
+
try {
|
|
300
|
+
await transport.openSSE()
|
|
301
|
+
} catch {
|
|
302
|
+
// 不支持 GET /sse 的 server(纯 Streamable HTTP POST):降级为无 SSE 模式
|
|
303
|
+
}
|
|
304
|
+
const mcpTools = await doInitialize(transport, config.name ?? config.url)
|
|
305
|
+
return buildTools(mcpTools, transport, config)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (config.command) {
|
|
309
|
+
const transport = stdioTransport(config.command, config.args ?? [])
|
|
310
|
+
try {
|
|
311
|
+
const mcpTools = await doInitialize(transport, config.name ?? config.command)
|
|
312
|
+
return buildTools(mcpTools, transport, config)
|
|
313
|
+
} catch (error) {
|
|
314
|
+
transport.close()
|
|
315
|
+
throw error
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
throw new Error(`MCP server "${config.name}": needs either 'command' (stdio) or 'url' (http)`)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function closeAllMcp(agent) {
|
|
323
|
+
for (const t of agent.tools) {
|
|
324
|
+
if (t._mcpTransport) t._mcpTransport.close()
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function removeMcpTools(agent, serverName) {
|
|
329
|
+
const keep = []
|
|
330
|
+
for (const t of agent.tools) {
|
|
331
|
+
if (t._mcpName === serverName) {
|
|
332
|
+
if (t._mcpTransport) t._mcpTransport.close()
|
|
333
|
+
} else {
|
|
334
|
+
keep.push(t)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
agent.tools = keep
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- helpers ----
|
|
341
|
+
|
|
342
|
+
function sanitizeToolName(name) {
|
|
343
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** cmd.exe 参数加引号(含空格/引号时) */
|
|
347
|
+
function quoteArg(s) {
|
|
348
|
+
return /[\s"]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function withTimeout(promise, ms) {
|
|
352
|
+
let timer
|
|
353
|
+
const timeout = new Promise((_, reject) => {
|
|
354
|
+
timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)
|
|
355
|
+
timer.unref?.() // 不拖住进程退出
|
|
356
|
+
})
|
|
357
|
+
// 竞速结束后清掉定时器,不留垃圾
|
|
358
|
+
return Promise.race([promise.finally(() => clearTimeout(timer)), timeout])
|
|
359
|
+
}
|
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)
|