thincoder 0.2.0 → 0.4.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 +28 -10
- package/bin/thincoder.mjs +88 -4
- package/package.json +11 -2
- package/src/SYSTEM_PROMPT.md +31 -0
- package/src/agent.mjs +458 -58
- package/src/coder-overlay.md +14 -0
- package/src/config.mjs +15 -5
- package/src/context.mjs +41 -6
- package/src/explore-overlay.md +10 -0
- package/src/mcp.mjs +359 -0
- package/src/session.mjs +2 -0
- 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 +635 -155
|
@@ -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
|
@@ -27,7 +27,7 @@ const DEFAULTS = {
|
|
|
27
27
|
providers: [{ name: "deepseek", ...deepseekPreset }],
|
|
28
28
|
activeProvider: "deepseek",
|
|
29
29
|
agent: {
|
|
30
|
-
maxTurns:
|
|
30
|
+
maxTurns: 100,
|
|
31
31
|
compactThreshold: 100000,
|
|
32
32
|
},
|
|
33
33
|
memory: {
|
|
@@ -39,6 +39,9 @@ const DEFAULTS = {
|
|
|
39
39
|
baseURL: "https://api.siliconflow.cn/v1",
|
|
40
40
|
model: "BAAI/bge-m3",
|
|
41
41
|
},
|
|
42
|
+
mcp: {
|
|
43
|
+
servers: [],
|
|
44
|
+
},
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
/**
|
|
@@ -60,7 +63,9 @@ const MODEL_CONTEXT_WINDOWS = [
|
|
|
60
63
|
["qwen", 128_000],
|
|
61
64
|
]
|
|
62
65
|
const DEFAULT_CONTEXT_WINDOW = 128_000
|
|
63
|
-
|
|
66
|
+
// 窗口利用率上限:0.8(DeepSeek 内部即全窗口;压缩本身要花一次 LLM 调用,过早压缩是纯浪费。
|
|
67
|
+
// 留 20% 余量给压缩后的尾部增长与输出 token)
|
|
68
|
+
const COMPACT_RATIO = 0.8
|
|
64
69
|
|
|
65
70
|
export function contextWindowForModel(model) {
|
|
66
71
|
const m = (model ?? "").toLowerCase()
|
|
@@ -133,10 +138,15 @@ export function loadConfig() {
|
|
|
133
138
|
if (process.env.THINCODER_BASE_URL) runtimeProvider.baseURL = process.env.THINCODER_BASE_URL
|
|
134
139
|
if (process.env.THINCODER_MODEL) runtimeProvider.model = process.env.THINCODER_MODEL
|
|
135
140
|
|
|
136
|
-
// apiKey
|
|
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]
|
|
147
|
+
}
|
|
137
148
|
if (!runtimeProvider.apiKey) {
|
|
138
|
-
runtimeProvider.apiKey =
|
|
139
|
-
process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
|
|
149
|
+
runtimeProvider.apiKey = process.env.THINCODER_API_KEY
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
// embedding apiKey
|
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/session.mjs
CHANGED
|
@@ -23,6 +23,8 @@ export function saveSession(agent) {
|
|
|
23
23
|
updatedAt: Date.now(),
|
|
24
24
|
history: agent.history,
|
|
25
25
|
tasks: agent.tasks ?? [],
|
|
26
|
+
planMode: agent.planMode ?? false,
|
|
27
|
+
goal: agent.goal ?? null,
|
|
26
28
|
}
|
|
27
29
|
const p = sessionPath(agent.cwd)
|
|
28
30
|
mkdirSync(dirname(p), { recursive: true })
|
package/src/skills.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills.mjs — 技能系统
|
|
3
|
+
* 从 .thincoder/skills/ 目录发现 .md 技能文件,
|
|
4
|
+
* 注入到 system prompt 供 agent 按需加载。
|
|
5
|
+
* 用 skill 工具激活指定技能,内容以 <skill-loaded> 包裹写入对话历史。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, readdir, stat } from "node:fs/promises"
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 扫描 .thincoder/skills/ 目录,返回技能列表。
|
|
13
|
+
* 每个技能:{ name, path, description } — name 取文件名(去扩展名)。
|
|
14
|
+
* 目录不存在或无文件返回空数组。
|
|
15
|
+
*/
|
|
16
|
+
export async function loadSkills(cwd) {
|
|
17
|
+
const dir = join(cwd, ".thincoder", "skills")
|
|
18
|
+
let entries
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(dir)
|
|
21
|
+
} catch {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
const skills = []
|
|
25
|
+
for (const name of entries) {
|
|
26
|
+
if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // 与 readSkill 的名字校验一致,防"列得出、读不了"
|
|
27
|
+
const p = join(dir, name)
|
|
28
|
+
try {
|
|
29
|
+
const s = await stat(p)
|
|
30
|
+
if (!s.isFile()) continue
|
|
31
|
+
// 提取描述(前 400 字符里第一段非空、非标题行)
|
|
32
|
+
const head = await readFile(p, "utf8")
|
|
33
|
+
const body = head.slice(0, 400).split("\n")
|
|
34
|
+
let desc = ""
|
|
35
|
+
for (const line of body) {
|
|
36
|
+
const t = line.trim()
|
|
37
|
+
if (t && !t.startsWith("#") && !t.startsWith("---")) {
|
|
38
|
+
desc = t.slice(0, 120)
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
|
|
43
|
+
} catch {
|
|
44
|
+
// 读失败跳过
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return skills
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 生成技能列表文本,注入 system prompt。
|
|
52
|
+
* 最多 3 个(占位少),超过则标 "... and N more"。
|
|
53
|
+
*/
|
|
54
|
+
export function formatSkillListing(skills) {
|
|
55
|
+
if (skills.length === 0) return ""
|
|
56
|
+
const listed = skills.slice(0, 3)
|
|
57
|
+
const lines = listed.map((s) => `- **${s.name}**: ${s.description}`)
|
|
58
|
+
if (skills.length > 3) lines.push(` ... and ${skills.length - 3} more`)
|
|
59
|
+
return "Available skills (use the skill tool to load one):\n" + lines.join("\n")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读取指定技能文件的完整内容。
|
|
64
|
+
* 返回文本,找不到返回 null。
|
|
65
|
+
*/
|
|
66
|
+
export async function readSkill(cwd, name) {
|
|
67
|
+
// 安全检查:技能名只能是字母数字 + 连字符/下划线
|
|
68
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null
|
|
69
|
+
const p = join(cwd, ".thincoder", "skills", `${name}.md`)
|
|
70
|
+
try {
|
|
71
|
+
return await readFile(p, "utf8")
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- command (required): Shell command to execute
|
|
5
|
+
- timeout: Timeout in milliseconds (default 120000, max ~300000)
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
- There is NO TTY — editors, pagers (vim, less), and interactive prompts WILL hang. Always pass non-interactive flags: `git commit -m`, `git --no-pager`, `-y`/`--yes` where applicable
|
|
9
|
+
- The environment sets GIT_PAGER=cat, PAGER=cat, EDITOR=true, TERM=dumb — but still always use non-interactive flags
|
|
10
|
+
- Output is capped at ~50000 chars; if you need more, redirect to a file and read it
|
|
11
|
+
- On Windows, use Unix shell syntax inside bash commands (Git Bash): forward slashes, `/dev/null` not `NUL`
|
|
12
|
+
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
13
|
+
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Delete a file. Use when the agent created a temporary or junk file that should be cleaned up, or when the user explicitly asks to delete something. Refuses to delete git-tracked files as a safety measure — tracked files should be edited or removed via bash with explicit user confirmation.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (required): File path, relative to cwd or absolute
|
|
5
|
+
|
|
6
|
+
Notes:
|
|
7
|
+
- Untracked or non-git files are deleted immediately
|
|
8
|
+
- Tracked files require force=true (user must confirm separately)
|
|
9
|
+
- Directories must be removed with bash (rm -rf)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (required): File path
|
|
5
|
+
- old_string (required): Exact text to find and replace
|
|
6
|
+
- new_string (required): Replacement text
|
|
7
|
+
- replace_all: Replace all occurrences instead of just one (default false)
|
|
8
|
+
|
|
9
|
+
Notes:
|
|
10
|
+
- Prefer this over write for targeted edits — it's safer and keeps diffs small
|
|
11
|
+
- If old_string matches zero times: error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
|
|
12
|
+
- Never fabricate the old_string — copy it verbatim from the actual file using read first
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Fetch a URL and return its content as text. HTML pages are stripped to readable text. Use after websearch to read full documents.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- url (required): http/https URL
|
|
5
|
+
|
|
6
|
+
Notes:
|
|
7
|
+
- Follows redirects automatically
|
|
8
|
+
- Timeout: 20 seconds
|
|
9
|
+
- HTML pages are converted to plain text (scripts, styles, navigation stripped)
|
|
10
|
+
- Non-HTML responses are returned as-is (truncated at ~50000 chars)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Show git diff (unified format). Use to see uncommitted changes, staged changes, or diff against a specific ref.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- staged: Show staged changes (default false, shows working tree diff)
|
|
5
|
+
- path: File or directory to diff (default all)
|
|
6
|
+
- ref: Compare against a ref (default HEAD)
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- Only works inside a git repository
|
|
10
|
+
- Output is standard unified diff — LLMs understand this natively
|
|
11
|
+
- If no changes, returns "(no changes)"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Show recent git commit history. Use to understand the project's recent changes, conventions, and pace.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- count: Number of commits to show (default 10)
|
|
5
|
+
- path: File or directory to show history for (default all)
|
|
6
|
+
- oneline: Compact one-line-per-commit format (default false)
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- Only works inside a git repository
|
|
10
|
+
- Output includes hash, author, date, and message
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Show structured git status. Use to understand the current working tree state: what files are staged, modified, untracked, or conflicting.
|
|
2
|
+
|
|
3
|
+
Returns categorized lists: staged, unstaged, untracked, conflicts.
|
|
4
|
+
|
|
5
|
+
Notes:
|
|
6
|
+
- Only works inside a git repository
|
|
7
|
+
- Output is categorized for easy parsing by the agent
|
|
8
|
+
- No side effects — read-only
|