thincoder 0.9.0 → 0.11.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 +6 -1
- package/package.json +1 -1
- package/src/cli/make-agent.mjs +7 -0
- package/src/config.mjs +46 -19
- package/src/context.mjs +18 -0
- package/src/prompts/coder.md +5 -2
- package/src/prompts/discipline.md +8 -5
- package/src/prompts/system.md +8 -5
- package/src/provider/anthropic.mjs +190 -0
- package/src/provider/core.mjs +36 -4
- package/src/provider/google.mjs +197 -0
- package/src/proxy.mjs +236 -0
- package/src/tools/codemode.mjs +178 -0
- package/src/tools/fetch.md +2 -1
- package/src/tools/git.mjs +120 -154
- package/src/tools/index.mjs +11 -9
- package/src/tools/linter.mjs +46 -32
- package/src/tools/lsp.mjs +317 -0
- package/src/tools/web.mjs +103 -82
- package/src/tools/websearch.md +5 -3
- package/src/tui/agent-turn.mjs +12 -13
- package/src/tui/cmd-advisor.mjs +29 -41
- package/src/tui/cmd-clear.mjs +11 -17
- package/src/tui/cmd-config.mjs +226 -142
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-fold.mjs +2 -3
- package/src/tui/cmd-goal.mjs +58 -27
- package/src/tui/cmd-help.mjs +3 -1
- package/src/tui/cmd-mcp.mjs +178 -142
- package/src/tui/cmd-model.mjs +15 -4
- package/src/tui/cmd-new.mjs +7 -13
- package/src/tui/cmd-restore.mjs +12 -16
- package/src/tui/cmd-session.mjs +28 -32
- package/src/tui/cmd-think.mjs +75 -50
- package/src/tui/cmd-undo.mjs +19 -23
- package/src/tui/cmd-upgrade.mjs +22 -26
- package/src/tui/index.mjs +64 -38
- package/src/tui/key-handler.mjs +48 -18
- package/src/tui/layout.mjs +12 -2
- package/src/tui/pickers.mjs +151 -182
- package/src/tui/render-frame.mjs +29 -11
- package/src/tui/slash-commands.mjs +26 -16
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/google.mjs — Google Gemini API transport
|
|
3
|
+
* Endpoint: POST https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent
|
|
4
|
+
* Docs: https://ai.google.dev/gemini-api/docs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { proxyFetch } from "../proxy.mjs"
|
|
8
|
+
|
|
9
|
+
/** Convert OpenAI-format tools to Gemini format */
|
|
10
|
+
export function normalizeTools(tools) {
|
|
11
|
+
if (!tools?.length) return null
|
|
12
|
+
return [{
|
|
13
|
+
functionDeclarations: tools.map((t) => ({
|
|
14
|
+
name: t.function.name,
|
|
15
|
+
description: t.function.description || "",
|
|
16
|
+
parameters: t.function.parameters || { type: "object", properties: {} },
|
|
17
|
+
})),
|
|
18
|
+
}]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert OpenAI-format messages to Gemini contents array.
|
|
23
|
+
* Gemini: [{ role: "user"|"model", parts: [{ text }] }]
|
|
24
|
+
* system → systemInstruction (top-level in request body)
|
|
25
|
+
*/
|
|
26
|
+
function convertMessages(messages) {
|
|
27
|
+
const contents = []
|
|
28
|
+
for (const m of messages) {
|
|
29
|
+
const role = m.role === "assistant" ? "model" : "user"
|
|
30
|
+
if (role === "system") continue
|
|
31
|
+
|
|
32
|
+
const parts = []
|
|
33
|
+
if (typeof m.content === "string") {
|
|
34
|
+
parts.push({ text: m.content })
|
|
35
|
+
} else if (Array.isArray(m.content)) {
|
|
36
|
+
for (const part of m.content) {
|
|
37
|
+
if (part.type === "text") parts.push({ text: part.text })
|
|
38
|
+
else if (part.type === "image_url") {
|
|
39
|
+
const url = part.image_url?.url || ""
|
|
40
|
+
const mimeMatch = url.match(/^data:([^;]+);base64,(.+)$/)
|
|
41
|
+
if (mimeMatch) {
|
|
42
|
+
parts.push({ inlineData: { mimeType: mimeMatch[1], data: mimeMatch[2] } })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (parts.length === 0) continue
|
|
48
|
+
|
|
49
|
+
// Gemini doesn't allow consecutive same-role messages; merge
|
|
50
|
+
const last = contents[contents.length - 1]
|
|
51
|
+
if (last?.role === role) {
|
|
52
|
+
last.parts.push(...parts)
|
|
53
|
+
} else {
|
|
54
|
+
contents.push({ role, parts })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return contents
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build and send a Gemini chat request. Returns the same shape as core.mjs chat. */
|
|
61
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, signal }) {
|
|
62
|
+
const systemMessages = messages.filter((m) => m.role === "system")
|
|
63
|
+
const contents = convertMessages(messages)
|
|
64
|
+
|
|
65
|
+
const body = {
|
|
66
|
+
contents,
|
|
67
|
+
generationConfig: {
|
|
68
|
+
...(provider.temperature != null ? { temperature: provider.temperature } : {}),
|
|
69
|
+
...(provider.maxTokens ? { maxOutputTokens: provider.maxTokens } : {}),
|
|
70
|
+
},
|
|
71
|
+
safetySettings: [
|
|
72
|
+
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" },
|
|
73
|
+
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },
|
|
74
|
+
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE" },
|
|
75
|
+
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE" },
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
if (systemMessages.length > 0) {
|
|
79
|
+
body.systemInstruction = {
|
|
80
|
+
parts: [{ text: systemMessages.map((m) => m.content).join("\n\n") }],
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (tools?.length) body.tools = tools
|
|
84
|
+
|
|
85
|
+
const FETCH_TIMEOUT_MS = 600_000
|
|
86
|
+
// Gemini uses API key as query parameter
|
|
87
|
+
const url = `${provider.baseURL}/models/${provider.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(provider.apiKey)}`
|
|
88
|
+
|
|
89
|
+
if (signal?.aborted) throw Object.assign(new DOMException("Aborted", "AbortError"), { reason: signal.reason })
|
|
90
|
+
|
|
91
|
+
const response = await proxyFetch(url, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify(body),
|
|
95
|
+
signal: signal
|
|
96
|
+
? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
|
|
97
|
+
: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
98
|
+
}, provider.proxyUri)
|
|
99
|
+
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
const text = await response.text().catch(() => "")
|
|
102
|
+
throw new Error(`Gemini API error ${response.status}: ${text}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await parseGeminiStream(response, { onToken, onReasoning, signal })
|
|
106
|
+
|
|
107
|
+
const usage = result.usage
|
|
108
|
+
if (usage) {
|
|
109
|
+
return {
|
|
110
|
+
content: result.content,
|
|
111
|
+
reasoning: result.reasoning,
|
|
112
|
+
usage: {
|
|
113
|
+
prompt_tokens: usage.prompt_tokens ?? 0,
|
|
114
|
+
completion_tokens: usage.completion_tokens ?? 0,
|
|
115
|
+
total_tokens: usage.total_tokens ?? 0,
|
|
116
|
+
},
|
|
117
|
+
toolCalls: result.toolCalls,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { content: result.content, reasoning: result.reasoning, toolCalls: result.toolCalls }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse Gemini SSE stream.
|
|
126
|
+
* Format: data: {...}\n\n (each line is a complete JSON object)
|
|
127
|
+
*/
|
|
128
|
+
async function parseGeminiStream(response, { onToken, onReasoning, signal }) {
|
|
129
|
+
const result = { content: "", reasoning: "", toolCalls: [], usage: null }
|
|
130
|
+
const decoder = new TextDecoder()
|
|
131
|
+
let buffer = ""
|
|
132
|
+
|
|
133
|
+
const processData = (data) => {
|
|
134
|
+
let json
|
|
135
|
+
try { json = JSON.parse(data) } catch { return }
|
|
136
|
+
if (!json) return
|
|
137
|
+
|
|
138
|
+
if (json.usageMetadata) {
|
|
139
|
+
result.usage = {
|
|
140
|
+
prompt_tokens: json.usageMetadata.promptTokenCount || 0,
|
|
141
|
+
completion_tokens: json.usageMetadata.candidatesTokenCount || 0,
|
|
142
|
+
total_tokens: json.usageMetadata.totalTokenCount || 0,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const candidate = json.candidates?.[0]
|
|
147
|
+
if (!candidate) return
|
|
148
|
+
|
|
149
|
+
const parts = candidate.content?.parts || []
|
|
150
|
+
for (const part of parts) {
|
|
151
|
+
if (part.thought === true && part.text) {
|
|
152
|
+
result.reasoning += part.text
|
|
153
|
+
onReasoning?.(part.text)
|
|
154
|
+
} else if (part.text) {
|
|
155
|
+
result.content += part.text
|
|
156
|
+
onToken?.(part.text)
|
|
157
|
+
} else if (part.functionCall) {
|
|
158
|
+
const existing = result.toolCalls.find((tc) => tc.name === part.functionCall.name)
|
|
159
|
+
if (!existing) {
|
|
160
|
+
result.toolCalls.push({
|
|
161
|
+
id: part.functionCall.name + "_" + result.toolCalls.length,
|
|
162
|
+
name: part.functionCall.name,
|
|
163
|
+
arguments: JSON.stringify(part.functionCall.args || {}),
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!response.body) throw new Error("No stream response body")
|
|
171
|
+
for await (const chunk of response.body) {
|
|
172
|
+
if (signal?.aborted) {
|
|
173
|
+
const e = new DOMException("Aborted", "AbortError")
|
|
174
|
+
e.reason = signal.reason
|
|
175
|
+
throw e
|
|
176
|
+
}
|
|
177
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
178
|
+
const lines = buffer.split("\n")
|
|
179
|
+
buffer = lines.pop()
|
|
180
|
+
|
|
181
|
+
for (const line of lines) {
|
|
182
|
+
if (!line.startsWith("data:")) continue
|
|
183
|
+
const data = line.slice(5).trim()
|
|
184
|
+
if (!data || data === "[DONE]") continue
|
|
185
|
+
processData(data)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
buffer += decoder.decode()
|
|
189
|
+
for (const line of buffer.split("\n")) {
|
|
190
|
+
if (!line.startsWith("data:")) continue
|
|
191
|
+
const data = line.slice(5).trim()
|
|
192
|
+
if (!data || data === "[DONE]") continue
|
|
193
|
+
processData(data)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
}
|
package/src/proxy.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy.mjs — Shared proxy tunnel for websearch, fetch, and provider calls.
|
|
3
|
+
* Zero dependencies: Node built-ins only (net, tls, http, url).
|
|
4
|
+
*/
|
|
5
|
+
import { connect } from "node:net";
|
|
6
|
+
import { connect as tlsConnect } from "node:tls";
|
|
7
|
+
import { PassThrough } from "node:stream";
|
|
8
|
+
import { URL } from "node:url";
|
|
9
|
+
|
|
10
|
+
const FETCH_TIMEOUT = 15_000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve proxy URI.
|
|
14
|
+
* New format: { proxy: { uri: "http://host:port", web: true, model: false } }
|
|
15
|
+
* Old format: { proxy: "http://host:port" } — backwards compatible, web=true model=false
|
|
16
|
+
* Env vars: HTTPS_PROXY, HTTP_PROXY, ALL_PROXY
|
|
17
|
+
*
|
|
18
|
+
* @returns {{ uri: string|null, web: boolean, model: boolean }}
|
|
19
|
+
*/
|
|
20
|
+
export function resolveProxyConfig(ctx) {
|
|
21
|
+
const cfgProxy = ctx?.agent?.config?.proxy
|
|
22
|
+
if (!cfgProxy) {
|
|
23
|
+
const uri = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY || null
|
|
24
|
+
return { uri, web: !!uri, model: false }
|
|
25
|
+
}
|
|
26
|
+
if (typeof cfgProxy === "string") {
|
|
27
|
+
// Backward compat: bare string → web only
|
|
28
|
+
return { uri: cfgProxy, web: true, model: false }
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
uri: cfgProxy.uri || cfgProxy.url || null,
|
|
32
|
+
web: cfgProxy.web !== false,
|
|
33
|
+
model: cfgProxy.model === true,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Convenience: resolve proxy URI for web tools (websearch/fetch) */
|
|
38
|
+
export function resolveWebProxy(ctx) {
|
|
39
|
+
const cfg = resolveProxyConfig(ctx)
|
|
40
|
+
return (cfg.uri && cfg.web) ? cfg.uri : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Inject the resolved proxy URI into each provider as provider.proxyUri
|
|
45
|
+
* (consumed by chat() in provider/core.mjs).
|
|
46
|
+
* Model 请求走代理需要双重开启:per-provider `proxy: true` 且全局 `config.proxy.model === true`(默认关)。
|
|
47
|
+
*/
|
|
48
|
+
export function injectProxy(providers, config) {
|
|
49
|
+
const { uri, model } = resolveProxyConfig({ agent: { config } })
|
|
50
|
+
for (const p of providers ?? []) {
|
|
51
|
+
p.proxyUri = p.proxy && model ? (uri ?? undefined) : undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function abortError(signal) {
|
|
56
|
+
const e = new DOMException("The operation was aborted", "AbortError")
|
|
57
|
+
e.reason = signal?.reason
|
|
58
|
+
return e
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 在已建立的 socket 上发 HTTP 请求,响应头到齐即 resolve(流式)。
|
|
63
|
+
* 返回 Response-like: { ok, status, headers: Headers, body: PassThrough(异步迭代), text(): Promise<string> }
|
|
64
|
+
* body 边收边吐(SSE 流式消费方可逐 chunk 读取);text() 消费流到底(非流式调用方用)。
|
|
65
|
+
* opts.signal 全程有效:abort 即 destroy socket 并 reject/终止流。
|
|
66
|
+
* absoluteForm: 请求行发绝对 URI(http:// 目标的经典代理转发用),默认发 origin-form。
|
|
67
|
+
* 导出供测试(裸 socket,无需 TLS/CONNECT);生产路径走 tunnelHttps / proxyFetch。
|
|
68
|
+
*/
|
|
69
|
+
export function streamHttpResponse(sock, urlStr, opts = {}, timeout = FETCH_TIMEOUT, absoluteForm = false) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const target = new URL(urlStr)
|
|
72
|
+
const method = opts.method ?? "GET"
|
|
73
|
+
const headers = opts.headers ?? {}
|
|
74
|
+
const signal = opts.signal
|
|
75
|
+
|
|
76
|
+
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal)) }
|
|
77
|
+
|
|
78
|
+
const body = new PassThrough()
|
|
79
|
+
let settled = false
|
|
80
|
+
let headerBuf = ""
|
|
81
|
+
|
|
82
|
+
const timer = setTimeout(() => fail(new Error("Response timeout")), timeout)
|
|
83
|
+
const onAbort = () => { sock.destroy(); fail(abortError(signal)) }
|
|
84
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
85
|
+
|
|
86
|
+
function cleanup() {
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
signal?.removeEventListener("abort", onAbort)
|
|
89
|
+
}
|
|
90
|
+
/** 头部阶段失败 reject;resolve 后失败则终止 body 流(for-await 抛出,不挂起) */
|
|
91
|
+
function fail(err) {
|
|
92
|
+
cleanup()
|
|
93
|
+
if (!settled) { settled = true; reject(err) }
|
|
94
|
+
else body.destroy(err)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
sock.on("data", (d) => {
|
|
98
|
+
if (settled) return // 理论上不会发生(settle 后摘掉本监听器),防御
|
|
99
|
+
headerBuf += d.toString("utf8")
|
|
100
|
+
const idx = headerBuf.indexOf("\r\n\r\n")
|
|
101
|
+
if (idx < 0) return
|
|
102
|
+
|
|
103
|
+
const headerText = headerBuf.slice(0, idx)
|
|
104
|
+
const statusMatch = headerText.match(/^HTTP\/\d\.\d (\d+)/)
|
|
105
|
+
const status = statusMatch ? Number(statusMatch[1]) : 502
|
|
106
|
+
const respHeaders = {}
|
|
107
|
+
for (const line of headerText.split("\r\n").slice(1)) {
|
|
108
|
+
const ci = line.indexOf(":")
|
|
109
|
+
if (ci > 0) respHeaders[line.slice(0, ci).trim().toLowerCase()] = line.slice(ci + 1).trim()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 头到齐:摘掉头阶段监听,剩余字节推入 body,后续数据直接 pipe
|
|
113
|
+
sock.removeAllListeners("data")
|
|
114
|
+
settled = true
|
|
115
|
+
cleanup()
|
|
116
|
+
const remaining = headerBuf.slice(idx + 4)
|
|
117
|
+
if (remaining) body.write(Buffer.from(remaining, "utf8"))
|
|
118
|
+
sock.pipe(body)
|
|
119
|
+
// body 结束后才移除 abort 监听(流式中途 abort 要能终止流)
|
|
120
|
+
body.on("close", () => signal?.removeEventListener("abort", onAbort))
|
|
121
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
122
|
+
|
|
123
|
+
resolve({
|
|
124
|
+
ok: status >= 200 && status < 400,
|
|
125
|
+
status,
|
|
126
|
+
headers: new Headers(respHeaders),
|
|
127
|
+
body,
|
|
128
|
+
text: async () => {
|
|
129
|
+
const chunks = []
|
|
130
|
+
for await (const c of body) chunks.push(c)
|
|
131
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
sock.on("close", () => {
|
|
136
|
+
if (!settled) fail(new Error("Connection closed before response"))
|
|
137
|
+
else body.end()
|
|
138
|
+
})
|
|
139
|
+
// 头部阶段的传输错误统一包装为稳定契约(对端 RST 会抛原生 ECONNRESET,调用方难判别);
|
|
140
|
+
// resolve 后的 body 阶段保留原始错误终止流
|
|
141
|
+
sock.on("error", (e) => {
|
|
142
|
+
if (!settled) fail(new Error(`Connection closed before response (${e.code ?? e.message})`))
|
|
143
|
+
else fail(e)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// 写请求(absoluteForm:代理转发时请求行为绝对 URI)
|
|
147
|
+
const requestTarget = absoluteForm ? urlStr : `${target.pathname}${target.search}`
|
|
148
|
+
const lines = [`${method} ${requestTarget} HTTP/1.1`]
|
|
149
|
+
for (const [k, v] of Object.entries({ ...headers, Host: target.hostname })) lines.push(`${k}: ${v}`)
|
|
150
|
+
lines.push("Connection: close", "", "")
|
|
151
|
+
sock.write(lines.join("\r\n"))
|
|
152
|
+
if (opts.body) sock.write(opts.body)
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* HTTPS request through HTTP CONNECT proxy tunnel.
|
|
158
|
+
* CONNECT + TLS 建立后交给 streamHttpResponse — 响应头到齐即 resolve,body 为流式。
|
|
159
|
+
*/
|
|
160
|
+
export function tunnelHttps(urlStr, opts, proxyUri, timeout = FETCH_TIMEOUT) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const target = new URL(urlStr)
|
|
163
|
+
const proxy = new URL(proxyUri)
|
|
164
|
+
const signal = opts?.signal
|
|
165
|
+
|
|
166
|
+
if (signal?.aborted) return reject(abortError(signal))
|
|
167
|
+
|
|
168
|
+
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
169
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
170
|
+
const onAbort = () => { sock.destroy(); reject(abortError(signal)) }
|
|
171
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
172
|
+
sock.on("connect", () => sock.write(`CONNECT ${target.hostname}:${target.port || 443} HTTP/1.1\r\nHost: ${target.hostname}\r\n\r\n`))
|
|
173
|
+
|
|
174
|
+
let buf = ""
|
|
175
|
+
sock.on("data", d => {
|
|
176
|
+
buf += d.toString()
|
|
177
|
+
const end = buf.indexOf("\r\n\r\n")
|
|
178
|
+
if (end < 0) return
|
|
179
|
+
const statusLine = buf.slice(0, end).split("\r\n")[0]
|
|
180
|
+
buf = buf.slice(end + 4)
|
|
181
|
+
if (!statusLine.includes("200")) { sock.destroy(); clearTimeout(timer); return reject(new Error(`Proxy CONNECT: ${statusLine}`)) }
|
|
182
|
+
sock.removeAllListeners("data"); clearTimeout(timer)
|
|
183
|
+
|
|
184
|
+
const tlsSock = tlsConnect({ socket: sock, servername: target.hostname, rejectUnauthorized: false, timeout })
|
|
185
|
+
if (buf) tlsSock.unshift(Buffer.from(buf))
|
|
186
|
+
tlsSock.on("secureConnect", () => {
|
|
187
|
+
// TLS 之后的请求/响应阶段:abort 交由 streamHttpResponse 接管
|
|
188
|
+
signal?.removeEventListener("abort", onAbort)
|
|
189
|
+
streamHttpResponse(tlsSock, urlStr, opts, timeout).then(resolve, reject)
|
|
190
|
+
})
|
|
191
|
+
tlsSock.on("error", e => { sock.destroy(); reject(e) })
|
|
192
|
+
})
|
|
193
|
+
sock.on("error", e => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) })
|
|
194
|
+
// 代理干净 FIN 关闭(无 error)时也要 reject,不能卡满超时
|
|
195
|
+
sock.on("close", () => { clearTimeout(timer); reject(new Error("Proxy connection closed before tunnel established")) })
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** TCP 直连代理(http:// 目标的经典转发用):超时/abort/对端关闭都有稳定 reject */
|
|
200
|
+
function tcpConnectProxy(proxyUri, signal, timeout) {
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
const proxy = new URL(proxyUri)
|
|
203
|
+
const sock = connect({ host: proxy.hostname, port: Number(proxy.port) || 3128 })
|
|
204
|
+
if (signal?.aborted) { sock.destroy(); return reject(abortError(signal)) }
|
|
205
|
+
const onAbort = () => sock.destroy()
|
|
206
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
207
|
+
const timer = setTimeout(() => { sock.destroy(); reject(new Error("Proxy CONNECT timeout")) }, timeout)
|
|
208
|
+
const onError = (e) => { clearTimeout(timer); reject(new Error(`Proxy CONNECT failed (${e.code ?? e.message})`)) }
|
|
209
|
+
const onClose = () => {
|
|
210
|
+
clearTimeout(timer)
|
|
211
|
+
reject(signal?.aborted ? abortError(signal) : new Error("Proxy connection closed before tunnel established"))
|
|
212
|
+
}
|
|
213
|
+
sock.once("connect", () => {
|
|
214
|
+
clearTimeout(timer)
|
|
215
|
+
signal?.removeEventListener("abort", onAbort)
|
|
216
|
+
sock.removeListener("error", onError)
|
|
217
|
+
sock.removeListener("close", onClose)
|
|
218
|
+
resolve(sock)
|
|
219
|
+
})
|
|
220
|
+
sock.once("error", onError)
|
|
221
|
+
sock.once("close", onClose)
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Generic fetch with proxy support.
|
|
227
|
+
* No proxy → native fetch. Proxy + HTTPS → CONNECT tunnel. Proxy + HTTP → 经典代理转发(绝对 URI 请求行)。
|
|
228
|
+
*/
|
|
229
|
+
export async function proxyFetch(urlStr, opts, proxyUri) {
|
|
230
|
+
if (!proxyUri) return globalThis.fetch(urlStr, opts)
|
|
231
|
+
const target = new URL(urlStr)
|
|
232
|
+
if (target.protocol === "https:") return tunnelHttps(urlStr, opts, proxyUri)
|
|
233
|
+
// http:// 目标:TCP 直连代理,请求行发绝对 URI(GET http://host/path HTTP/1.1)
|
|
234
|
+
const sock = await tcpConnectProxy(proxyUri, opts?.signal, FETCH_TIMEOUT)
|
|
235
|
+
return streamHttpResponse(sock, urlStr, opts, FETCH_TIMEOUT, true)
|
|
236
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/codemode.mjs — CodeMode: sandboxed JS execution tool
|
|
3
|
+
*
|
|
4
|
+
* Gives the model an `execute` tool backed by Node.js vm.Script.runInNewContext.
|
|
5
|
+
* Multiple tool calls can be composed into a single script, reducing API round-trips
|
|
6
|
+
* and keeping large intermediate results out of context.
|
|
7
|
+
*
|
|
8
|
+
* Sandbox API (all sync, no callbacks):
|
|
9
|
+
* readFile(path) — read a file relative to cwd, return string
|
|
10
|
+
* writeFile(path, c) — write content to a file (auto-creates parent dirs)
|
|
11
|
+
* glob(pattern) — return array of matching paths
|
|
12
|
+
* grep(pattern, file) — return array of matching lines
|
|
13
|
+
* log(...args) — append to output buffer
|
|
14
|
+
* fetch(url) — HTTP GET, return string (SSRF-protected)
|
|
15
|
+
*
|
|
16
|
+
* Not available: require, import, process, child_process, setTimeout, any Node API.
|
|
17
|
+
*
|
|
18
|
+
* Limits:
|
|
19
|
+
* timeout: 30s (configurable via timeoutMs param)
|
|
20
|
+
* maxOutput: 50000 bytes
|
|
21
|
+
* maxScriptSize: 50000 bytes
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Script, createContext } from "node:vm"
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
|
|
26
|
+
import { join, dirname, relative, resolve } from "node:path"
|
|
27
|
+
import { globToRegex, normalizeEOL } from "./shared.mjs"
|
|
28
|
+
|
|
29
|
+
const MAX_OUTPUT = 50_000
|
|
30
|
+
const MAX_SCRIPT = 50_000
|
|
31
|
+
const DEFAULT_TIMEOUT = 30_000
|
|
32
|
+
|
|
33
|
+
/** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout */
|
|
34
|
+
async function sandboxFetch(url) {
|
|
35
|
+
const parsed = new URL(url)
|
|
36
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
37
|
+
throw new Error(`CodeMode fetch: protocol not allowed: ${parsed.protocol}`)
|
|
38
|
+
}
|
|
39
|
+
// Block private/internal IPs
|
|
40
|
+
const hostname = parsed.hostname.toLowerCase()
|
|
41
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ||
|
|
42
|
+
hostname.startsWith("192.168.") || hostname.startsWith("10.") ||
|
|
43
|
+
hostname.startsWith("172.16.") || hostname.startsWith("172.17.") ||
|
|
44
|
+
hostname.startsWith("172.18.") || hostname.startsWith("172.19.") ||
|
|
45
|
+
hostname.startsWith("172.20.") || hostname.startsWith("172.21.") ||
|
|
46
|
+
hostname.startsWith("172.22.") || hostname.startsWith("172.23.") ||
|
|
47
|
+
hostname.startsWith("172.24.") || hostname.startsWith("172.25.") ||
|
|
48
|
+
hostname.startsWith("172.26.") || hostname.startsWith("172.27.") ||
|
|
49
|
+
hostname.startsWith("172.28.") || hostname.startsWith("172.29.") ||
|
|
50
|
+
hostname.startsWith("172.30.") || hostname.startsWith("172.31.") ||
|
|
51
|
+
hostname === "0.0.0.0" || hostname.endsWith(".local")) {
|
|
52
|
+
throw new Error(`CodeMode fetch: private/internal host not allowed: ${hostname}`)
|
|
53
|
+
}
|
|
54
|
+
const ctrl = new AbortController()
|
|
55
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000)
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(url, { signal: ctrl.signal })
|
|
58
|
+
const text = await res.text()
|
|
59
|
+
return text.slice(0, 100_000)
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const codeModeTool = {
|
|
66
|
+
name: "execute",
|
|
67
|
+
description:
|
|
68
|
+
"Execute sandboxed JavaScript code. Use this to compose multiple file operations into one call — " +
|
|
69
|
+
"read, write, glob, grep, and log results. No network or system access. Max 30s timeout, 50KB output.",
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
code: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "JavaScript code to execute in the sandbox. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args).",
|
|
76
|
+
},
|
|
77
|
+
timeoutMs: {
|
|
78
|
+
type: "integer",
|
|
79
|
+
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ["code"],
|
|
83
|
+
},
|
|
84
|
+
readonly: false,
|
|
85
|
+
|
|
86
|
+
async execute(args, ctx) {
|
|
87
|
+
const cwd = ctx.cwd
|
|
88
|
+
const code = args.code ?? ""
|
|
89
|
+
|
|
90
|
+
if (code.length > MAX_SCRIPT) {
|
|
91
|
+
return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const output = []
|
|
95
|
+
const timeoutMs = Math.min(args.timeoutMs ?? DEFAULT_TIMEOUT, 60_000)
|
|
96
|
+
|
|
97
|
+
// File path guard: ensure paths are within cwd
|
|
98
|
+
function safePath(p) {
|
|
99
|
+
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
100
|
+
// Normalize and resolve
|
|
101
|
+
const abs = resolve(cwd, p)
|
|
102
|
+
// Check containment
|
|
103
|
+
const rel = relative(cwd, abs)
|
|
104
|
+
if (rel.startsWith("..") || (rel.includes("..") && process.platform === "win32")) {
|
|
105
|
+
throw new Error(`Path traversal denied: ${p}`)
|
|
106
|
+
}
|
|
107
|
+
return abs
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sandbox = createContext({
|
|
111
|
+
readFile: (p) => {
|
|
112
|
+
const abs = safePath(p)
|
|
113
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
|
|
114
|
+
const st = statSync(abs)
|
|
115
|
+
if (st.size > 5_000_000) throw new Error(`File too large: ${p} (${Math.round(st.size / 1000000)}MB)`)
|
|
116
|
+
return normalizeEOL(readFileSync(abs, "utf8"))
|
|
117
|
+
},
|
|
118
|
+
writeFile: (p, content) => {
|
|
119
|
+
const abs = safePath(p)
|
|
120
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
121
|
+
writeFileSync(abs, String(content), "utf8")
|
|
122
|
+
},
|
|
123
|
+
glob: (pattern) => {
|
|
124
|
+
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
125
|
+
const regex = globToRegex(pattern)
|
|
126
|
+
const results = []
|
|
127
|
+
function walk(dir, rel) {
|
|
128
|
+
let entries
|
|
129
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
132
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
133
|
+
if (e.isDirectory()) { walk(join(dir, e.name), relPath) }
|
|
134
|
+
else if (regex.test(relPath)) results.push(relPath)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
walk(cwd, "")
|
|
138
|
+
return results.slice(0, 200)
|
|
139
|
+
},
|
|
140
|
+
grep: (pattern, file) => {
|
|
141
|
+
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
142
|
+
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
143
|
+
const abs = safePath(file)
|
|
144
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
145
|
+
const content = normalizeEOL(readFileSync(abs, "utf8"))
|
|
146
|
+
const regex = new RegExp(pattern)
|
|
147
|
+
const lines = content.split("\n")
|
|
148
|
+
const matches = []
|
|
149
|
+
for (let i = 0; i < lines.length; i++) {
|
|
150
|
+
if (regex.test(lines[i])) matches.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
151
|
+
}
|
|
152
|
+
return matches.slice(0, 100)
|
|
153
|
+
},
|
|
154
|
+
log: (...args) => {
|
|
155
|
+
const line = args.map((a) => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")
|
|
156
|
+
output.push(line)
|
|
157
|
+
if (output.join("\n").length > MAX_OUTPUT) {
|
|
158
|
+
output.push("... (output truncated)")
|
|
159
|
+
throw new Error("CodeMode output limit exceeded")
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
fetch: sandboxFetch,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const script = new Script(code, {
|
|
167
|
+
filename: "codemode.js",
|
|
168
|
+
timeout: timeoutMs,
|
|
169
|
+
})
|
|
170
|
+
script.runInContext(sandbox)
|
|
171
|
+
return output.join("\n") || "(no output)"
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const out = output.join("\n")
|
|
174
|
+
const prefix = out ? `${out}\n\n` : ""
|
|
175
|
+
return `${prefix}Error: ${err.message}`
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
package/src/tools/fetch.md
CHANGED
|
@@ -5,6 +5,7 @@ Parameters:
|
|
|
5
5
|
|
|
6
6
|
Notes:
|
|
7
7
|
- Follows redirects automatically
|
|
8
|
-
- Timeout:
|
|
8
|
+
- Timeout: 15 seconds
|
|
9
9
|
- HTML pages are converted to plain text (scripts, styles, navigation stripped)
|
|
10
10
|
- Non-HTML responses are returned as-is (truncated at ~50000 chars)
|
|
11
|
+
- Proxy support: set `"proxy": {"uri": "http://host:port", "web": true}` in config.json or `HTTPS_PROXY` env var
|