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.
@@ -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
- * 配置文件:~/.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,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
- 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
- maxTurns: 50,
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
- ["moonshot", 256_000],
43
- ["kimi", 256_000],
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
- const COMPACT_RATIO = 0.6
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
- * 加载配置:文件 + 环境变量兜底(key 不明文落盘时走 env)。
67
- * 环境变量优先级:THINCODER_API_KEY > DEEPSEEK_API_KEY > OPENAI_API_KEY
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
- config = JSON.parse(readFileSync(configPath, "utf8"))
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
- provider: { ...DEFAULTS.provider, ...config.provider },
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
- if (!merged.provider.apiKey) {
85
- merged.provider.apiKey =
86
- process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
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 (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
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, merged.provider.model)
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
- role: "user",
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)