thincoder 0.7.0 → 0.7.2
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 +23 -1
- package/bin/thincoder.mjs +28 -1
- package/package.json +1 -1
- package/src/SYSTEM_PROMPT.md +1 -0
- package/src/agent.mjs +96 -64
- package/src/checkpoint.mjs +6 -3
- package/src/config.mjs +10 -4
- package/src/context.mjs +37 -8
- package/src/distill.mjs +6 -2
- package/src/embedding.mjs +11 -2
- package/src/gitmem.mjs +6 -2
- package/src/markdown.mjs +11 -4
- package/src/mcp.mjs +118 -36
- package/src/memory.mjs +214 -74
- package/src/provider.mjs +160 -21
- package/src/repomap.mjs +117 -16
- package/src/session.mjs +23 -9
- package/src/skills.mjs +6 -2
- package/src/tools/apply_patch.md +11 -0
- package/src/tools/checkpoint.md +11 -0
- package/src/tools.mjs +311 -25
- package/src/tui.mjs +506 -426
package/src/provider.mjs
CHANGED
|
@@ -10,11 +10,109 @@ import { specForModel } from "./config.mjs"
|
|
|
10
10
|
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
11
11
|
const MAX_RETRIES = 3
|
|
12
12
|
const MAX_CONTINUATIONS = 3 // Partial Mode 截断续写上限(防异常的无限 length 循环)
|
|
13
|
+
// 429 的专项退避:RPM/TPM 按 60s 窗口计(账户级独立计数器),通用退避的 1s/2s/4s 等不出窗口
|
|
14
|
+
const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
|
-
*
|
|
17
|
+
* 测试钩子:睡眠/时钟/窗口长度可替换(离线测试不能真等 60s)。
|
|
18
|
+
* 生产代码不要直接调 setTimeout/sleep,统一走这里。
|
|
19
|
+
*/
|
|
20
|
+
export const _rateHooks = {
|
|
21
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
22
|
+
now: () => Date.now(),
|
|
23
|
+
windowMs: 60_000,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- TPM/RPM 主动节流闸门
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 滑动窗口闸门:发请求前若 窗口内已耗 + 本次估算 超预算,就睡到最早的记录滑出窗口。
|
|
30
|
+
* 按 baseURL+apiKey 记账(限速是账户级);预算来自 provider.tpm / provider.rpm,
|
|
31
|
+
* 不配则闸门关闭(429 反应式退避仍然生效)。主循环/压缩摘要/子 agent/截断续写全走这里。
|
|
32
|
+
*/
|
|
33
|
+
const rateWindows = new Map() // key → { tokens: [{ts, n}], requests: [ts] }
|
|
34
|
+
|
|
35
|
+
function rateKey(provider) {
|
|
36
|
+
return `${provider.baseURL}|${provider.apiKey ?? ""}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 粗估文本 token 数(与 context.mjs 同口径:ASCII/4 + 非 ASCII/1;context 依赖本模块,不能反向 import) */
|
|
40
|
+
function estimateText(s) {
|
|
41
|
+
let nonAscii = 0
|
|
42
|
+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) nonAscii++
|
|
43
|
+
return Math.ceil((s.length - nonAscii) / 4) + nonAscii
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 本次请求的 prompt 估算:messages 正文 + 思考链 + tool_calls 参数 + tools schema */
|
|
47
|
+
function estimateRequestTokens(body) {
|
|
48
|
+
let tokens = 0
|
|
49
|
+
for (const m of body.messages ?? []) {
|
|
50
|
+
if (typeof m.content === "string") tokens += estimateText(m.content)
|
|
51
|
+
if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
|
|
52
|
+
for (const tc of m.tool_calls ?? []) {
|
|
53
|
+
tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (body.tools) tokens += estimateText(JSON.stringify(body.tools))
|
|
57
|
+
return tokens
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 闸门:超预算则睡到窗口腾出空间;onWait({phase:"gate", seconds}) 供 UI 展示等待 */
|
|
61
|
+
async function rateGate(provider, estimated, onWait, signal) {
|
|
62
|
+
// 单次请求估算已超过 TPM 预算时闸门无意义(等到天荒地老也塞不下),放行由服务端裁决
|
|
63
|
+
const tpm = provider.tpm != null && estimated <= provider.tpm ? provider.tpm : null
|
|
64
|
+
const rpm = provider.rpm ?? null
|
|
65
|
+
if (tpm == null && rpm == null) return
|
|
66
|
+
const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
|
|
67
|
+
rateWindows.set(rateKey(provider), w)
|
|
68
|
+
for (;;) {
|
|
69
|
+
const now = _rateHooks.now()
|
|
70
|
+
const cutoff = now - _rateHooks.windowMs
|
|
71
|
+
w.tokens = w.tokens.filter((e) => e.ts > cutoff)
|
|
72
|
+
w.requests = w.requests.filter((ts) => ts > cutoff)
|
|
73
|
+
const usedTokens = w.tokens.reduce((s, e) => s + e.n, 0)
|
|
74
|
+
const overTokens = tpm != null ? usedTokens + estimated - tpm : 0
|
|
75
|
+
const overRequests = rpm != null ? w.requests.length + 1 - rpm : 0
|
|
76
|
+
if (overTokens <= 0 && overRequests <= 0) break
|
|
77
|
+
let waitMs = _rateHooks.windowMs
|
|
78
|
+
if (overTokens > 0) {
|
|
79
|
+
// tokens 按时间升序:累加最早若干条,过期量足够腾出空间时的过期时刻
|
|
80
|
+
let freed = 0
|
|
81
|
+
for (const e of w.tokens) {
|
|
82
|
+
freed += e.n
|
|
83
|
+
if (freed >= overTokens) {
|
|
84
|
+
waitMs = Math.min(waitMs, e.ts + _rateHooks.windowMs - now)
|
|
85
|
+
break
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (overRequests > 0) {
|
|
90
|
+
// 等第 overRequests-1 条(升序)滑出,窗口内请求数即降回 rpm-1
|
|
91
|
+
waitMs = Math.min(waitMs, w.requests[overRequests - 1] + _rateHooks.windowMs - now)
|
|
92
|
+
}
|
|
93
|
+
waitMs = Math.max(waitMs, 50)
|
|
94
|
+
onWait?.({ phase: "gate", seconds: Math.ceil(waitMs / 1000) })
|
|
95
|
+
await _rateHooks.sleep(waitMs)
|
|
96
|
+
if (signal?.aborted) return
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 记账:响应回来后按实测 usage 记(无 usage 用发送前的估算兜底);被拒的请求不记(服务端未处理) */
|
|
101
|
+
function recordRate(provider, estimated, usage) {
|
|
102
|
+
if (provider.tpm == null && provider.rpm == null) return
|
|
103
|
+
const w = rateWindows.get(rateKey(provider)) ?? { tokens: [], requests: [] }
|
|
104
|
+
rateWindows.set(rateKey(provider), w)
|
|
105
|
+
const now = _rateHooks.now()
|
|
106
|
+
w.requests.push(now)
|
|
107
|
+
// TPM 按输入+输出总量计(Moonshot 口径)
|
|
108
|
+
w.tokens.push({ ts: now, n: usage ? (usage.prompt_tokens ?? estimated) + (usage.completion_tokens ?? 0) : estimated })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 创建 provider。config: { baseURL, apiKey, model, maxTokens?, temperature?, thinking?, reasoningEffort?, tpm?, rpm? }
|
|
16
113
|
* thinking: { type: "enabled"|"disabled" } 思维模式开关
|
|
17
114
|
* reasoningEffort: "low"|"high"|"max" 推理强度(DeepSeek/Kimi/GLM 通用)
|
|
115
|
+
* tpm / rpm: 主动节流预算(tokens/分钟、请求数/分钟,按账户限速等级自配),不配则闸门关闭
|
|
18
116
|
*/
|
|
19
117
|
export function createProvider(config) {
|
|
20
118
|
if (!config?.baseURL) throw new Error("provider config: baseURL is required")
|
|
@@ -28,6 +126,8 @@ export function createProvider(config) {
|
|
|
28
126
|
temperature: config.temperature,
|
|
29
127
|
thinking: config.thinking,
|
|
30
128
|
reasoningEffort: config.reasoningEffort,
|
|
129
|
+
tpm: config.tpm,
|
|
130
|
+
rpm: config.rpm,
|
|
31
131
|
}
|
|
32
132
|
}
|
|
33
133
|
|
|
@@ -35,6 +135,7 @@ export function createProvider(config) {
|
|
|
35
135
|
* 流式对话。
|
|
36
136
|
* messages: OpenAI 格式数组; tools: OpenAI tools schema(可选)
|
|
37
137
|
* onToken(text): 正文流式回调; onReasoning(text): 思考流回调(DeepSeek-R1 类模型)
|
|
138
|
+
* onWait({phase, seconds}): 节流等待回调(phase: "gate"=主动节流 / "retry"=429 退避)
|
|
38
139
|
* signal: AbortSignal(可选)
|
|
39
140
|
* 返回 { content, reasoning, toolCalls: [{id, name, arguments}], usage, finishReason }
|
|
40
141
|
* 注意:toolCalls[i].arguments 是 JSON 字符串,调用方负责 parse
|
|
@@ -46,7 +147,7 @@ export function createProvider(config) {
|
|
|
46
147
|
* - prefixMode(DeepSeek):assistant 消息带 prefix:true,且须走 /beta 端点;
|
|
47
148
|
* 思考模式不支持前缀续写,已产出 reasoning 时放弃续写
|
|
48
149
|
*/
|
|
49
|
-
export async function chat(provider, { messages, tools, onToken, onReasoning, signal }) {
|
|
150
|
+
export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal }) {
|
|
50
151
|
const spec = specForModel(provider.model)
|
|
51
152
|
const body = {
|
|
52
153
|
model: provider.model,
|
|
@@ -77,8 +178,13 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
|
|
|
77
178
|
}
|
|
78
179
|
if (tools?.length) body.tools = tools
|
|
79
180
|
|
|
80
|
-
|
|
181
|
+
// TPM/RPM 主动节流:超预算先在本地睡到窗口腾出空间,不打 429 碰运气
|
|
182
|
+
const estimated = estimateRequestTokens(body)
|
|
183
|
+
await rateGate(provider, estimated, onWait, signal)
|
|
184
|
+
|
|
185
|
+
const response = await requestWithRetry(provider, body, signal, onWait)
|
|
81
186
|
const result = await readSSE(response, { onToken, onReasoning })
|
|
187
|
+
recordRate(provider, estimated, result.usage)
|
|
82
188
|
|
|
83
189
|
// 截断续写:仅规格表声明续写协议的模型(其他端点不认识 partial/prefix 字段,可能 400)
|
|
84
190
|
if (!spec.partialMode && !spec.prefixMode) return result
|
|
@@ -101,26 +207,31 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
|
|
|
101
207
|
tools,
|
|
102
208
|
onToken,
|
|
103
209
|
onReasoning,
|
|
210
|
+
onWait,
|
|
104
211
|
signal,
|
|
105
212
|
})
|
|
106
213
|
result.content += continued.content
|
|
107
214
|
result.reasoning += continued.reasoning ?? ""
|
|
108
215
|
for (const tc of continued.toolCalls ?? []) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
216
|
+
if (tc.index == null) { result.toolCalls = continued.toolCalls; break }
|
|
217
|
+
const s = result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" }
|
|
218
|
+
if (tc.id) s.id = tc.id
|
|
219
|
+
s.name += tc.name ?? ""
|
|
220
|
+
s.arguments += tc.arguments ?? ""
|
|
221
|
+
}
|
|
115
222
|
result.finishReason = continued.finishReason
|
|
116
223
|
if (continued.usage) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
224
|
+
const sum = (k) => (result.usage?.[k] ?? 0) + (continued.usage[k] ?? 0)
|
|
225
|
+
result.usage = {
|
|
226
|
+
prompt_tokens: sum("prompt_tokens"),
|
|
227
|
+
completion_tokens: sum("completion_tokens"),
|
|
228
|
+
total_tokens: sum("total_tokens"),
|
|
229
|
+
// 缓存命中/未命中也要累计(DeepSeek 计费与状态栏展示依赖这两个字段)
|
|
230
|
+
prompt_cache_hit_tokens: sum("prompt_cache_hit_tokens"),
|
|
231
|
+
prompt_cache_miss_tokens: sum("prompt_cache_miss_tokens"),
|
|
232
|
+
}
|
|
121
233
|
}
|
|
122
234
|
}
|
|
123
|
-
}
|
|
124
235
|
return result
|
|
125
236
|
}
|
|
126
237
|
|
|
@@ -141,11 +252,18 @@ export async function listModels(provider, { signal } = {}) {
|
|
|
141
252
|
return (data.data ?? []).map((m) => m.id).filter(Boolean).sort()
|
|
142
253
|
}
|
|
143
254
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
255
|
+
/**
|
|
256
|
+
* 带重试的请求:网络错误与 5xx 指数退避(1s/2s/4s);429 专项处理——
|
|
257
|
+
* 有 Retry-After 以它为准,没有按 15s/30s/60s 退避(RPM/TPM 是 60s 窗口,秒级退避等不出去)。
|
|
258
|
+
* 配额/余额错误(如 exceeded_current_quota_error)与限速同状态码但语义不同:重试无用,直接抛。
|
|
259
|
+
*/
|
|
260
|
+
async function requestWithRetry(provider, body, signal, onWait) {
|
|
146
261
|
let lastError
|
|
262
|
+
let lastWas429 = false
|
|
263
|
+
let rateLimitHits = 0 // 连续 429 计数(退避档位用,与 attempt 解耦)
|
|
147
264
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
148
|
-
if (attempt > 0) await sleep(2 ** (attempt - 1) * 1000)
|
|
265
|
+
if (attempt > 0 && !lastWas429) await _rateHooks.sleep(2 ** (attempt - 1) * 1000)
|
|
266
|
+
lastWas429 = false
|
|
149
267
|
|
|
150
268
|
let response
|
|
151
269
|
try {
|
|
@@ -168,6 +286,21 @@ async function requestWithRetry(provider, body, signal) {
|
|
|
168
286
|
|
|
169
287
|
const text = await response.text().catch(() => "")
|
|
170
288
|
const message = `LLM API error ${response.status}: ${text}`
|
|
289
|
+
if (isQuotaError(text)) throw new Error(message)
|
|
290
|
+
if (response.status === 429) {
|
|
291
|
+
const retryAfter = Number(response.headers.get("retry-after"))
|
|
292
|
+
const waitMs =
|
|
293
|
+
Number.isFinite(retryAfter) && retryAfter > 0
|
|
294
|
+
? retryAfter * 1000
|
|
295
|
+
: RATE_LIMIT_BACKOFF_MS[Math.min(rateLimitHits++, RATE_LIMIT_BACKOFF_MS.length - 1)]
|
|
296
|
+
lastError = new Error(message)
|
|
297
|
+
lastWas429 = true
|
|
298
|
+
if (attempt < MAX_RETRIES) {
|
|
299
|
+
onWait?.({ phase: "retry", seconds: Math.ceil(waitMs / 1000) })
|
|
300
|
+
await _rateHooks.sleep(waitMs)
|
|
301
|
+
}
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
171
304
|
if (RETRYABLE_STATUS.has(response.status)) {
|
|
172
305
|
lastError = new Error(message)
|
|
173
306
|
continue
|
|
@@ -177,6 +310,16 @@ async function requestWithRetry(provider, body, signal) {
|
|
|
177
310
|
throw lastError
|
|
178
311
|
}
|
|
179
312
|
|
|
313
|
+
/** 配额/余额错误(重试无意义):错误体 type 含 quota,如 Moonshot exceeded_current_quota_error */
|
|
314
|
+
function isQuotaError(text) {
|
|
315
|
+
try {
|
|
316
|
+
const type = JSON.parse(text)?.error?.type
|
|
317
|
+
return typeof type === "string" && type.includes("quota")
|
|
318
|
+
} catch {
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
180
323
|
/** 解析 SSE 流,累积正文/思考/tool_calls */
|
|
181
324
|
async function readSSE(response, { onToken, onReasoning }) {
|
|
182
325
|
const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
|
|
@@ -233,10 +376,6 @@ async function readSSE(response, { onToken, onReasoning }) {
|
|
|
233
376
|
return result
|
|
234
377
|
}
|
|
235
378
|
|
|
236
|
-
function sleep(ms) {
|
|
237
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
238
|
-
}
|
|
239
|
-
|
|
240
379
|
/** DeepSeek Prefix Completion 只在 /beta 端点开放:.../v1 → .../beta */
|
|
241
380
|
function betaBaseURL(baseURL) {
|
|
242
381
|
return baseURL.replace(/\/v1$/, "/beta")
|
package/src/repomap.mjs
CHANGED
|
@@ -74,17 +74,15 @@ function parsePyOutline(lines) {
|
|
|
74
74
|
for (const line of lines) {
|
|
75
75
|
const fromRe = line.match(/^from\s+(\S+)\s+import\s+(.+)/)
|
|
76
76
|
if (fromRe) {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
imports.push(normalizeExt(mod.replace(/^\.+/, "")))
|
|
77
|
+
const rel = pyRelPath(fromRe[1])
|
|
78
|
+
if (rel) imports.push(rel)
|
|
80
79
|
continue
|
|
81
80
|
}
|
|
82
81
|
const impRe = line.match(/^import\s+(.+)/)
|
|
83
82
|
if (impRe) {
|
|
84
83
|
for (const mod of impRe[1].split(",")) {
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
imports.push(normalizeExt(m.replace(/^\.+/, "")))
|
|
84
|
+
const rel = pyRelPath(mod.trim().split(/\s+/)[0])
|
|
85
|
+
if (rel) imports.push(rel)
|
|
88
86
|
}
|
|
89
87
|
continue
|
|
90
88
|
}
|
|
@@ -94,19 +92,32 @@ function parsePyOutline(lines) {
|
|
|
94
92
|
return { imports: [...new Set(imports)], symbols: [...new Set(symbols)] }
|
|
95
93
|
}
|
|
96
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Python 相对导入 → 相对文件路径:
|
|
97
|
+
* 前导 n 个点表示上溯 n-1 层("."=当前包),模块点号转路径分隔符。
|
|
98
|
+
* 非相对导入(不以 . 开头)或纯包导入("from . import x")返回 null。
|
|
99
|
+
*/
|
|
100
|
+
function pyRelPath(mod) {
|
|
101
|
+
if (!mod?.startsWith(".")) return null
|
|
102
|
+
const dots = mod.match(/^\.+/)[0].length
|
|
103
|
+
const rest = mod.slice(dots).replaceAll(".", "/")
|
|
104
|
+
if (!rest) return null
|
|
105
|
+
return normalizeExt("../".repeat(dots - 1) + rest)
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
function normalizeExt(p) {
|
|
98
109
|
return p.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
99
110
|
}
|
|
100
111
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
112
|
+
/**
|
|
113
|
+
* 内部:扫描全量文件,构建正向依赖图 + 反向引用图。
|
|
114
|
+
* 返回 { deps, importers, fileCount } 供 buildOutline / buildSummary 共用。
|
|
115
|
+
*/
|
|
116
|
+
function _buildDepGraph(db, cwd) {
|
|
103
117
|
const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
|
|
118
|
+
if (allFiles.length === 0) return null
|
|
104
119
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// 构建正向(谁 import 谁)+ 反向(被谁 import)图——总是全量扫描,
|
|
108
|
-
// 因为聚焦一个文件也需要知道别的文件是否 import 了它
|
|
109
|
-
const deps = new Map() // path → { imports: Set, exports: Set, size: number }
|
|
120
|
+
const deps = new Map() // path → { imports: Set, exports: Set, size: number, dir: string }
|
|
110
121
|
const importers = new Map() // importee → Set<importer>
|
|
111
122
|
|
|
112
123
|
for (const rel of allFiles) {
|
|
@@ -129,7 +140,6 @@ export function buildOutline(db, cwd, focusPath) {
|
|
|
129
140
|
// 把 import 路径解析成相对路径(处理 ./ ../)
|
|
130
141
|
const resolved = []
|
|
131
142
|
for (let imp of imports) {
|
|
132
|
-
// 去掉 ./ 前缀
|
|
133
143
|
if (imp.startsWith("./")) imp = imp.slice(2)
|
|
134
144
|
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
|
|
135
145
|
const parts = imp.split("/")
|
|
@@ -143,7 +153,8 @@ export function buildOutline(db, cwd, focusPath) {
|
|
|
143
153
|
}
|
|
144
154
|
}
|
|
145
155
|
|
|
146
|
-
|
|
156
|
+
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "."
|
|
157
|
+
deps.set(rel, { imports: new Set(resolved), exports: new Set(exports), size: Math.floor(text.length / 1024), dir })
|
|
147
158
|
|
|
148
159
|
for (const r of resolved) {
|
|
149
160
|
if (!importers.has(r)) importers.set(r, new Set())
|
|
@@ -151,7 +162,97 @@ export function buildOutline(db, cwd, focusPath) {
|
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
164
|
|
|
154
|
-
|
|
165
|
+
return { deps, importers, fileCount: allFiles.length }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 生成紧凑架构摘要(替换旧的全量注入)。
|
|
170
|
+
* 三层信息,每层信息密度递减:
|
|
171
|
+
* 1. 目录级依赖(多目录项目才有意义,单目录跳过)
|
|
172
|
+
* 2. 枢纽文件 Top-12(被 import 最多的文件——架构骨架)
|
|
173
|
+
* 3. 入口文件(无人 import 的文件——启动/顶层入口)
|
|
174
|
+
* 输出天然有界(~1000-2000 字符),不再需要 OUTLINE_INJECT_MAX 硬截断。
|
|
175
|
+
*/
|
|
176
|
+
export function buildSummary(db, cwd) {
|
|
177
|
+
const graph = _buildDepGraph(db, cwd)
|
|
178
|
+
if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
|
|
179
|
+
const { deps, importers, fileCount } = graph
|
|
180
|
+
|
|
181
|
+
const out = []
|
|
182
|
+
out.push(`${fileCount} source files indexed.`)
|
|
183
|
+
|
|
184
|
+
// 1) 目录级依赖
|
|
185
|
+
const dirDeps = new Map() // dir → Set<imported-dir>
|
|
186
|
+
const dirSet = new Set()
|
|
187
|
+
for (const [rel, d] of deps) {
|
|
188
|
+
dirSet.add(d.dir)
|
|
189
|
+
if (!dirDeps.has(d.dir)) dirDeps.set(d.dir, new Set())
|
|
190
|
+
for (const imp of d.imports) {
|
|
191
|
+
const targetDir = imp.includes("/") ? imp.slice(0, imp.lastIndexOf("/")) : "."
|
|
192
|
+
if (targetDir !== d.dir) dirDeps.get(d.dir).add(targetDir)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (dirSet.size > 1) {
|
|
196
|
+
out.push("Directory dependencies:")
|
|
197
|
+
for (const dir of [...dirSet].sort()) {
|
|
198
|
+
const targets = dirDeps.get(dir)
|
|
199
|
+
if (targets?.size) {
|
|
200
|
+
out.push(` ${dir}/ → ${[...targets].sort().join(", ")}/`)
|
|
201
|
+
} else {
|
|
202
|
+
out.push(` ${dir}/ (leaf)`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2) 枢纽文件 Top-12:按被 import 次数降序
|
|
208
|
+
const HUB_LIMIT = 12
|
|
209
|
+
const hubScores = []
|
|
210
|
+
for (const [rel] of deps) {
|
|
211
|
+
const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
212
|
+
const rev = importers.get(key)
|
|
213
|
+
if (rev?.size) hubScores.push({ path: rel, count: rev.size })
|
|
214
|
+
}
|
|
215
|
+
hubScores.sort((a, b) => b.count - a.count)
|
|
216
|
+
if (hubScores.length > 0) {
|
|
217
|
+
out.push(`Hub files (by inbound dependencies, top ${Math.min(hubScores.length, HUB_LIMIT)}):`)
|
|
218
|
+
for (const h of hubScores.slice(0, HUB_LIMIT)) {
|
|
219
|
+
const d = deps.get(h.path)
|
|
220
|
+
const kb = d?.size ? ` (${d.size} KB)` : ""
|
|
221
|
+
const key = h.path.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
222
|
+
const rev = importers.get(key)
|
|
223
|
+
const shortRefs = rev.size <= 5
|
|
224
|
+
? [...rev].join(", ")
|
|
225
|
+
: [...rev].slice(0, 4).join(", ") + ` +${rev.size - 4} more`
|
|
226
|
+
out.push(` ${h.path}${kb} — imported by: ${shortRefs}`)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 3) 入口文件:无人 import 的(叶子/入口)
|
|
231
|
+
const entries = []
|
|
232
|
+
for (const [rel] of deps) {
|
|
233
|
+
const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
234
|
+
if (!importers.has(key) || importers.get(key).size === 0) {
|
|
235
|
+
entries.push(rel)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (entries.length > 0 && entries.length < fileCount) {
|
|
239
|
+
const limit = 8
|
|
240
|
+
const shown = entries.slice(0, limit)
|
|
241
|
+
out.push(`Entry points (not imported by others):`)
|
|
242
|
+
for (const e of shown) out.push(` ${e}`)
|
|
243
|
+
if (entries.length > limit) out.push(` ... +${entries.length - limit} more`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
out.push("For detailed per-file relationships, call repo_outline with a file path.")
|
|
247
|
+
return out.join("\n")
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 从 code_chunks 取已知文件列表(复用索引),按路径解析生成大纲文本 */
|
|
251
|
+
export function buildOutline(db, cwd, focusPath) {
|
|
252
|
+
const graph = _buildDepGraph(db, cwd)
|
|
253
|
+
if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
|
|
254
|
+
const { deps, importers } = graph
|
|
255
|
+
|
|
155
256
|
const files = focusPath ? [focusPath] : [...deps.keys()]
|
|
156
257
|
const out = []
|
|
157
258
|
const sorted = files.sort()
|
package/src/session.mjs
CHANGED
|
@@ -45,8 +45,8 @@ function saveManifest(cwd, m) {
|
|
|
45
45
|
writeSessionFile(manifestPath(cwd), m)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
export function archiveCurrent(cwd) {
|
|
48
|
+
/** 归档当前会话到空闲槽位——满了踢最老;exclude 指定一个不许被踢的槽位(switchToSlot 的目标槽) */
|
|
49
|
+
export function archiveCurrent(cwd, { exclude } = {}) {
|
|
50
50
|
const src = sessionPath(cwd)
|
|
51
51
|
if (!existsSync(src)) return
|
|
52
52
|
const m = loadManifest(cwd)
|
|
@@ -57,11 +57,20 @@ export function archiveCurrent(cwd) {
|
|
|
57
57
|
slot = 1
|
|
58
58
|
while (m.slots[slot]) slot++
|
|
59
59
|
} else {
|
|
60
|
-
|
|
60
|
+
const candidates = entries.filter(([n]) => Number(n) !== exclude)
|
|
61
|
+
slot = Number((candidates.length ? candidates : entries).sort((a, b) => a[1] - b[1])[0][0])
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
const dst = slotPath(cwd, slot)
|
|
64
|
-
|
|
65
|
+
// 复制(rename 会丢当前);走原子写,防中途崩溃留下截断的 JSON 丢归档
|
|
66
|
+
let data
|
|
67
|
+
try {
|
|
68
|
+
data = JSON.parse(readFileSync(src, "utf8"))
|
|
69
|
+
} catch {
|
|
70
|
+
// 会话文件损坏,放弃归档,下次保存会覆盖
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
writeSessionFile(dst, data)
|
|
65
74
|
m.slots[slot] = Date.now()
|
|
66
75
|
delete m.slots._currentName
|
|
67
76
|
saveManifest(cwd, m)
|
|
@@ -82,16 +91,21 @@ export function switchToSlot(cwd, slot) {
|
|
|
82
91
|
if (!m.slots[slot]) return null
|
|
83
92
|
|
|
84
93
|
// 归档当前(内部写 manifest;之后我们的 m 已过期,需重读)
|
|
85
|
-
|
|
94
|
+
// 满槽时排除目标槽:否则最老槽=目标槽,归档会把目标覆盖掉再复制回来,目标会话永久丢失
|
|
95
|
+
archiveCurrent(cwd, { exclude: slot })
|
|
86
96
|
|
|
87
97
|
// 槽位文件 → 当前(copy+unlink,不用 rename:Windows rename 目标已存在会抛 EPERM)
|
|
88
98
|
const src = slotPath(cwd, slot)
|
|
89
99
|
const dst = sessionPath(cwd)
|
|
90
100
|
if (!existsSync(src)) return null
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
try {
|
|
102
|
+
try { unlinkSync(dst) } catch { /* 不存在就算了 */ }
|
|
103
|
+
copyFileSync(src, dst)
|
|
104
|
+
unlinkSync(src)
|
|
105
|
+
} catch {
|
|
106
|
+
// 文件操作失败(磁盘满/权限不足/锁文件),放弃切换
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
95
109
|
|
|
96
110
|
// 重读 manifest(archiveCurrent 改了它)
|
|
97
111
|
const m2 = loadManifest(cwd)
|
package/src/skills.mjs
CHANGED
|
@@ -28,13 +28,17 @@ export async function loadSkills(cwd) {
|
|
|
28
28
|
try {
|
|
29
29
|
const s = await stat(p)
|
|
30
30
|
if (!s.isFile()) continue
|
|
31
|
-
// 提取描述(前 400
|
|
31
|
+
// 提取描述(前 400 字符里第一段非空、非标题行);文件带 frontmatter 时整块跳过,
|
|
32
|
+
// 否则会把 frontmatter 字段行(如 "name: x")误当描述
|
|
32
33
|
const head = await readFile(p, "utf8")
|
|
33
34
|
const body = head.slice(0, 400).split("\n")
|
|
34
35
|
let desc = ""
|
|
36
|
+
let inFrontmatter = false
|
|
35
37
|
for (const line of body) {
|
|
36
38
|
const t = line.trim()
|
|
37
|
-
if (t
|
|
39
|
+
if (t === "---") { inFrontmatter = !inFrontmatter; continue }
|
|
40
|
+
if (inFrontmatter) continue
|
|
41
|
+
if (t && !t.startsWith("#")) {
|
|
38
42
|
desc = t.slice(0, 120)
|
|
39
43
|
break
|
|
40
44
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Apply a unified diff to one or more files, atomically: if any hunk fails to apply, nothing is written.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- patch (required): Unified diff text. One `--- a/path` / `+++ b/path` header pair per file, then `@@ -old,count +new,count @@` hunks. Use `--- /dev/null` to create a new file.
|
|
5
|
+
|
|
6
|
+
Notes:
|
|
7
|
+
- Use this for multi-file changes (e.g. rename an interface + update all callers) — one call, all-or-nothing
|
|
8
|
+
- Hunks are located by their context/removed lines, not line numbers — but the context must match the file EXACTLY. Read the files first and generate the patch from actual content
|
|
9
|
+
- If a hunk's context matches multiple locations it is rejected — add more surrounding context lines
|
|
10
|
+
- Deleting files is not supported — use the delete tool
|
|
11
|
+
- For single-file small edits, edit is simpler; for full rewrites, write is simpler
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
List, create, and restore workspace snapshots (checkpoints). Git repositories only.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- action (required): "list" | "create" | "rewind"
|
|
5
|
+
- id: snapshot id (required for rewind)
|
|
6
|
+
|
|
7
|
+
Notes:
|
|
8
|
+
- A checkpoint is AUTO-CREATED before every user task. If uncommitted work was destroyed (by you, a git command, or a failed refactor), use action=list then action=rewind with the latest id to recover it
|
|
9
|
+
- A checkpoint captures all uncommitted state: tracked-file changes (as a diff) plus copies of untracked files
|
|
10
|
+
- Rewind first snapshots the current state, so rewinding is itself reversible
|
|
11
|
+
- Create one manually before risky bulk operations
|