thincoder 0.7.8 → 0.8.1
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 -13
- package/bin/thincoder.js +4 -0
- package/bin/thincoder.mjs +27 -346
- package/package.json +2 -2
- package/src/agent/dispatch.mjs +98 -0
- package/src/agent/helpers.mjs +185 -0
- package/src/agent/setup.mjs +117 -0
- package/src/agent-tools/goal.mjs +71 -0
- package/src/agent-tools/plan.mjs +31 -0
- package/src/agent-tools/recent-changes.mjs +23 -0
- package/src/agent-tools/skill.mjs +46 -0
- package/src/agent-tools/subagent.mjs +113 -0
- package/src/agent-tools/task.mjs +67 -0
- package/src/agent-tools/verify.mjs +198 -0
- package/src/agent-tools.mjs +12 -0
- package/src/agent.mjs +90 -1040
- package/src/cli/distill-command.mjs +85 -0
- package/src/cli/make-agent.mjs +85 -0
- package/src/cli/memory-command.mjs +63 -0
- package/src/cli/permission.mjs +41 -0
- package/src/cli/setup-wizard.mjs +70 -0
- package/src/config.mjs +5 -8
- package/src/context.mjs +10 -13
- package/src/distill.mjs +4 -3
- package/src/embedding.mjs +4 -2
- package/src/{checkpoint.mjs → git/checkpoint.mjs} +1 -1
- package/src/mcp/helpers.mjs +37 -0
- package/src/mcp/transport-http.mjs +176 -0
- package/src/mcp/transport-stdio.mjs +84 -0
- package/src/mcp/transport-ws.mjs +87 -0
- package/src/mcp.mjs +4 -428
- package/src/memory/code-index.mjs +211 -0
- package/src/memory/code-sync.mjs +306 -0
- package/src/memory/core.mjs +277 -0
- package/src/memory/docs.mjs +262 -0
- package/src/memory/schema.mjs +426 -0
- package/src/memory.mjs +12 -1403
- package/src/provider/core.mjs +239 -0
- package/src/provider/index.mjs +6 -0
- package/src/provider/rate.mjs +104 -0
- package/src/session.mjs +18 -5
- package/src/tools/bash.mjs +144 -0
- package/src/tools/file.mjs +205 -0
- package/src/tools/git.mjs +166 -0
- package/src/tools/glob.mjs +51 -0
- package/src/tools/grep.mjs +100 -0
- package/src/tools/index.mjs +22 -0
- package/src/tools/ls.mjs +36 -0
- package/src/tools/patch.mjs +226 -0
- package/src/tools/repomap-parse.mjs +168 -0
- package/src/tools/shared.mjs +257 -0
- package/src/tools/system.mjs +336 -0
- package/src/tools/web.mjs +121 -0
- package/src/tools.mjs +2 -1194
- package/src/tui/agent-turn.mjs +254 -0
- package/src/tui/ansi.mjs +32 -0
- package/src/tui/clipboard.mjs +48 -0
- package/src/tui/cmd-auto.mjs +21 -0
- package/src/tui/cmd-clear.mjs +26 -0
- package/src/tui/cmd-config.mjs +72 -0
- package/src/tui/cmd-exit.mjs +5 -0
- package/src/tui/cmd-extract.mjs +5 -0
- package/src/tui/cmd-goal.mjs +47 -0
- package/src/tui/cmd-help.mjs +25 -0
- package/src/tui/cmd-init.mjs +91 -0
- package/src/tui/cmd-mcp.mjs +146 -0
- package/src/tui/cmd-model.mjs +7 -0
- package/src/tui/cmd-new.mjs +18 -0
- package/src/tui/cmd-plan.mjs +21 -0
- package/src/tui/cmd-reindex.mjs +44 -0
- package/src/tui/cmd-restore.mjs +39 -0
- package/src/tui/cmd-session.mjs +42 -0
- package/src/tui/cmd-skills.mjs +17 -0
- package/src/tui/cmd-think.mjs +56 -0
- package/src/tui/config-helpers.mjs +34 -0
- package/src/tui/distill-cmd.mjs +45 -0
- package/src/tui/index.mjs +330 -0
- package/src/tui/interaction.mjs +79 -0
- package/src/tui/key-handler.mjs +267 -0
- package/src/tui/layout.mjs +115 -0
- package/src/tui/pickers.mjs +279 -0
- package/src/tui/render-frame.mjs +304 -0
- package/src/tui/render.mjs +205 -0
- package/src/tui/slash-commands.mjs +138 -0
- package/src/tui/startup.mjs +113 -0
- package/src/tui/wizard.mjs +168 -0
- package/src/tui-render.mjs +4 -0
- package/src/tui.mjs +3 -2566
- package/src/provider.mjs +0 -383
- /package/src/{gitmem.mjs → git/gitmem.mjs} +0 -0
- /package/src/{coder-overlay.md → prompts/coder.md} +0 -0
- /package/src/{discipline-rules.md → prompts/discipline.md} +0 -0
- /package/src/{explore-overlay.md → prompts/explore.md} +0 -0
- /package/src/{main-overlay.md → prompts/main.md} +0 -0
- /package/src/{plan-overlay.md → prompts/plan.md} +0 -0
- /package/src/{SYSTEM_PROMPT.md → prompts/system.md} +0 -0
- /package/src/{repomap.mjs → tools/repomap.mjs} +0 -0
package/src/mcp.mjs
CHANGED
|
@@ -1,406 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mcp.mjs — MCP (Model Context Protocol) client
|
|
3
|
-
* 零依赖:stdio transport (spawn + JSON-RPC) + HTTP transport (fetch + SSE) + WebSocket transport (global WebSocket)。
|
|
4
3
|
* config: { command, args?, name } 或 { url, name, headers? } 或 { wsUrl, name, headers? }
|
|
5
4
|
*/
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const CALL_TIMEOUT_MS = 120_000
|
|
11
|
-
// 等 legacy SSE 首个 endpoint 事件的上限:legacy server 连接后立即发,实际几毫秒内到达
|
|
12
|
-
const ENDPOINT_WAIT_MS = 5_000
|
|
13
|
-
|
|
14
|
-
// ---- JSON-RPC helpers ----
|
|
15
|
-
|
|
16
|
-
let nextRpcId = 0
|
|
17
|
-
function rpcId() {
|
|
18
|
-
return String(++nextRpcId) // 自增:随机数可能碰撞串响应
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// ---- stdio transport ----
|
|
22
|
-
|
|
23
|
-
function stdioTransport(command, args) {
|
|
24
|
-
// Windows 上 npx 等命令是 .cmd,Node 不带 shell 拒 spawn(EINVAL);
|
|
25
|
-
// shell:true 又触发 DEP0190 且不转义参数——显式走 cmd.exe 并自己加引号;
|
|
26
|
-
// windowsVerbatimArguments 防止 Node 把内层引号转义成 \"(cmd 不认,会把引号当字面量传下去)
|
|
27
|
-
const spawnOptions = { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env: { ...process.env } }
|
|
28
|
-
const child =
|
|
29
|
-
process.platform === "win32" && !/\.exe$/i.test(command)
|
|
30
|
-
? spawn("cmd.exe", ["/d", "/s", "/c", [command, ...(args ?? [])].map(quoteArg).join(" ")], {
|
|
31
|
-
...spawnOptions,
|
|
32
|
-
windowsVerbatimArguments: true,
|
|
33
|
-
})
|
|
34
|
-
: spawn(command, args ?? [], spawnOptions)
|
|
35
|
-
|
|
36
|
-
const pending = new Map()
|
|
37
|
-
const decoder = new TextDecoder() // 单一实例:跨 chunk 保留多字节 UTF-8 的中间状态
|
|
38
|
-
let buffer = ""
|
|
39
|
-
let stderrTail = "" // 诊断用:server 起不来时给用户一点线索
|
|
40
|
-
let spawnError = null
|
|
41
|
-
let closed = false
|
|
42
|
-
|
|
43
|
-
const failAll = (message) => {
|
|
44
|
-
for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
|
|
45
|
-
pending.clear()
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
child.stdout.on("data", (chunk) => {
|
|
49
|
-
// stream:true:多字节字符跨 chunk 拆分时暂存残片,等下一个 chunk 拼完整
|
|
50
|
-
buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
|
|
51
|
-
const lines = buffer.split("\n")
|
|
52
|
-
buffer = lines.pop() ?? ""
|
|
53
|
-
for (const line of lines) {
|
|
54
|
-
if (!line.trim()) continue
|
|
55
|
-
try {
|
|
56
|
-
const msg = JSON.parse(line)
|
|
57
|
-
const resolver = pending.get(msg.id)
|
|
58
|
-
if (resolver) {
|
|
59
|
-
pending.delete(msg.id)
|
|
60
|
-
resolver(msg)
|
|
61
|
-
}
|
|
62
|
-
} catch {
|
|
63
|
-
// 非 JSON 行忽略
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
child.stderr.on("data", (chunk) => {
|
|
69
|
-
stderrTail = (stderrTail + chunk.toString()).slice(-2000)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
// stdin 写错误(EPIPE 等):没有这个监听,error 事件会崩掉整个进程;close 事件统一兜底
|
|
73
|
-
child.stdin.on("error", () => {})
|
|
74
|
-
|
|
75
|
-
// spawn 失败(命令不存在/EINVAL):没有这个监听,error 事件会崩掉整个进程
|
|
76
|
-
child.on("error", (error) => {
|
|
77
|
-
spawnError = error
|
|
78
|
-
closed = true
|
|
79
|
-
failAll(`spawn failed: ${error.message}`)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
child.on("close", () => {
|
|
83
|
-
closed = true
|
|
84
|
-
const lastLine = stderrTail.trim().split("\n").pop()
|
|
85
|
-
failAll(`Connection closed${lastLine ? ` | stderr: ${lastLine}` : ""}`)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
const send = (method, params) => {
|
|
89
|
-
if (spawnError) return Promise.resolve({ id: null, error: { code: -32000, message: `spawn failed: ${spawnError.message}` } })
|
|
90
|
-
if (closed) return Promise.reject(new Error("MCP connection closed"))
|
|
91
|
-
const id = rpcId()
|
|
92
|
-
const promise = new Promise((resolve) => pending.set(id, resolve))
|
|
93
|
-
try {
|
|
94
|
-
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
|
|
95
|
-
} catch (error) {
|
|
96
|
-
pending.delete(id)
|
|
97
|
-
return Promise.resolve({ id: null, error: { code: -32000, message: `stdin write failed: ${error.message}` } })
|
|
98
|
-
}
|
|
99
|
-
return withTimeout(promise, CALL_TIMEOUT_MS).finally(() => pending.delete(id))
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// notification:无 id,不期待响应(协议要求)
|
|
103
|
-
const notify = (method, params) => {
|
|
104
|
-
if (closed) return
|
|
105
|
-
try {
|
|
106
|
-
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
|
|
107
|
-
} catch { /* 忽略:close 事件会兜底 */ }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return { send, notify, close: () => { if (!closed) child.kill() } }
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ---- HTTP + SSE transport (Streamable HTTP) ----
|
|
114
|
-
|
|
115
|
-
function httpTransport(baseURL, extraHeaders = {}) {
|
|
116
|
-
const url = baseURL.replace(/\/+$/, "")
|
|
117
|
-
let sessionId = null
|
|
118
|
-
let closed = false
|
|
119
|
-
let eventSource = null
|
|
120
|
-
let abortController = null
|
|
121
|
-
// legacy SSE (2024-11-05):POST 地址由 server 的 endpoint 事件告知;
|
|
122
|
-
// Streamable HTTP (2025-03-26):POST 到配置的 URL 本身
|
|
123
|
-
let postUrl = url
|
|
124
|
-
// 收到 endpoint 事件才置真:legacy SSE (2024-11-05) 模式,POST 只回 202,响应经 SSE 流推回;
|
|
125
|
-
// 否则按 Streamable HTTP (2025-03-26):响应就在 POST 自身(即使 server 同时支持 GET 推送)
|
|
126
|
-
let legacySSE = false
|
|
127
|
-
|
|
128
|
-
const headers = () => {
|
|
129
|
-
const h = { "Content-Type": "application/json", Accept: "text/event-stream, application/json", ...extraHeaders }
|
|
130
|
-
if (sessionId) h["Mcp-Session-Id"] = sessionId
|
|
131
|
-
return h
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const pending = new Map()
|
|
135
|
-
|
|
136
|
-
// SSE 解析器:从 response body 逐行读,处理 data: / event: / 空行(dispatch)
|
|
137
|
-
async function* parseSSE(response) {
|
|
138
|
-
const reader = response.body.getReader()
|
|
139
|
-
const decoder = new TextDecoder()
|
|
140
|
-
let buf = ""
|
|
141
|
-
let current = { data: "", event: "message" }
|
|
142
|
-
try {
|
|
143
|
-
while (true) {
|
|
144
|
-
const { done, value } = await reader.read()
|
|
145
|
-
if (done) break
|
|
146
|
-
buf += decoder.decode(value, { stream: true })
|
|
147
|
-
const lines = buf.split("\n")
|
|
148
|
-
buf = lines.pop() ?? ""
|
|
149
|
-
for (const raw of lines) {
|
|
150
|
-
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw
|
|
151
|
-
if (line === "") {
|
|
152
|
-
if (current.data) {
|
|
153
|
-
yield { event: current.event, data: current.data.trimEnd() }
|
|
154
|
-
current = { data: "", event: "message" }
|
|
155
|
-
}
|
|
156
|
-
} else if (line.startsWith("data:")) {
|
|
157
|
-
current.data += (current.data ? "\n" : "") + line.slice(5).replace(/^ /, "")
|
|
158
|
-
} else if (line.startsWith("event:")) {
|
|
159
|
-
current.event = line.slice(6).trim()
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
} finally {
|
|
164
|
-
reader.releaseLock()
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// 打开 SSE 长连接(legacy SSE transport,用于接收服务端推送)
|
|
169
|
-
// 按 2024-11-05 规范:GET 配置的 URL 本身,server 的第一个 endpoint 事件告知 POST 地址
|
|
170
|
-
async function openSSE() {
|
|
171
|
-
if (closed) return
|
|
172
|
-
abortController?.abort()
|
|
173
|
-
abortController = new AbortController()
|
|
174
|
-
const resp = await fetch(url, {
|
|
175
|
-
method: "GET",
|
|
176
|
-
headers: { Accept: "text/event-stream", ...extraHeaders },
|
|
177
|
-
signal: abortController.signal,
|
|
178
|
-
})
|
|
179
|
-
if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
|
|
180
|
-
eventSource = parseSSE(resp)
|
|
181
|
-
let endpointReady
|
|
182
|
-
const gotEndpoint = new Promise((resolve) => { endpointReady = resolve })
|
|
183
|
-
|
|
184
|
-
// 后台消费 SSE 事件并分发到 pending
|
|
185
|
-
;(async () => {
|
|
186
|
-
try {
|
|
187
|
-
for await (const { event, data } of eventSource) {
|
|
188
|
-
if (closed) break
|
|
189
|
-
if (event === "endpoint") {
|
|
190
|
-
// data 是相对/绝对 URI,拼到配置的 URL 上作为 POST 地址
|
|
191
|
-
postUrl = new URL(data.trim(), url).href
|
|
192
|
-
legacySSE = true
|
|
193
|
-
endpointReady()
|
|
194
|
-
continue
|
|
195
|
-
}
|
|
196
|
-
try {
|
|
197
|
-
const msg = JSON.parse(data)
|
|
198
|
-
const resolver = pending.get(msg.id)
|
|
199
|
-
if (resolver) {
|
|
200
|
-
pending.delete(msg.id)
|
|
201
|
-
resolver(msg)
|
|
202
|
-
}
|
|
203
|
-
// 没有 pending resolver 的可能是通知,忽略
|
|
204
|
-
} catch { /* 非 JSON,忽略 */ }
|
|
205
|
-
}
|
|
206
|
-
} catch (error) {
|
|
207
|
-
if (!closed) {
|
|
208
|
-
for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: `SSE error: ${error.message}` } })
|
|
209
|
-
pending.clear()
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
})()
|
|
213
|
-
|
|
214
|
-
// 等 endpoint 事件再发请求(2024-11-05 要求拿到 POST 地址后才能 POST);
|
|
215
|
-
// 超时说明不是 legacy server——Streamable HTTP 的 GET 流只推服务端消息,响应走 POST 自身
|
|
216
|
-
const wait = new Promise((resolve) => {
|
|
217
|
-
const t = setTimeout(resolve, ENDPOINT_WAIT_MS)
|
|
218
|
-
t.unref?.()
|
|
219
|
-
})
|
|
220
|
-
await Promise.race([gotEndpoint, wait])
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// POST JSON-RPC 请求,同时监听响应
|
|
224
|
-
async function postRequest(method, params) {
|
|
225
|
-
const id = rpcId()
|
|
226
|
-
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params })
|
|
227
|
-
|
|
228
|
-
// legacy SSE:POST 只回 202,服务器经 SSE 流推回响应
|
|
229
|
-
if (legacySSE) {
|
|
230
|
-
return new Promise((resolve) => {
|
|
231
|
-
pending.set(id, resolve)
|
|
232
|
-
fetch(postUrl, { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
|
|
233
|
-
.then((resp) => {
|
|
234
|
-
// 2024-11-05:POST 期望 202 Accepted;其他错误码说明请求没送达
|
|
235
|
-
if (!resp.ok) {
|
|
236
|
-
pending.delete(id)
|
|
237
|
-
resolve({ id, error: { code: -32000, message: `POST failed: HTTP ${resp.status}` } })
|
|
238
|
-
}
|
|
239
|
-
})
|
|
240
|
-
.catch((e) => {
|
|
241
|
-
pending.delete(id)
|
|
242
|
-
resolve({ id, error: { code: -32000, message: `POST failed: ${e.message}` } })
|
|
243
|
-
})
|
|
244
|
-
// 兜底清理:响应超时(send 外层 withTimeout 先赢)时 pending 不留尸
|
|
245
|
-
}).finally(() => pending.delete(id))
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Streamable HTTP(无 SSE,或 GET 流只推服务端消息):响应就在 POST 自身
|
|
249
|
-
const resp = await fetch(postUrl, {
|
|
250
|
-
method: "POST",
|
|
251
|
-
headers: headers(),
|
|
252
|
-
body,
|
|
253
|
-
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
254
|
-
})
|
|
255
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
256
|
-
|
|
257
|
-
const ct = resp.headers.get("content-type") ?? ""
|
|
258
|
-
const newSessionId = resp.headers.get("Mcp-Session-Id")
|
|
259
|
-
if (newSessionId) sessionId = newSessionId
|
|
260
|
-
|
|
261
|
-
if (ct.includes("text/event-stream")) {
|
|
262
|
-
// 服务器返回 SSE:第一个事件是响应
|
|
263
|
-
const sse = parseSSE(resp)
|
|
264
|
-
for await (const { data } of sse) {
|
|
265
|
-
try {
|
|
266
|
-
const msg = JSON.parse(data)
|
|
267
|
-
if (msg.id === id) return msg
|
|
268
|
-
// 可能是通知
|
|
269
|
-
} catch { /* skip */ }
|
|
270
|
-
}
|
|
271
|
-
return { id, error: { code: -32000, message: "No JSON-RPC response in SSE stream" } }
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// 纯 JSON 响应
|
|
275
|
-
return resp.json()
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const send = async (method, params) => withTimeout(postRequest(method, params), CALL_TIMEOUT_MS)
|
|
279
|
-
|
|
280
|
-
// notification:无 id,不期待响应(协议要求)
|
|
281
|
-
const notify = (method, params) => {
|
|
282
|
-
fetch(postUrl, {
|
|
283
|
-
method: "POST",
|
|
284
|
-
headers: headers(),
|
|
285
|
-
body: JSON.stringify({ jsonrpc: "2.0", method, params }),
|
|
286
|
-
signal: AbortSignal.timeout(10_000),
|
|
287
|
-
}).catch(() => {})
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const close = () => {
|
|
291
|
-
closed = true
|
|
292
|
-
abortController?.abort()
|
|
293
|
-
// Streamable HTTP 规范:有 session 时发 DELETE 让 server 释放会话(尽力而为)
|
|
294
|
-
if (sessionId) {
|
|
295
|
-
fetch(postUrl, {
|
|
296
|
-
method: "DELETE",
|
|
297
|
-
headers: { "Mcp-Session-Id": sessionId, ...extraHeaders },
|
|
298
|
-
signal: AbortSignal.timeout(5_000),
|
|
299
|
-
}).catch(() => {})
|
|
300
|
-
sessionId = null
|
|
301
|
-
}
|
|
302
|
-
for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: "Connection closed" } })
|
|
303
|
-
pending.clear()
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return { send, notify, close, openSSE, url, headers: extraHeaders }
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ---- WebSocket transport ----
|
|
310
|
-
|
|
311
|
-
function wsTransport(wsUrl, extraHeaders = {}) {
|
|
312
|
-
const pending = new Map()
|
|
313
|
-
let closed = false
|
|
314
|
-
let ws = null
|
|
315
|
-
|
|
316
|
-
const failAll = (message) => {
|
|
317
|
-
for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
|
|
318
|
-
pending.clear()
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const connect = () => {
|
|
322
|
-
if (closed) throw new Error("MCP WebSocket connection closed")
|
|
323
|
-
// WebSocket API 不支持自定义 header,token 走 query param
|
|
324
|
-
// (不能把 "Bearer ..." 当子协议传——含空格,违反 Sec-WebSocket-Protocol token 规则会抛 SyntaxError)
|
|
325
|
-
ws = new WebSocket(withAuthToken(wsUrl, extraHeaders.Authorization))
|
|
326
|
-
|
|
327
|
-
return new Promise((resolve, reject) => {
|
|
328
|
-
const timeout = setTimeout(() => {
|
|
329
|
-
ws.close()
|
|
330
|
-
reject(new Error(`WebSocket connect timeout: ${wsUrl}`))
|
|
331
|
-
}, INIT_TIMEOUT_MS)
|
|
332
|
-
|
|
333
|
-
ws.addEventListener("open", () => {
|
|
334
|
-
clearTimeout(timeout)
|
|
335
|
-
resolve()
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
ws.addEventListener("message", (event) => {
|
|
339
|
-
try {
|
|
340
|
-
const msg = JSON.parse(event.data.toString())
|
|
341
|
-
const resolver = pending.get(msg.id)
|
|
342
|
-
if (resolver) {
|
|
343
|
-
pending.delete(msg.id)
|
|
344
|
-
resolver(msg)
|
|
345
|
-
}
|
|
346
|
-
// 没有 resolver 的是通知,忽略
|
|
347
|
-
} catch { /* 非 JSON,忽略 */ }
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
ws.addEventListener("error", (event) => {
|
|
351
|
-
clearTimeout(timeout)
|
|
352
|
-
closed = true
|
|
353
|
-
const errMsg = event.message || "WebSocket error"
|
|
354
|
-
if (pending.size > 0) {
|
|
355
|
-
failAll(errMsg)
|
|
356
|
-
} else {
|
|
357
|
-
reject(new Error(errMsg))
|
|
358
|
-
}
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
ws.addEventListener("close", () => {
|
|
362
|
-
clearTimeout(timeout)
|
|
363
|
-
closed = true
|
|
364
|
-
failAll("WebSocket closed")
|
|
365
|
-
})
|
|
366
|
-
})
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const send = (method, params) => {
|
|
370
|
-
if (closed) return Promise.reject(new Error("MCP WebSocket connection closed"))
|
|
371
|
-
const id = rpcId()
|
|
372
|
-
const promise = new Promise((resolve) => pending.set(id, resolve))
|
|
373
|
-
try {
|
|
374
|
-
ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
|
|
375
|
-
} catch (error) {
|
|
376
|
-
// 非 OPEN 状态 send 会同步抛;close 事件随后统一兜底
|
|
377
|
-
pending.delete(id)
|
|
378
|
-
return Promise.resolve({ id: null, error: { code: -32000, message: `ws send failed: ${error.message}` } })
|
|
379
|
-
}
|
|
380
|
-
return withTimeout(promise, CALL_TIMEOUT_MS).finally(() => pending.delete(id))
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const notify = (method, params) => {
|
|
384
|
-
if (!closed && ws?.readyState === WebSocket.OPEN) {
|
|
385
|
-
ws.send(JSON.stringify({ jsonrpc: "2.0", method, params }))
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const close = () => {
|
|
390
|
-
closed = true
|
|
391
|
-
failAll("Connection closed")
|
|
392
|
-
try { ws?.close() } catch { /* 忽略 */ }
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return { send, notify, close, connect }
|
|
396
|
-
}
|
|
5
|
+
import { INIT_TIMEOUT_MS, withTimeout, sanitizeToolName } from "./mcp/helpers.mjs"
|
|
6
|
+
import { stdioTransport } from "./mcp/transport-stdio.mjs"
|
|
7
|
+
import { httpTransport } from "./mcp/transport-http.mjs"
|
|
8
|
+
import { wsTransport } from "./mcp/transport-ws.mjs"
|
|
397
9
|
|
|
398
10
|
// ---- MCP lifecycle ----
|
|
399
11
|
|
|
400
12
|
function buildTools(mcpTools, transport, config) {
|
|
401
13
|
const prefix = config.name ? `${config.name}_` : "mcp_"
|
|
402
14
|
return mcpTools.map((t) => ({
|
|
403
|
-
// 组合名整体 sanitize + 截断:prefix 也要计入 64 字符上限
|
|
404
15
|
name: sanitizeToolName(prefix + t.name),
|
|
405
16
|
description: t.description ?? `MCP tool: ${t.name}`,
|
|
406
17
|
parameters: t.inputSchema ?? { type: "object", properties: {} },
|
|
@@ -435,11 +46,6 @@ async function doInitialize(transport, name) {
|
|
|
435
46
|
return toolsResp.result?.tools ?? []
|
|
436
47
|
}
|
|
437
48
|
|
|
438
|
-
/**
|
|
439
|
-
* 连接一个 MCP server。
|
|
440
|
-
* stdio: { name, command, args? }
|
|
441
|
-
* http: { name, url, headers? }
|
|
442
|
-
*/
|
|
443
49
|
export async function connectMcpServer(config) {
|
|
444
50
|
if (config.wsUrl) {
|
|
445
51
|
const transport = wsTransport(config.wsUrl, config.headers ?? {})
|
|
@@ -500,33 +106,3 @@ export function removeMcpTools(agent, serverName) {
|
|
|
500
106
|
}
|
|
501
107
|
agent.tools = keep
|
|
502
108
|
}
|
|
503
|
-
|
|
504
|
-
// ---- helpers ----
|
|
505
|
-
|
|
506
|
-
/** 把 Authorization header 转成 ?token= query param(WebSocket 无法自定义 header) */
|
|
507
|
-
function withAuthToken(wsUrl, authorization) {
|
|
508
|
-
if (!authorization) return wsUrl
|
|
509
|
-
const token = authorization.replace(/^Bearer\s+/i, "")
|
|
510
|
-
const u = new URL(wsUrl)
|
|
511
|
-
u.searchParams.set("token", token)
|
|
512
|
-
return u.href
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function sanitizeToolName(name) {
|
|
516
|
-
return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/** cmd.exe 参数加引号(含空格/引号时)。cmd 不认 \" 转义——内层引号必须翻倍 */
|
|
520
|
-
function quoteArg(s) {
|
|
521
|
-
return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function withTimeout(promise, ms) {
|
|
525
|
-
let timer
|
|
526
|
-
const timeout = new Promise((_, reject) => {
|
|
527
|
-
timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)
|
|
528
|
-
timer.unref?.() // 不拖住进程退出
|
|
529
|
-
})
|
|
530
|
-
// 竞速结束后清掉定时器,不留垃圾
|
|
531
|
-
return Promise.race([promise.finally(() => clearTimeout(timer)), timeout])
|
|
532
|
-
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/code-index.mjs — 代码和文档的分块、语言检测、符号提取
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { segmentCJK, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./schema.mjs"
|
|
6
|
+
|
|
7
|
+
/** 推断文件语言(按扩展名) */
|
|
8
|
+
export function detectLanguage(filename) {
|
|
9
|
+
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase()
|
|
10
|
+
const map = {
|
|
11
|
+
".mjs": "javascript", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript", ".tsx": "tsx",
|
|
12
|
+
".py": "python", ".rs": "rust", ".go": "go", ".java": "java",
|
|
13
|
+
".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp",
|
|
14
|
+
".rb": "ruby", ".swift": "swift", ".kt": "kotlin",
|
|
15
|
+
".sh": "bash", ".bash": "bash", ".sql": "sql",
|
|
16
|
+
".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".json": "json",
|
|
17
|
+
".css": "css", ".html": "html", ".vue": "vue", ".svelte": "svelte",
|
|
18
|
+
".md": "markdown", ".mdc": "markdown",
|
|
19
|
+
}
|
|
20
|
+
return map[ext] ?? ext.slice(1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 用正则提取 JS/TS 文件的顶层符号声明(函数、类、const 导出等)。
|
|
25
|
+
* 返回 [{ name, line, kind }]。
|
|
26
|
+
*/
|
|
27
|
+
export function extractSymbols(lines, ext) {
|
|
28
|
+
const jsish = new Set([".mjs", ".js", ".ts", ".jsx", ".tsx"])
|
|
29
|
+
if (!jsish.has(ext)) return []
|
|
30
|
+
|
|
31
|
+
const symbols = []
|
|
32
|
+
const text = lines.join("\n")
|
|
33
|
+
const re = /(?:export\s+)?(?:(?:async\s+)?function\s+(\w+)|class\s+(\w+)|(?:export\s+)?(?:const|let|var)\s+(\w+))/gm
|
|
34
|
+
let m
|
|
35
|
+
while ((m = re.exec(text))) {
|
|
36
|
+
const name = m[1] || m[2] || m[3]
|
|
37
|
+
if (!name || name[0] !== name[0].toLowerCase() && name.length < 2) continue
|
|
38
|
+
const line = text.slice(0, m.index).split("\n").length
|
|
39
|
+
symbols.push({ name, line, kind: m[1] ? "function" : m[2] ? "class" : "variable" })
|
|
40
|
+
}
|
|
41
|
+
return symbols
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 提取 Python 文件的顶层 def/class。 */
|
|
45
|
+
export function extractPySymbols(lines) {
|
|
46
|
+
const symbols = []
|
|
47
|
+
const re = /^(?:async\s+)?(?:def|class)\s+(\w+)/gm
|
|
48
|
+
const text = lines.join("\n")
|
|
49
|
+
let m
|
|
50
|
+
while ((m = re.exec(text))) {
|
|
51
|
+
symbols.push({ name: m[1], line: text.slice(0, m.index).split("\n").length, kind: text[m.index] === "c" ? "class" : "function" })
|
|
52
|
+
}
|
|
53
|
+
return symbols
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 将一个文件拆成代码块。小文件整文件一块;大文件按符号切分,符号间的内容并入前一个符号块。
|
|
58
|
+
* 每个块会额外带上符号前的 JSDoc / docstring 注释,提升搜索质量。
|
|
59
|
+
*/
|
|
60
|
+
export function chunkCode(lines, filepath) {
|
|
61
|
+
const ext = filepath.slice(filepath.lastIndexOf(".")).toLowerCase()
|
|
62
|
+
const chunks = []
|
|
63
|
+
|
|
64
|
+
if (lines.length <= BIG_FILE_LINES) {
|
|
65
|
+
const doc = extractLeadingDoc(lines, 1, ext)
|
|
66
|
+
const content = (doc ? doc + "\n" : "") + lines.join("\n").trimEnd()
|
|
67
|
+
chunks.push({ name: filepath, line_start: 1, line_end: lines.length, content })
|
|
68
|
+
return chunks
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const symbols = ext === ".py" ? extractPySymbols(lines) : extractSymbols(lines, ext)
|
|
72
|
+
if (symbols.length <= 1) {
|
|
73
|
+
const doc = extractLeadingDoc(lines, 1, ext)
|
|
74
|
+
const content = (doc ? doc + "\n" : "") + lines.join("\n").trimEnd()
|
|
75
|
+
chunks.push({ name: filepath, line_start: 1, line_end: lines.length, content })
|
|
76
|
+
return chunks
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < symbols.length; i++) {
|
|
80
|
+
const sym = symbols[i]
|
|
81
|
+
const start = sym.line
|
|
82
|
+
const end = i + 1 < symbols.length ? symbols[i + 1].line - 1 : lines.length
|
|
83
|
+
if (start > end) continue
|
|
84
|
+
const doc = extractLeadingDoc(lines, start, ext)
|
|
85
|
+
const body = lines.slice(start - 1, end).join("\n").trimEnd()
|
|
86
|
+
const content = (doc ? doc + "\n" : "") + body
|
|
87
|
+
if (!content) continue
|
|
88
|
+
chunks.push({ name: `${filepath}:${sym.name}`, line_start: start, line_end: end, content })
|
|
89
|
+
}
|
|
90
|
+
return chunks
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 提取指定行之前的 JSDoc / docstring 注释。
|
|
95
|
+
* JS/TS: 向前扫描 /** ... */ 或 // 连续注释行
|
|
96
|
+
* Python: 符号定义行的下一行开始找 """...""" docstring
|
|
97
|
+
*/
|
|
98
|
+
export function extractLeadingDoc(lines, lineNum, ext) {
|
|
99
|
+
if (ext === ".py") {
|
|
100
|
+
if (lineNum >= lines.length) return ""
|
|
101
|
+
const next = lines[lineNum]
|
|
102
|
+
const m = next?.match(/^\s*"""(.+?)"""\s*$/)
|
|
103
|
+
if (m) return m[1].trim()
|
|
104
|
+
if (/^\s*"""\s*$/.test(next)) {
|
|
105
|
+
const parts = []
|
|
106
|
+
for (let i = lineNum + 1; i < lines.length && i < lineNum + 8; i++) {
|
|
107
|
+
if (/^\s*"""\s*$/.test(lines[i])) break
|
|
108
|
+
parts.push(lines[i].trim())
|
|
109
|
+
}
|
|
110
|
+
const text = parts.join(" ").trim()
|
|
111
|
+
return text.length > 0 && text.length < 300 ? text : ""
|
|
112
|
+
}
|
|
113
|
+
return ""
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const jsish = new Set([".mjs", ".js", ".ts", ".jsx", ".tsx"])
|
|
117
|
+
if (!jsish.has(ext)) return ""
|
|
118
|
+
|
|
119
|
+
const parts = []
|
|
120
|
+
let i = lineNum - 2
|
|
121
|
+
if (i >= 0 && /^\s*\*\/\s*$/.test(lines[i])) {
|
|
122
|
+
while (i >= 0) {
|
|
123
|
+
const line = lines[i].trim()
|
|
124
|
+
if (/^\s*\/\*\*/.test(line)) {
|
|
125
|
+
parts.unshift(line.replace(/^\s*\/\*\*\s*/, "").replace(/\s*\*\/\s*$/, "").trim())
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
parts.unshift(line.replace(/^\s*\*\s?/, "").trim())
|
|
129
|
+
i--
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
while (i >= 0 && /^\s*\/\//.test(lines[i])) {
|
|
133
|
+
parts.unshift(lines[i].replace(/^\s*\/\/\s*/, "").trim())
|
|
134
|
+
i--
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const text = parts.join(" ").trim()
|
|
139
|
+
return text.length > 0 && text.length < 300 ? text : ""
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 将控制权交还给事件循环一个 tick(让键盘输入有机会被处理) */
|
|
143
|
+
export function yieldTick() {
|
|
144
|
+
return new Promise((r) => setTimeout(r, 0))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 单文件入索引:删除旧块 → 分块 → 插入新块 */
|
|
148
|
+
export function _upsertCodeFile(memory, origin, rel, lines, lang, mtimeMs) {
|
|
149
|
+
const chunks = chunkCode(lines, rel)
|
|
150
|
+
memory.db.exec("BEGIN")
|
|
151
|
+
try {
|
|
152
|
+
memory.db.prepare(`DELETE FROM code_chunks WHERE origin = ? AND path = ?`).run(origin, rel)
|
|
153
|
+
const insert = memory.db.prepare(`
|
|
154
|
+
INSERT INTO code_chunks (origin, path, language, chunk_type, symbol_name, content, line_start, line_end, mtime_ms, seg_content)
|
|
155
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
156
|
+
`)
|
|
157
|
+
for (const c of chunks) {
|
|
158
|
+
const isFile = c.name === rel
|
|
159
|
+
insert.run(origin, rel, lang, isFile ? "file" : "symbol", isFile ? "" : c.name.slice(rel.length + 1), c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
160
|
+
}
|
|
161
|
+
memory.db.exec("COMMIT")
|
|
162
|
+
} catch (e) {
|
|
163
|
+
memory.db.exec("ROLLBACK")
|
|
164
|
+
throw e
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 按 ## 标题切分 markdown 文件。每个 ## section 独立入索引,
|
|
170
|
+
* 标题路径做 heading(如 "README.md > 部署 > Docker"),方便检索定位。
|
|
171
|
+
*/
|
|
172
|
+
export function chunkMarkdown(lines, filepath) {
|
|
173
|
+
const chunks = []
|
|
174
|
+
let start = 1
|
|
175
|
+
let heading = filepath
|
|
176
|
+
|
|
177
|
+
for (let i = 0; i < lines.length; i++) {
|
|
178
|
+
const m = lines[i].match(/^(#{1,4})\s+(.+)/)
|
|
179
|
+
if (m) {
|
|
180
|
+
if (i > start) {
|
|
181
|
+
chunks.push({ heading, line_start: start, line_end: i, content: lines.slice(start - 1, i).join("\n").trimEnd() })
|
|
182
|
+
}
|
|
183
|
+
heading = `${filepath} > ${m[2].trim()}`
|
|
184
|
+
start = i + 1
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (start <= lines.length) {
|
|
188
|
+
chunks.push({ heading, line_start: start, line_end: lines.length, content: lines.slice(start - 1).join("\n").trimEnd() })
|
|
189
|
+
}
|
|
190
|
+
return chunks.filter((c) => c.content)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function _upsertDocFile(memory, origin, rel, lines, mtimeMs) {
|
|
194
|
+
const chunks = chunkMarkdown(lines, rel)
|
|
195
|
+
const lang = rel.endsWith(".rst") ? "rst" : rel.endsWith(".adoc") ? "asciidoc" : rel.endsWith(".txt") ? "text" : "markdown"
|
|
196
|
+
memory.db.exec("BEGIN")
|
|
197
|
+
try {
|
|
198
|
+
memory.db.prepare(`DELETE FROM doc_chunks WHERE origin = ? AND path = ?`).run(origin, rel)
|
|
199
|
+
const insert = memory.db.prepare(`
|
|
200
|
+
INSERT INTO doc_chunks (origin, path, language, heading, content, line_start, line_end, mtime_ms, seg_content)
|
|
201
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
202
|
+
`)
|
|
203
|
+
for (const c of chunks) {
|
|
204
|
+
insert.run(origin, rel, lang, c.heading, c.content, c.line_start, c.line_end, mtimeMs, segmentCJK(c.content))
|
|
205
|
+
}
|
|
206
|
+
memory.db.exec("COMMIT")
|
|
207
|
+
} catch (e) {
|
|
208
|
+
memory.db.exec("ROLLBACK")
|
|
209
|
+
throw e
|
|
210
|
+
}
|
|
211
|
+
}
|