thincoder 0.7.7 → 0.8.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 +36 -13
- package/bin/thincoder.mjs +27 -346
- package/package.json +1 -1
- 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.md +1 -0
- 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 -1188
- 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 -2546
- 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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/core.mjs — LLM 调用核心
|
|
3
|
+
* chat / listModels / createProvider / requestWithRetry / readSSE
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { specForModel } from "../config.mjs"
|
|
7
|
+
import {
|
|
8
|
+
RETRYABLE_STATUS, MAX_RETRIES, MAX_CONTINUATIONS,
|
|
9
|
+
RATE_LIMIT_BACKOFF_MS, _rateHooks,
|
|
10
|
+
estimateRequestTokens, rateGate, recordRate,
|
|
11
|
+
} from "./rate.mjs"
|
|
12
|
+
|
|
13
|
+
export function createProvider(config) {
|
|
14
|
+
if (!config?.baseURL) throw new Error("provider config: baseURL is required")
|
|
15
|
+
if (!config?.apiKey) throw new Error("provider config: apiKey is required (config file or THINCODER_API_KEY env)")
|
|
16
|
+
if (!config?.model) throw new Error("provider config: model is required")
|
|
17
|
+
return {
|
|
18
|
+
baseURL: config.baseURL.replace(/\/+$/, ""),
|
|
19
|
+
apiKey: config.apiKey,
|
|
20
|
+
model: config.model,
|
|
21
|
+
maxTokens: config.maxTokens,
|
|
22
|
+
temperature: config.temperature,
|
|
23
|
+
thinking: config.thinking,
|
|
24
|
+
reasoningEffort: config.reasoningEffort,
|
|
25
|
+
tpm: config.tpm,
|
|
26
|
+
rpm: config.rpm,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal }) {
|
|
31
|
+
const spec = specForModel(provider.model)
|
|
32
|
+
const body = {
|
|
33
|
+
model: provider.model,
|
|
34
|
+
messages,
|
|
35
|
+
stream: true,
|
|
36
|
+
stream_options: { include_usage: true },
|
|
37
|
+
}
|
|
38
|
+
if (provider.maxTokens) body.max_tokens = provider.maxTokens
|
|
39
|
+
if (provider.temperature != null) {
|
|
40
|
+
let t = provider.temperature
|
|
41
|
+
if (spec.tempRange) {
|
|
42
|
+
t = Math.min(spec.tempRange[1], Math.max(spec.tempRange[0], t))
|
|
43
|
+
t = Math.round(t * 100) / 100
|
|
44
|
+
}
|
|
45
|
+
body.temperature = t
|
|
46
|
+
}
|
|
47
|
+
if (provider.thinking) body.thinking = provider.thinking
|
|
48
|
+
if (provider.reasoningEffort) {
|
|
49
|
+
if (spec.reasoningEffortEnum && !spec.reasoningEffortEnum.includes(provider.reasoningEffort)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`reasoning_effort "${provider.reasoningEffort}" not supported by model "${provider.model}"; ` +
|
|
52
|
+
`valid values: ${spec.reasoningEffortEnum.join(", ")}`
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
body.reasoning_effort = provider.reasoningEffort
|
|
56
|
+
}
|
|
57
|
+
if (tools?.length) body.tools = tools
|
|
58
|
+
|
|
59
|
+
const estimated = estimateRequestTokens(body)
|
|
60
|
+
await rateGate(provider, estimated, onWait, signal)
|
|
61
|
+
|
|
62
|
+
const response = await requestWithRetry(provider, body, signal, onWait)
|
|
63
|
+
const result = await readSSE(response, { onToken, onReasoning })
|
|
64
|
+
recordRate(provider, estimated, result.usage)
|
|
65
|
+
|
|
66
|
+
if (!spec.partialMode && !spec.prefixMode) return result
|
|
67
|
+
if (spec.prefixMode && !spec.partialMode && result.reasoning) return result
|
|
68
|
+
for (let n = 0; result.finishReason === "length" && result.content && n < MAX_CONTINUATIONS; n++) {
|
|
69
|
+
const continued = await chat(spec.prefixMode ? { ...provider, baseURL: betaBaseURL(provider.baseURL) } : provider, {
|
|
70
|
+
messages: [
|
|
71
|
+
...messages,
|
|
72
|
+
spec.partialMode
|
|
73
|
+
? {
|
|
74
|
+
role: "assistant",
|
|
75
|
+
content: result.content,
|
|
76
|
+
partial: true,
|
|
77
|
+
...(result.reasoning ? { reasoning_content: result.reasoning } : {}),
|
|
78
|
+
}
|
|
79
|
+
: { role: "assistant", content: result.content, prefix: true },
|
|
80
|
+
],
|
|
81
|
+
tools,
|
|
82
|
+
onToken,
|
|
83
|
+
onReasoning,
|
|
84
|
+
onWait,
|
|
85
|
+
signal,
|
|
86
|
+
})
|
|
87
|
+
result.content += continued.content
|
|
88
|
+
result.reasoning += continued.reasoning ?? ""
|
|
89
|
+
for (const tc of continued.toolCalls ?? []) {
|
|
90
|
+
const idx = tc.index ?? result.toolCalls.length
|
|
91
|
+
const s = (result.toolCalls[idx] ??= { id: "", name: "", arguments: "" })
|
|
92
|
+
if (tc.id) s.id = tc.id
|
|
93
|
+
s.name += tc.name ?? ""
|
|
94
|
+
s.arguments += tc.arguments ?? ""
|
|
95
|
+
}
|
|
96
|
+
result.finishReason = continued.finishReason
|
|
97
|
+
if (continued.usage) {
|
|
98
|
+
const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
|
|
99
|
+
result.usage = {
|
|
100
|
+
prompt_tokens: sum("prompt_tokens"),
|
|
101
|
+
completion_tokens: sum("completion_tokens"),
|
|
102
|
+
total_tokens: sum("total_tokens"),
|
|
103
|
+
prompt_cache_hit_tokens: sum("prompt_cache_hit_tokens"),
|
|
104
|
+
prompt_cache_miss_tokens: sum("prompt_cache_miss_tokens"),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function listModels(provider, { signal } = {}) {
|
|
112
|
+
const response = await fetch(`${provider.baseURL}/models`, {
|
|
113
|
+
headers: { Authorization: `Bearer ${provider.apiKey}` },
|
|
114
|
+
signal,
|
|
115
|
+
})
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
const text = await response.text().catch(() => "")
|
|
118
|
+
throw new Error(`GET /models failed ${response.status}: ${text}`)
|
|
119
|
+
}
|
|
120
|
+
const data = await response.json()
|
|
121
|
+
return (data.data ?? []).map((m) => m.id).filter(Boolean).sort()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function requestWithRetry(provider, body, signal, onWait) {
|
|
125
|
+
let lastError
|
|
126
|
+
let lastWas429 = false
|
|
127
|
+
let rateLimitHits = 0
|
|
128
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
129
|
+
if (attempt > 0 && !lastWas429) await _rateHooks.sleep(2 ** (attempt - 1) * 1000)
|
|
130
|
+
lastWas429 = false
|
|
131
|
+
|
|
132
|
+
let response
|
|
133
|
+
try {
|
|
134
|
+
response = await fetch(`${provider.baseURL}${provider.chatPath ?? "/chat/completions"}`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
"Content-Type": "application/json",
|
|
138
|
+
Authorization: `Bearer ${provider.apiKey}`,
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify(body),
|
|
141
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(120000)]) : AbortSignal.timeout(120000),
|
|
142
|
+
})
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error.name === "AbortError") throw error
|
|
145
|
+
lastError = error
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (response.ok) return response
|
|
150
|
+
|
|
151
|
+
const text = await response.text().catch(() => "")
|
|
152
|
+
const message = `LLM API error ${response.status}: ${text}`
|
|
153
|
+
if (isQuotaError(text)) throw new Error(message)
|
|
154
|
+
if (response.status === 429) {
|
|
155
|
+
const retryAfter = Number(response.headers.get("retry-after"))
|
|
156
|
+
const waitMs =
|
|
157
|
+
Number.isFinite(retryAfter) && retryAfter > 0
|
|
158
|
+
? retryAfter * 1000
|
|
159
|
+
: RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits++, RATE_LIMIT_BACKOFF_MS.length - 1)]
|
|
160
|
+
lastError = new Error(message)
|
|
161
|
+
lastWas429 = true
|
|
162
|
+
if (attempt < MAX_RETRIES) {
|
|
163
|
+
onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
|
|
164
|
+
await _rateHooks.sleep(waitMs)
|
|
165
|
+
}
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (RETRYABLE_STATUS.has(response.status)) {
|
|
169
|
+
lastError = new Error(message)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
throw new Error(message)
|
|
173
|
+
}
|
|
174
|
+
throw lastError
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isQuotaError(text) {
|
|
178
|
+
try {
|
|
179
|
+
const type = JSON.parse(text)?.error?.type
|
|
180
|
+
return typeof type === "string" && type.includes("quota")
|
|
181
|
+
} catch {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readSSE(response, { onToken, onReasoning }) {
|
|
187
|
+
const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
|
|
188
|
+
const decoder = new TextDecoder()
|
|
189
|
+
let buffer = ""
|
|
190
|
+
|
|
191
|
+
const processLines = (lines) => {
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
if (!line.startsWith("data:")) continue
|
|
194
|
+
const data = line.slice(5).trim()
|
|
195
|
+
if (!data || data === "[DONE]") continue
|
|
196
|
+
|
|
197
|
+
let json
|
|
198
|
+
try { json = JSON.parse(data) } catch { continue }
|
|
199
|
+
|
|
200
|
+
if (json.usage) result.usage = json.usage
|
|
201
|
+
const choice = json.choices?.[0]
|
|
202
|
+
if (!choice) continue
|
|
203
|
+
if (choice.finish_reason) result.finishReason = choice.finish_reason
|
|
204
|
+
|
|
205
|
+
const delta = choice.delta ?? {}
|
|
206
|
+
if (delta.reasoning_content) {
|
|
207
|
+
result.reasoning += delta.reasoning_content
|
|
208
|
+
onReasoning?.(delta.reasoning_content)
|
|
209
|
+
}
|
|
210
|
+
if (delta.content) {
|
|
211
|
+
result.content += delta.content
|
|
212
|
+
onToken?.(delta.content)
|
|
213
|
+
}
|
|
214
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
215
|
+
const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
|
|
216
|
+
if (tc.id) slot.id = tc.id
|
|
217
|
+
if (tc.function?.name && !slot.name) slot.name = tc.function.name
|
|
218
|
+
if (tc.function?.arguments) slot.arguments += tc.function.arguments
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!response.body) throw new Error("No stream response body")
|
|
224
|
+
for await (const chunk of response.body) {
|
|
225
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
226
|
+
const lines = buffer.split("\n")
|
|
227
|
+
buffer = lines.pop()
|
|
228
|
+
processLines(lines)
|
|
229
|
+
}
|
|
230
|
+
buffer += decoder.decode()
|
|
231
|
+
processLines(buffer.split("\n"))
|
|
232
|
+
return result
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function betaBaseURL(baseURL) {
|
|
236
|
+
// DeepSeek prefix 续写走 /beta 端点;只处理 /v1 后缀,缺 /v1 时追加 /beta
|
|
237
|
+
if (/\/v1$/.test(baseURL)) return baseURL.replace(/\/v1$/, "/beta")
|
|
238
|
+
return baseURL.endsWith("/") ? baseURL + "beta" : baseURL + "/beta"
|
|
239
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/index.mjs — 后端兼容重导出
|
|
3
|
+
* import { chat } from "./provider" → 自动解析到本文件
|
|
4
|
+
*/
|
|
5
|
+
export { chat, createProvider, listModels } from "./core.mjs"
|
|
6
|
+
export { RETRYABLE_STATUS, _rateHooks, estimateText, estimateRequestTokens, rateGate, recordRate } from "./rate.mjs"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider/rate.mjs — TPM/RPM 主动节流闸门
|
|
3
|
+
* 滑动窗口记账,发请求前预检预算,超支则睡到窗口腾出空间。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { specForModel } from "../config.mjs"
|
|
7
|
+
|
|
8
|
+
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
9
|
+
export const MAX_RETRIES = 3
|
|
10
|
+
export const MAX_CONTINUATIONS = 3
|
|
11
|
+
export const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 测试钩子:睡眠/时钟/窗口长度可替换(离线测试不能真等 60s)。
|
|
15
|
+
* 生产代码不要直接调 setTimeout/sleep,统一走这里。
|
|
16
|
+
*/
|
|
17
|
+
export const _rateHooks = {
|
|
18
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
19
|
+
now: () => Date.now(),
|
|
20
|
+
windowMs: 60_000,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
|
|
24
|
+
|
|
25
|
+
function rateKey(provider) {
|
|
26
|
+
// 归一化:/beta 和 /v1 视为同一账户的同一限流窗口(DeepSeek prefix continuation 改 /beta 端点)
|
|
27
|
+
const base = provider.baseURL.replace(/\/beta$/, "/v1")
|
|
28
|
+
return `${base}|${provider.apiKey ?? ""}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 粗估文本 token 数。
|
|
32
|
+
* ASCII 按 ~4 字符/token;非 ASCII(CJK/emoji)按 ~1 字符/token(保守,实测 BPE 1.5-2.5 字/token)。 */
|
|
33
|
+
export function estimateText(s) {
|
|
34
|
+
let nonAscii = 0
|
|
35
|
+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
|
|
36
|
+
return Math.ceil((s.length - nonAscii) / 4) + nonAscii
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 本次请求的 prompt 估算 */
|
|
40
|
+
export function estimateRequestTokens(body) {
|
|
41
|
+
let tokens = 0
|
|
42
|
+
for (const m of body.messages ?? []) {
|
|
43
|
+
if (typeof m.content === "string") tokens += estimateText(m.content)
|
|
44
|
+
if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
|
|
45
|
+
for (const tc of m.tool_calls ?? []) {
|
|
46
|
+
tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (body.tools) tokens += estimateText(JSON.stringify(body.tools))
|
|
50
|
+
return tokens
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 闸门:超预算则睡到窗口腾出空间 */
|
|
54
|
+
export async function rateGate(provider, estimated, onWait, signal) {
|
|
55
|
+
const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
|
|
56
|
+
const rpm = provider.rpm ?? null
|
|
57
|
+
if (tpm == null && rpm == null) return
|
|
58
|
+
const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
|
|
59
|
+
rateWindows.set(rateKey(provider), w)
|
|
60
|
+
for (;;) {
|
|
61
|
+
const now = _rateHooks.now()
|
|
62
|
+
const cutoff = now - _rateHooks.windowMs
|
|
63
|
+
w.tokens = w.tokens.filter((e) => e.ts > cutoff)
|
|
64
|
+
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
65
|
+
const usedTokens = w.tokens.reduce((s, e) => s + e.n, 0)
|
|
66
|
+
const overTokens = tpm != null ? usedTokens + estimated - tpm : 0
|
|
67
|
+
const overRequests = rpm != null ? w.requests.length + 1 - rpm : 0
|
|
68
|
+
if (overTokens <= 0 && overRequests <= 0) break
|
|
69
|
+
let waitMs = _rateHooks.windowMs
|
|
70
|
+
if (overTokens > 0) {
|
|
71
|
+
let freed = 0
|
|
72
|
+
for (const e of w.tokens) {
|
|
73
|
+
freed += e.n
|
|
74
|
+
if (freed >= overTokens) {
|
|
75
|
+
waitMs = Math.min(waitMs, e.ts + _rateHooks.windowMs - now)
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (overRequests > 0) {
|
|
81
|
+
waitMs = Math.min(waitMs, w.requests[overRequests - 1] + _rateHooks.windowMs - now)
|
|
82
|
+
}
|
|
83
|
+
waitMs = Math.max(waitMs, 50)
|
|
84
|
+
onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
|
|
85
|
+
await _rateHooks.sleep(waitMs)
|
|
86
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 记账:响应回来后按实测 usage 记 */
|
|
91
|
+
export function recordRate(provider, estimated, usage) {
|
|
92
|
+
if (provider.tpm == null && provider.rpm == null) return
|
|
93
|
+
const key = rateKey(provider)
|
|
94
|
+
const w = rateWindows.get(key) ?? { tokens: [], requests: [] }
|
|
95
|
+
const now = _rateHooks.now()
|
|
96
|
+
const cutoff = now - _rateHooks.windowMs
|
|
97
|
+
w.tokens = w.tokens.filter((e) => e.ts > cutoff)
|
|
98
|
+
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
99
|
+
w.requests.push(now)
|
|
100
|
+
w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
|
|
101
|
+
// 窗口已空则删条目,防长期跨 provider 配置时 Map 无界增长
|
|
102
|
+
if (w.tokens.length === 0 && w.requests.length === 0) rateWindows.delete(key)
|
|
103
|
+
else rateWindows.set(key, w)
|
|
104
|
+
}
|
package/src/session.mjs
CHANGED
|
@@ -21,14 +21,20 @@ export function sessionPath(cwd) {
|
|
|
21
21
|
function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
|
|
22
22
|
function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
24
|
+
/** 原子写:先写临时文件再 rename 替换,防写入中途崩溃留下截断的 JSON 丢整个会话。
|
|
25
|
+
* rename 在 POSIX 上原子;Windows 上目标已存在时 Node 24 用 MoveFileExW+REPLACE_EXISTING 也能原子替换。
|
|
26
|
+
* 某些旧 Windows 文件系统可能抛 EPERM,重试一次。 */
|
|
26
27
|
function writeSessionFile(p, data) {
|
|
27
28
|
mkdirSync(dirname(p), { recursive: true })
|
|
28
29
|
const tmp = `${p}.tmp`
|
|
29
30
|
writeFileSync(tmp, JSON.stringify(data), "utf8")
|
|
30
|
-
try {
|
|
31
|
-
|
|
31
|
+
try {
|
|
32
|
+
renameSync(tmp, p)
|
|
33
|
+
} catch {
|
|
34
|
+
// Windows EPERM 兜底:删目标后重试(极罕见,仅旧 NTFS/网络盘)
|
|
35
|
+
try { unlinkSync(p) } catch {}
|
|
36
|
+
renameSync(tmp, p)
|
|
37
|
+
}
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
// ========== 槽位管理 ==========
|
|
@@ -52,7 +58,8 @@ export function archiveCurrent(cwd, { exclude } = {}) {
|
|
|
52
58
|
const m = loadManifest(cwd)
|
|
53
59
|
|
|
54
60
|
let slot
|
|
55
|
-
|
|
61
|
+
// 只计数字 key 的槽位,排除 _currentName 等遗留非数字 key
|
|
62
|
+
const entries = Object.entries(m.slots).filter(([n]) => /^\d+$/.test(n))
|
|
56
63
|
if (entries.length < MAX_SLOTS) {
|
|
57
64
|
slot = 1
|
|
58
65
|
while (m.slots[slot]) slot++
|
|
@@ -175,6 +182,12 @@ export function applySession(agent, data) {
|
|
|
175
182
|
agent.goal = data.goal ?? null
|
|
176
183
|
agent._pendingReminders = data.pendingReminders ?? []
|
|
177
184
|
agent._sessionStart = data.sessionStart ?? null
|
|
185
|
+
// 重置轮次计数器:切换会话后不应继承旧会话的停滞/压缩状态
|
|
186
|
+
agent._turnsSinceTaskUpdate = 0
|
|
187
|
+
agent._turnsInPlanMode = 0
|
|
188
|
+
agent._compressFailures = 0
|
|
189
|
+
agent._verifyRetries = 0
|
|
190
|
+
agent._verifyPassed = false
|
|
178
191
|
if (data.activeProvider && data.activeProvider !== agent.activeProvider) {
|
|
179
192
|
const p = agent.providers?.find((pr) => pr.name === data.activeProvider)
|
|
180
193
|
if (p) {
|
package/src/tools/bash.md
CHANGED
|
@@ -25,3 +25,4 @@ Notes:
|
|
|
25
25
|
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
26
26
|
- After commands that change files (git checkout, npm install, etc.), repo_outline and code_search may be stale — re-run them to get current results.
|
|
27
27
|
- Prefer read/glob/grep/ls for file operations inside the project — bash has no directory confinement.
|
|
28
|
+
- NEVER use bash to write or modify files (echo/sed/printf > file, cat << EOF, etc.). Use write/edit/insert_after/apply_patch instead — they handle encoding, escaping, and directory confinement correctly.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DESC,
|
|
3
|
+
sanitizeOutput,
|
|
4
|
+
truncate,
|
|
5
|
+
makeDecoder,
|
|
6
|
+
BASH_TIMEOUT_MS,
|
|
7
|
+
hasFileRedirection,
|
|
8
|
+
shellSegments,
|
|
9
|
+
isDestructiveGitSegment,
|
|
10
|
+
isDestructiveCommand,
|
|
11
|
+
insideGitRepo,
|
|
12
|
+
} from "./shared.mjs"
|
|
13
|
+
import { spawn, execFileSync } from "node:child_process"
|
|
14
|
+
|
|
15
|
+
/** 子进程环境变量白名单:只透传安全变量,隔离 API key 等敏感信息 */
|
|
16
|
+
const SAFE_ENV_KEYS = new Set([
|
|
17
|
+
"PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
|
18
|
+
"TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL",
|
|
19
|
+
"ComSpec", "PATHEXT", "SystemRoot", "windir",
|
|
20
|
+
"NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "OS",
|
|
21
|
+
"PYTHONIOENCODING", "GIT_EDITOR", "EDITOR", "VISUAL",
|
|
22
|
+
"GIT_PAGER", "PAGER", "TERM",
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
export const bashTool = {
|
|
26
|
+
name: "bash",
|
|
27
|
+
description: DESC("bash"),
|
|
28
|
+
parameters: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
command: { type: "string", description: "Shell command to execute" },
|
|
32
|
+
timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
|
|
33
|
+
},
|
|
34
|
+
required: ["command"],
|
|
35
|
+
},
|
|
36
|
+
readonly: false,
|
|
37
|
+
async execute(args, ctx) {
|
|
38
|
+
// 安全预检:禁止 shell 重定向(> >> <)——应改用 write/edit/insert_after 工具
|
|
39
|
+
if (hasFileRedirection(args.command)) {
|
|
40
|
+
throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
|
|
41
|
+
}
|
|
42
|
+
// 安全预检:破坏性非 git 命令(rm -rf / DROP TABLE 等)直接拒绝
|
|
43
|
+
if (shellSegments(args.command).some(isDestructiveCommand)) {
|
|
44
|
+
throw new Error("Destructive command blocked — use specific tools or confirm with the user first")
|
|
45
|
+
}
|
|
46
|
+
// 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
|
|
47
|
+
if (shellSegments(args.command).some(isDestructiveGitSegment)) {
|
|
48
|
+
if (!insideGitRepo(ctx.cwd)) {
|
|
49
|
+
throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
|
|
50
|
+
}
|
|
51
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
52
|
+
cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
53
|
+
}).trim()
|
|
54
|
+
if (status) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
|
|
57
|
+
`(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
// detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
|
|
64
|
+
// Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
|
|
65
|
+
const killTree = () => {
|
|
66
|
+
if (process.platform === "win32") {
|
|
67
|
+
try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
|
|
68
|
+
} else {
|
|
69
|
+
try { process.kill(-child.pid, "SIGKILL") } catch {}
|
|
70
|
+
try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
|
|
74
|
+
// chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
|
|
75
|
+
// 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
|
|
76
|
+
// 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
|
|
77
|
+
const winCmd = process.platform === "win32"
|
|
78
|
+
const child = spawn(args.command, {
|
|
79
|
+
cwd: ctx.cwd,
|
|
80
|
+
shell: true,
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
detached: process.platform !== "win32",
|
|
83
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
84
|
+
env: {
|
|
85
|
+
...Object.fromEntries(
|
|
86
|
+
Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
|
|
87
|
+
),
|
|
88
|
+
GIT_EDITOR: "true",
|
|
89
|
+
EDITOR: "true",
|
|
90
|
+
VISUAL: "true",
|
|
91
|
+
GIT_PAGER: "cat",
|
|
92
|
+
PAGER: "cat",
|
|
93
|
+
TERM: "dumb",
|
|
94
|
+
...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
// stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
|
|
98
|
+
// 且允许模型按 stderr 快速定位错误)
|
|
99
|
+
const outDecoder = makeDecoder()
|
|
100
|
+
const errDecoder = makeDecoder()
|
|
101
|
+
let outBuf = ""
|
|
102
|
+
let errBuf = ""
|
|
103
|
+
let truncatedNote = ""
|
|
104
|
+
|
|
105
|
+
const onStdout = (d) => {
|
|
106
|
+
const s = sanitizeOutput(outDecoder(d))
|
|
107
|
+
if (s) {
|
|
108
|
+
ctx.onOutput?.(s)
|
|
109
|
+
if (outBuf.length < 2_000_000) outBuf += s
|
|
110
|
+
else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const onStderr = (d) => {
|
|
114
|
+
const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
|
|
115
|
+
if (errBuf.length < 2_000_000) errBuf += s
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
child.stdout.on("data", onStdout)
|
|
119
|
+
child.stderr.on("data", onStderr)
|
|
120
|
+
|
|
121
|
+
const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
|
|
122
|
+
if (ctx.signal) {
|
|
123
|
+
ctx.signal.addEventListener("abort", killTree, { once: true })
|
|
124
|
+
}
|
|
125
|
+
child.on("error", (error) => {
|
|
126
|
+
clearTimeout(timer)
|
|
127
|
+
resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
|
|
128
|
+
})
|
|
129
|
+
child.on("close", (code, signal) => {
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
// 冲刷解码器尾部
|
|
132
|
+
outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
|
|
133
|
+
errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
|
|
134
|
+
const status = signal
|
|
135
|
+
? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
|
|
136
|
+
: `exit code ${code}`
|
|
137
|
+
const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
|
|
138
|
+
if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
|
|
139
|
+
parts.push(`(${status})`)
|
|
140
|
+
resolve(truncate(parts.join("\n\n") + truncatedNote))
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
},
|
|
144
|
+
}
|