thincoder 0.3.0 → 0.5.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 +9 -3
- package/bin/thincoder.mjs +57 -10
- package/package.json +11 -2
- package/src/SYSTEM_PROMPT.md +7 -6
- package/src/agent.mjs +333 -60
- package/src/coder-overlay.md +2 -1
- package/src/config.mjs +29 -20
- package/src/context.mjs +128 -48
- package/src/explore-overlay.md +6 -2
- package/src/main-overlay.md +8 -0
- package/src/memory.mjs +677 -3
- package/src/plan-overlay.md +13 -0
- package/src/provider.mjs +73 -6
- package/src/repomap.mjs +204 -0
- package/src/session.mjs +142 -15
- package/src/skills.mjs +2 -1
- package/src/tools/bash.md +1 -0
- package/src/tools.mjs +33 -7
- package/src/tui.mjs +228 -34
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
You are a planning subagent. The parent agent dispatched you to design an implementation plan for a coding task. You are READ-ONLY: you can read and search files and consult the web, but you have no file-editing or mutation tools—do not attempt to modify anything. Your deliverable IS the plan itself, returned as your final message.
|
|
2
|
+
|
|
3
|
+
Guidelines:
|
|
4
|
+
- Before planning, use repo_outline to understand the project structure, doc_search for conventions and design docs, and code_search to locate relevant symbols. Ground the plan in real paths, not guesses.
|
|
5
|
+
- First judge whether you understand the codebase areas the task touches. If not, say so instead of guessing—structure your reply as:
|
|
6
|
+
1. What you already know from the provided information
|
|
7
|
+
2. Which open questions would benefit from an explore subagent's investigation (the parent can dispatch one)
|
|
8
|
+
3. Your plan—preliminary if questions remain, final if context is sufficient
|
|
9
|
+
- Ground the plan in reality: cite real file paths and line numbers, name actual functions and modules. No invented architecture.
|
|
10
|
+
- Make steps concrete and verifiable: each step small enough to check, ordered so dependencies come first.
|
|
11
|
+
- Where a real design choice exists, call out the trade-offs and recommend ONE option with reasoning—don't list possibilities without taking a stance.
|
|
12
|
+
- Keep scope minimal: the plan should solve the task, not redesign the codebase.
|
|
13
|
+
- If something is ambiguous, note it in the plan; do not ask the user.
|
package/src/provider.mjs
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
* provider.mjs — LLM 调用层
|
|
3
3
|
* 原生 fetch 直连 OpenAI 兼容协议,SSE 流式,零依赖。
|
|
4
4
|
* 覆盖:OpenAI / DeepSeek / Moonshot / Ollama / 一切 OpenAI 兼容端点。
|
|
5
|
+
* 模型私有能力(Kimi/Qwen Partial Mode、DeepSeek Prefix Completion)由 config.mjs 的规格表声明,这里只按能力开关分支。
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
import { specForModel } from "./config.mjs"
|
|
9
|
+
|
|
7
10
|
export const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
|
8
11
|
const MAX_RETRIES = 3
|
|
12
|
+
const MAX_CONTINUATIONS = 3 // Partial Mode 截断续写上限(防异常的无限 length 循环)
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* 创建 provider。config: { baseURL, apiKey, model, maxTokens?, temperature?, thinking?, reasoningEffort? }
|
|
@@ -34,6 +38,13 @@ export function createProvider(config) {
|
|
|
34
38
|
* signal: AbortSignal(可选)
|
|
35
39
|
* 返回 { content, reasoning, toolCalls: [{id, name, arguments}], usage, finishReason }
|
|
36
40
|
* 注意:toolCalls[i].arguments 是 JSON 字符串,调用方负责 parse
|
|
41
|
+
*
|
|
42
|
+
* 截断续写(按规格表能力门控,未声明的模型原样返回截断结果):
|
|
43
|
+
* finish_reason=length 且已有正文时,把已输出内容作为前缀 assistant 消息回传,
|
|
44
|
+
* 模型接着续写而非丢弃重跑。思考阶段被截断(content 为空)时无前缀可续,直接返回。
|
|
45
|
+
* - partialMode(Kimi / Qwen):assistant 消息带 partial:true;K3 思考续写需回传 reasoning_content
|
|
46
|
+
* - prefixMode(DeepSeek):assistant 消息带 prefix:true,且须走 /beta 端点;
|
|
47
|
+
* 思考模式不支持前缀续写,已产出 reasoning 时放弃续写
|
|
37
48
|
*/
|
|
38
49
|
export async function chat(provider, { messages, tools, onToken, onReasoning, signal }) {
|
|
39
50
|
const body = {
|
|
@@ -49,7 +60,51 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, si
|
|
|
49
60
|
if (tools?.length) body.tools = tools
|
|
50
61
|
|
|
51
62
|
const response = await requestWithRetry(provider, body, signal)
|
|
52
|
-
|
|
63
|
+
const result = await readSSE(response, { onToken, onReasoning })
|
|
64
|
+
|
|
65
|
+
// 截断续写:仅规格表声明续写协议的模型(其他端点不认识 partial/prefix 字段,可能 400)
|
|
66
|
+
const spec = specForModel(provider.model)
|
|
67
|
+
if (!spec.partialMode && !spec.prefixMode) return result
|
|
68
|
+
// DeepSeek prefix 续写不支持思考模式,已产出 reasoning 时无前缀协议可用
|
|
69
|
+
if (spec.prefixMode && !spec.partialMode && result.reasoning) return result
|
|
70
|
+
for (let n = 0; result.finishReason === "length" && result.content && n < MAX_CONTINUATIONS; n++) {
|
|
71
|
+
const continued = await chat(spec.prefixMode ? { ...provider, baseURL: betaBaseURL(provider.baseURL) } : provider, {
|
|
72
|
+
messages: [
|
|
73
|
+
...messages,
|
|
74
|
+
spec.partialMode
|
|
75
|
+
? {
|
|
76
|
+
role: "assistant",
|
|
77
|
+
content: result.content,
|
|
78
|
+
partial: true,
|
|
79
|
+
// K3 思考模式续写必须回传 reasoning_content
|
|
80
|
+
...(result.reasoning ? { reasoning_content: result.reasoning } : {}),
|
|
81
|
+
}
|
|
82
|
+
: { role: "assistant", content: result.content, prefix: true },
|
|
83
|
+
],
|
|
84
|
+
tools,
|
|
85
|
+
onToken,
|
|
86
|
+
onReasoning,
|
|
87
|
+
signal,
|
|
88
|
+
})
|
|
89
|
+
result.content += continued.content
|
|
90
|
+
result.reasoning += continued.reasoning ?? ""
|
|
91
|
+
for (const tc of continued.toolCalls ?? []) {
|
|
92
|
+
if (tc.index == null) { result.toolCalls = continued.toolCalls; break }
|
|
93
|
+
const s = result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" }
|
|
94
|
+
if (tc.id) s.id = tc.id
|
|
95
|
+
s.name += tc.name ?? ""
|
|
96
|
+
s.arguments += tc.arguments ?? ""
|
|
97
|
+
}
|
|
98
|
+
result.finishReason = continued.finishReason
|
|
99
|
+
if (continued.usage) {
|
|
100
|
+
result.usage = {
|
|
101
|
+
prompt_tokens: (result.usage?.prompt_tokens??0) + (continued.usage.prompt_tokens??0),
|
|
102
|
+
completion_tokens: (result.usage?.completion_tokens??0) + (continued.usage.completion_tokens??0),
|
|
103
|
+
total_tokens: (result.usage?.total_tokens??0) + (continued.usage.total_tokens??0),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result
|
|
53
108
|
}
|
|
54
109
|
|
|
55
110
|
/**
|
|
@@ -111,11 +166,7 @@ async function readSSE(response, { onToken, onReasoning }) {
|
|
|
111
166
|
const decoder = new TextDecoder()
|
|
112
167
|
let buffer = ""
|
|
113
168
|
|
|
114
|
-
|
|
115
|
-
buffer += decoder.decode(chunk, { stream: true })
|
|
116
|
-
const lines = buffer.split("\n")
|
|
117
|
-
buffer = lines.pop() // 最后半行留到下一轮
|
|
118
|
-
|
|
169
|
+
const processLines = (lines) => {
|
|
119
170
|
for (const line of lines) {
|
|
120
171
|
if (!line.startsWith("data:")) continue
|
|
121
172
|
const data = line.slice(5).trim()
|
|
@@ -151,9 +202,25 @@ async function readSSE(response, { onToken, onReasoning }) {
|
|
|
151
202
|
}
|
|
152
203
|
}
|
|
153
204
|
}
|
|
205
|
+
|
|
206
|
+
if (!response.body) throw new Error("No stream response body")
|
|
207
|
+
for await (const chunk of response.body) {
|
|
208
|
+
buffer += decoder.decode(chunk, { stream: true })
|
|
209
|
+
const lines = buffer.split("\n")
|
|
210
|
+
buffer = lines.pop() // 最后半行留到下一轮
|
|
211
|
+
processLines(lines)
|
|
212
|
+
}
|
|
213
|
+
// flush 解码器内部残留(流以不完整 UTF-8 序列截断时不丢尾部字节),并处理没有换行结尾的尾行
|
|
214
|
+
buffer += decoder.decode()
|
|
215
|
+
processLines(buffer.split("\n"))
|
|
154
216
|
return result
|
|
155
217
|
}
|
|
156
218
|
|
|
157
219
|
function sleep(ms) {
|
|
158
220
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
159
221
|
}
|
|
222
|
+
|
|
223
|
+
/** DeepSeek Prefix Completion 只在 /beta 端点开放:.../v1 → .../beta */
|
|
224
|
+
function betaBaseURL(baseURL) {
|
|
225
|
+
return baseURL.replace(/\/v1$/, "/beta")
|
|
226
|
+
}
|
package/src/repomap.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* repomap.mjs — 仓库依赖大纲(零依赖,纯 regex)
|
|
3
|
+
* 实时解析 import/export 关系,生成紧凑文本给 LLM 理解代码结构。
|
|
4
|
+
* 不存索引——每次调用读文件解析,~50ms 完成。
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
|
|
9
|
+
/** 提取 JS/TS 文件的 import 路径(去掉 .ts/.js/.mjs 后缀统一) */
|
|
10
|
+
function parseImports(lines, ext) {
|
|
11
|
+
const imports = []
|
|
12
|
+
const text = lines.join("\n")
|
|
13
|
+
// 普通 import
|
|
14
|
+
const re = /import\s+(?:{[^}]*}|\*\s+as\s+\w+|\w+\s*,?\s*(?:{[^}]*})?)\s*from\s*['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g
|
|
15
|
+
let m
|
|
16
|
+
while ((m = re.exec(text))) {
|
|
17
|
+
const raw = m[1] || m[2]
|
|
18
|
+
if (!raw || raw.startsWith("node:") || !raw.startsWith(".")) continue
|
|
19
|
+
imports.push(normalizeExt(raw))
|
|
20
|
+
}
|
|
21
|
+
// re-export: export { x } from './module'
|
|
22
|
+
const reExportRe = /export\s*\{[^}]*\}\s*from\s*['"]([^'"]+)['"]/g
|
|
23
|
+
while ((m = reExportRe.exec(text))) {
|
|
24
|
+
const raw = m[1]
|
|
25
|
+
if (!raw || raw.startsWith("node:") || !raw.startsWith(".")) continue
|
|
26
|
+
imports.push(normalizeExt(raw))
|
|
27
|
+
}
|
|
28
|
+
return [...new Set(imports)]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 提取 JS/TS 文件的 export 符号 */
|
|
32
|
+
function parseExports(lines, ext) {
|
|
33
|
+
const exports = []
|
|
34
|
+
const text = lines.join("\n")
|
|
35
|
+
// export function/class/const/let/var name
|
|
36
|
+
const namedRe = /export\s+(?:async\s+)?(?:function\s+(\w+)|class\s+(\w+)|(?:const|let|var)\s+(\w+))/g
|
|
37
|
+
let m
|
|
38
|
+
while ((m = namedRe.exec(text))) {
|
|
39
|
+
exports.push(m[1] || m[2] || m[3])
|
|
40
|
+
}
|
|
41
|
+
// export default function/class name / export default expression
|
|
42
|
+
const defaultRe = /export\s+default\s+(?:(?:async\s+)?(?:function\s+(\w+)|class\s+(\w+))|(\w+))/g
|
|
43
|
+
while ((m = defaultRe.exec(text))) {
|
|
44
|
+
const name = m[1] || m[2] || m[3]
|
|
45
|
+
if (name) exports.push(name)
|
|
46
|
+
else if (!exports.some((e) => e === "default")) exports.push("default")
|
|
47
|
+
}
|
|
48
|
+
// export { a, b as c } —— 优先取 as 后的导出名
|
|
49
|
+
const braceRe = /export\s*\{([^}]+)\}/g
|
|
50
|
+
while ((m = braceRe.exec(text))) {
|
|
51
|
+
for (const name of m[1].split(",")) {
|
|
52
|
+
const parts = name.trim().split(/\s+/)
|
|
53
|
+
// "a as b" → b(导出名),"a" → a
|
|
54
|
+
const exported = parts.length >= 3 ? parts[2] : parts[0]
|
|
55
|
+
if (exported) exports.push(exported)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// export const { a, b } = ...(解构导出)
|
|
59
|
+
const destructRe = /export\s+(?:const|let|var)\s*\{([^}]+)\}\s*=/g
|
|
60
|
+
while ((m = destructRe.exec(text))) {
|
|
61
|
+
for (const name of m[1].split(",")) {
|
|
62
|
+
const parts = name.trim().split(/\s*:\s*/)
|
|
63
|
+
const n = parts[0].trim()
|
|
64
|
+
if (n) exports.push(n)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(exports)]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 提取 Python 的 import 和顶层 def/class */
|
|
71
|
+
function parsePyOutline(lines) {
|
|
72
|
+
const imports = []
|
|
73
|
+
const symbols = []
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const fromRe = line.match(/^from\s+(\S+)\s+import\s+(.+)/)
|
|
76
|
+
if (fromRe) {
|
|
77
|
+
const mod = fromRe[1]
|
|
78
|
+
if (!mod.startsWith(".")) continue // 只本地
|
|
79
|
+
imports.push(normalizeExt(mod.replace(/^\.+/, "")))
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
const impRe = line.match(/^import\s+(.+)/)
|
|
83
|
+
if (impRe) {
|
|
84
|
+
for (const mod of impRe[1].split(",")) {
|
|
85
|
+
const m = mod.trim().split(/\s+/)[0]
|
|
86
|
+
if (!m.startsWith(".")) continue
|
|
87
|
+
imports.push(normalizeExt(m.replace(/^\.+/, "")))
|
|
88
|
+
}
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
const defRe = line.match(/^(?:async\s+)?(?:def|class)\s+(\w+)/)
|
|
92
|
+
if (defRe) symbols.push(defRe[1])
|
|
93
|
+
}
|
|
94
|
+
return { imports: [...new Set(imports)], symbols: [...new Set(symbols)] }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeExt(p) {
|
|
98
|
+
return p.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 从 code_chunks 取已知文件列表(复用索引),按路径解析生成大纲文本 */
|
|
102
|
+
export function buildOutline(db, cwd, focusPath) {
|
|
103
|
+
const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
|
|
104
|
+
|
|
105
|
+
if (allFiles.length === 0) return "(no indexed source files; run codeSync or /reindex first)"
|
|
106
|
+
|
|
107
|
+
// 构建正向(谁 import 谁)+ 反向(被谁 import)图——总是全量扫描,
|
|
108
|
+
// 因为聚焦一个文件也需要知道别的文件是否 import 了它
|
|
109
|
+
const deps = new Map() // path → { imports: Set, exports: Set, size: number }
|
|
110
|
+
const importers = new Map() // importee → Set<importer>
|
|
111
|
+
|
|
112
|
+
for (const rel of allFiles) {
|
|
113
|
+
const abs = join(cwd, ...rel.split("/"))
|
|
114
|
+
if (!existsSync(abs)) continue
|
|
115
|
+
const text = readFileSync(abs, "utf8")
|
|
116
|
+
const lines = text.split("\n")
|
|
117
|
+
const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
|
|
118
|
+
|
|
119
|
+
let imports, exports
|
|
120
|
+
if (ext === ".py") {
|
|
121
|
+
const py = parsePyOutline(lines)
|
|
122
|
+
imports = py.imports
|
|
123
|
+
exports = py.symbols
|
|
124
|
+
} else {
|
|
125
|
+
imports = parseImports(lines, ext)
|
|
126
|
+
exports = parseExports(lines, ext)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 把 import 路径解析成相对路径(处理 ./ ../)
|
|
130
|
+
const resolved = []
|
|
131
|
+
for (let imp of imports) {
|
|
132
|
+
// 去掉 ./ 前缀
|
|
133
|
+
if (imp.startsWith("./")) imp = imp.slice(2)
|
|
134
|
+
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""
|
|
135
|
+
const parts = imp.split("/")
|
|
136
|
+
if (parts[0] === "..") {
|
|
137
|
+
const up = dir.split("/").filter(Boolean)
|
|
138
|
+
let i = 0
|
|
139
|
+
while (parts[i] === ".." && up.length > 0) { up.pop(); i++ }
|
|
140
|
+
resolved.push([...up, ...parts.slice(i)].join("/"))
|
|
141
|
+
} else {
|
|
142
|
+
resolved.push(dir ? `${dir}/${imp}` : imp)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
deps.set(rel, { imports: new Set(resolved), exports: new Set(exports), size: Math.floor(text.length / 1024) })
|
|
147
|
+
|
|
148
|
+
for (const r of resolved) {
|
|
149
|
+
if (!importers.has(r)) importers.set(r, new Set())
|
|
150
|
+
importers.get(r).add(rel)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 生成文本
|
|
155
|
+
const files = focusPath ? [focusPath] : [...deps.keys()]
|
|
156
|
+
const out = []
|
|
157
|
+
const sorted = files.sort()
|
|
158
|
+
for (const rel of sorted) {
|
|
159
|
+
const d = deps.get(rel)
|
|
160
|
+
if (!d) continue
|
|
161
|
+
const parts = []
|
|
162
|
+
// imported by(匹配时去掉扩展名,因为 import 路径通常不含 .mjs/.js 后缀)
|
|
163
|
+
const key = rel.replace(/\.(m?js|jsx|tsx?)$/i, "")
|
|
164
|
+
const rev = importers.get(key)
|
|
165
|
+
if (rev?.size) parts.push(`← imported by: ${[...rev].join(", ")}`)
|
|
166
|
+
// imports
|
|
167
|
+
if (d.imports.size) parts.push(`→ imports: ${[...d.imports].join(", ")}`)
|
|
168
|
+
// exports
|
|
169
|
+
if (d.exports.size) parts.push(`→ exports: ${[...d.exports].join(", ")}`)
|
|
170
|
+
|
|
171
|
+
const kb = d.size > 0 ? ` (${d.size} KB)` : ""
|
|
172
|
+
if (parts.length) {
|
|
173
|
+
out.push(`${rel}${kb}\n ${parts.join("\n ")}`)
|
|
174
|
+
} else {
|
|
175
|
+
out.push(`${rel}${kb}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return out.join("\n")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 生成 repo_outline 工具(只读)。
|
|
184
|
+
* 需要 memory.db(复用 code_chunks 文件列表)和 cwd。
|
|
185
|
+
*/
|
|
186
|
+
export function repoOutlineTool(db, cwd) {
|
|
187
|
+
return {
|
|
188
|
+
name: "repo_outline",
|
|
189
|
+
description:
|
|
190
|
+
"Show the project's file dependency outline: which files import/export from which, and what symbols they export. Use when you need to understand the project structure, find where a function is defined, or see what files depend on a module. Pass a path to focus on a single file's relationships.",
|
|
191
|
+
parameters: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
path: { type: "string", description: "Optional: focus on a specific file path (relative to project root)" },
|
|
195
|
+
},
|
|
196
|
+
required: [],
|
|
197
|
+
},
|
|
198
|
+
readonly: true,
|
|
199
|
+
async execute(args) {
|
|
200
|
+
const outline = buildOutline(db, cwd, args.path ?? null)
|
|
201
|
+
return outline
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/session.mjs
CHANGED
|
@@ -1,37 +1,142 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* session.mjs — 会话持久化
|
|
3
|
-
* 每个项目(按 cwd
|
|
4
|
-
*
|
|
3
|
+
* 每个项目(按 cwd 哈希)最多保留 5 轮会话,按最后使用时间轮转。
|
|
4
|
+
* 两种恢复需求分开存:agent 恢复(history)要上下文连续,用户恢复(display)要所见即所得。
|
|
5
|
+
*
|
|
6
|
+
* 文件布局:{hash}.json(当前)、{hash}.json.1~5(槽位)、{hash}.json.manifest(槽位元数据)
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { createHash } from "node:crypto"
|
|
8
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, unlinkSync, existsSync } from "node:fs"
|
|
9
11
|
import { join, dirname } from "node:path"
|
|
10
12
|
import { configDir } from "./config.mjs"
|
|
11
13
|
|
|
14
|
+
const MAX_SLOTS = 5
|
|
15
|
+
|
|
12
16
|
export function sessionPath(cwd) {
|
|
13
17
|
const hash = createHash("sha1").update(cwd).digest("hex").slice(0, 12)
|
|
14
18
|
return join(configDir, "sessions", `${hash}.json`)
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
function slotPath(cwd, n) { return sessionPath(cwd) + "." + n }
|
|
22
|
+
function manifestPath(cwd) { return sessionPath(cwd) + ".manifest" }
|
|
23
|
+
|
|
24
|
+
/** 原子写:先写临时文件再替换,防写入中途崩溃留下截断的 JSON 丢整个会话。
|
|
25
|
+
* 不用 renameSync:Windows rename 目标已存在抛 EPERM */
|
|
26
|
+
function writeSessionFile(p, data) {
|
|
27
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
28
|
+
const tmp = `${p}.tmp`
|
|
29
|
+
writeFileSync(tmp, JSON.stringify(data), "utf8")
|
|
30
|
+
try { unlinkSync(p) } catch { /* 旧文件不存在就算了 */ }
|
|
31
|
+
renameSync(tmp, p)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ========== 槽位管理 ==========
|
|
35
|
+
|
|
36
|
+
function loadManifest(cwd) {
|
|
37
|
+
try {
|
|
38
|
+
const p = manifestPath(cwd)
|
|
39
|
+
if (!existsSync(p)) return { slots: {} }
|
|
40
|
+
return JSON.parse(readFileSync(p, "utf8"))
|
|
41
|
+
} catch { return { slots: {} } }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveManifest(cwd, m) {
|
|
45
|
+
writeSessionFile(manifestPath(cwd), m)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 归档当前会话到空闲槽位——满了踢最老 */
|
|
49
|
+
export function archiveCurrent(cwd) {
|
|
50
|
+
const src = sessionPath(cwd)
|
|
51
|
+
if (!existsSync(src)) return
|
|
52
|
+
const m = loadManifest(cwd)
|
|
53
|
+
|
|
54
|
+
let slot
|
|
55
|
+
const entries = Object.entries(m.slots)
|
|
56
|
+
if (entries.length < MAX_SLOTS) {
|
|
57
|
+
slot = 1
|
|
58
|
+
while (m.slots[slot]) slot++
|
|
59
|
+
} else {
|
|
60
|
+
slot = Number(entries.sort((a, b) => a[1] - b[1])[0][0])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const dst = slotPath(cwd, slot)
|
|
64
|
+
writeFileSync(dst, readFileSync(src, "utf8"), "utf8") // 复制(rename 会丢当前)
|
|
65
|
+
m.slots[slot] = Date.now()
|
|
66
|
+
delete m.slots._currentName
|
|
67
|
+
saveManifest(cwd, m)
|
|
68
|
+
return slot
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 列出所有归档槽位,最新在前 */
|
|
72
|
+
export function listSlots(cwd) {
|
|
73
|
+
const m = loadManifest(cwd)
|
|
74
|
+
return Object.entries(m.slots)
|
|
75
|
+
.map(([n, ts]) => ({ slot: Number(n), timestamp: ts, date: new Date(ts).toLocaleString() }))
|
|
76
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 切换到指定槽位:归档当前 → 槽位文件复制到当前 → 返回恢复数据(失败返回 null) */
|
|
80
|
+
export function switchToSlot(cwd, slot) {
|
|
81
|
+
const m = loadManifest(cwd)
|
|
82
|
+
if (!m.slots[slot]) return null
|
|
83
|
+
|
|
84
|
+
// 归档当前(内部写 manifest;之后我们的 m 已过期,需重读)
|
|
85
|
+
archiveCurrent(cwd)
|
|
86
|
+
|
|
87
|
+
// 槽位文件 → 当前(copy+unlink,不用 rename:Windows rename 目标已存在会抛 EPERM)
|
|
88
|
+
const src = slotPath(cwd, slot)
|
|
89
|
+
const dst = sessionPath(cwd)
|
|
90
|
+
if (!existsSync(src)) return null
|
|
91
|
+
// 先删当前文件(archiveCurrent 是复制不是移动,所以它还在)
|
|
92
|
+
try { unlinkSync(dst) } catch { /* 不存在就算了 */ }
|
|
93
|
+
copyFileSync(src, dst)
|
|
94
|
+
unlinkSync(src)
|
|
95
|
+
|
|
96
|
+
// 重读 manifest(archiveCurrent 改了它)
|
|
97
|
+
const m2 = loadManifest(cwd)
|
|
98
|
+
delete m2.slots[slot]
|
|
99
|
+
saveManifest(cwd, m2)
|
|
100
|
+
|
|
101
|
+
return loadSession(cwd)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ========== 旧版 transient 前缀清理 ==========
|
|
105
|
+
|
|
106
|
+
const LEGACY_TRANSIENT_PREFIXES = [
|
|
107
|
+
"[System reminder: working directory snapshot:",
|
|
108
|
+
"[Relevant memories from previous sessions",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
function isLegacyTransient(m) {
|
|
112
|
+
return (
|
|
113
|
+
m.role === "user" &&
|
|
114
|
+
typeof m.content === "string" &&
|
|
115
|
+
LEGACY_TRANSIENT_PREFIXES.some((p) => m.content.startsWith(p))
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ========== 核心读写 ==========
|
|
120
|
+
|
|
121
|
+
export function saveSession(agent, display) {
|
|
122
|
+
const history = agent.history.filter((m) => !m.transient && !isLegacyTransient(m))
|
|
19
123
|
const data = {
|
|
20
124
|
version: 2,
|
|
21
125
|
cwd: agent.cwd,
|
|
22
|
-
activeProvider: agent.provider?.name,
|
|
126
|
+
activeProvider: agent.activeProvider ?? agent.provider?.name,
|
|
23
127
|
updatedAt: Date.now(),
|
|
24
|
-
history
|
|
128
|
+
history,
|
|
129
|
+
display: display ?? [],
|
|
25
130
|
tasks: agent.tasks ?? [],
|
|
26
131
|
planMode: agent.planMode ?? false,
|
|
132
|
+
autoApprove: agent.autoApprove ?? false,
|
|
27
133
|
goal: agent.goal ?? null,
|
|
134
|
+
pendingReminders: agent._pendingReminders ?? [],
|
|
135
|
+
sessionStart: agent._sessionStart ?? null,
|
|
28
136
|
}
|
|
29
|
-
|
|
30
|
-
mkdirSync(dirname(p), { recursive: true })
|
|
31
|
-
writeFileSync(p, JSON.stringify(data), "utf8")
|
|
137
|
+
writeSessionFile(sessionPath(agent.cwd), data)
|
|
32
138
|
}
|
|
33
139
|
|
|
34
|
-
/** 恢复会话。没有或损坏返回 null */
|
|
35
140
|
export function loadSession(cwd) {
|
|
36
141
|
try {
|
|
37
142
|
const p = sessionPath(cwd)
|
|
@@ -39,17 +144,39 @@ export function loadSession(cwd) {
|
|
|
39
144
|
const data = JSON.parse(readFileSync(p, "utf8"))
|
|
40
145
|
if (data?.version !== 1 && data?.version !== 2) return null
|
|
41
146
|
if (!Array.isArray(data.history)) return null
|
|
147
|
+
if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
|
|
148
|
+
data.history = data.history.filter((m) => !isLegacyTransient(m))
|
|
149
|
+
data.display = Array.isArray(data.display)
|
|
150
|
+
? data.display.filter((l) => l && typeof l.text === "string").map((l) => ({ text: l.text, color: l.color }))
|
|
151
|
+
: []
|
|
42
152
|
return data
|
|
43
|
-
} catch {
|
|
44
|
-
|
|
153
|
+
} catch { return null }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function applySession(agent, data) {
|
|
157
|
+
agent.history = data.history
|
|
158
|
+
agent.tasks = data.tasks ?? []
|
|
159
|
+
agent.planMode = data.planMode ?? false
|
|
160
|
+
agent.autoApprove = data.autoApprove ?? false
|
|
161
|
+
agent.goal = data.goal ?? null
|
|
162
|
+
agent._pendingReminders = data.pendingReminders ?? []
|
|
163
|
+
agent._sessionStart = data.sessionStart ?? null
|
|
164
|
+
if (data.activeProvider && data.activeProvider !== agent.activeProvider) {
|
|
165
|
+
const p = agent.providers?.find((pr) => pr.name === data.activeProvider)
|
|
166
|
+
if (p) {
|
|
167
|
+
agent.provider = { ...p }
|
|
168
|
+
agent.activeProvider = p.name
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
45
171
|
}
|
|
172
|
+
return false
|
|
46
173
|
}
|
|
47
174
|
|
|
48
|
-
/** 清空会话(/new) */
|
|
49
175
|
export function clearSession(cwd) {
|
|
50
176
|
try {
|
|
177
|
+
archiveCurrent(cwd)
|
|
51
178
|
const p = sessionPath(cwd)
|
|
52
|
-
|
|
179
|
+
writeSessionFile(p, { version: 2, cwd, history: [], tasks: [], display: [], goal: null, autoApprove: false, pendingReminders: [], sessionStart: null })
|
|
53
180
|
} catch {
|
|
54
181
|
// 清不掉就算了,下次保存会覆盖
|
|
55
182
|
}
|
package/src/skills.mjs
CHANGED
|
@@ -50,13 +50,14 @@ export async function loadSkills(cwd) {
|
|
|
50
50
|
/**
|
|
51
51
|
* 生成技能列表文本,注入 system prompt。
|
|
52
52
|
* 最多 3 个(占位少),超过则标 "... and N more"。
|
|
53
|
+
* 以 DISREGARD 开头:清单刷新(技能增删)后旧清单自动作废,无需删历史(借鉴 kimi-code)。
|
|
53
54
|
*/
|
|
54
55
|
export function formatSkillListing(skills) {
|
|
55
56
|
if (skills.length === 0) return ""
|
|
56
57
|
const listed = skills.slice(0, 3)
|
|
57
58
|
const lines = listed.map((s) => `- **${s.name}**: ${s.description}`)
|
|
58
59
|
if (skills.length > 3) lines.push(` ... and ${skills.length - 3} more`)
|
|
59
|
-
return "
|
|
60
|
+
return "DISREGARD any earlier skill listings. Current available skills (use the skill tool to load one):\n" + lines.join("\n")
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/**
|
package/src/tools/bash.md
CHANGED
|
@@ -11,3 +11,4 @@ Notes:
|
|
|
11
11
|
- On Windows, use Unix shell syntax inside bash commands (Git Bash): forward slashes, `/dev/null` not `NUL`
|
|
12
12
|
- Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
|
|
13
13
|
- Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
|
|
14
|
+
- 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.
|
package/src/tools.mjs
CHANGED
|
@@ -15,7 +15,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
15
15
|
const DESC = (name) => readFileSync(join(__dirname, "tools", `${name}.md`), "utf8")
|
|
16
16
|
|
|
17
17
|
const MAX_READ_LINES = 2000
|
|
18
|
-
|
|
18
|
+
// 输出上限只是内存安全阀:超过 16k 的输出由 agent 层 offload 整体落盘(全量保留、预览+路径回喂),
|
|
19
|
+
// 这里截断必须远高于落盘阈值,否则被截掉的内容在落盘前就永远丢了
|
|
20
|
+
const MAX_OUTPUT_CHARS = 200_000
|
|
19
21
|
const BASH_TIMEOUT_MS = 120_000
|
|
20
22
|
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
21
23
|
|
|
@@ -36,7 +38,7 @@ export function toOpenAISchema(tool) {
|
|
|
36
38
|
/** 剥离 ANSI 转义序列(vim/less/颜色码会冲花 TUI 渲染),并把 \r 进度条改写转成换行 */
|
|
37
39
|
function sanitizeOutput(s) {
|
|
38
40
|
return s
|
|
39
|
-
.replace(/\x1b\[[0-9;?]*[
|
|
41
|
+
.replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
|
|
40
42
|
.replace(/\r\n/g, "\n")
|
|
41
43
|
.replace(/\r/g, "\n")
|
|
42
44
|
}
|
|
@@ -47,7 +49,9 @@ function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
function resolveInCwd(ctx, p) {
|
|
50
|
-
|
|
52
|
+
const resolved = resolve(ctx.cwd, p)
|
|
53
|
+
if (relative(ctx.cwd, resolved).startsWith("..")) throw new Error(`Access denied outside working directory: ${p}`)
|
|
54
|
+
return resolved
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
// ---------------------------------------------------------------- read
|
|
@@ -95,6 +99,8 @@ const writeTool = {
|
|
|
95
99
|
async execute(args, ctx) {
|
|
96
100
|
const abs = resolveInCwd(ctx, args.path)
|
|
97
101
|
await mkdir(dirname(abs), { recursive: true })
|
|
102
|
+
const st = await stat(abs).catch(() => null)
|
|
103
|
+
if (st?.isDirectory()) throw new Error(`Path is a directory: ${abs}`)
|
|
98
104
|
await writeFile(abs, args.content, "utf8")
|
|
99
105
|
return `Wrote ${args.content.length} chars to ${abs}`
|
|
100
106
|
},
|
|
@@ -149,6 +155,20 @@ const bashTool = {
|
|
|
149
155
|
},
|
|
150
156
|
readonly: false,
|
|
151
157
|
async execute(args, ctx) {
|
|
158
|
+
// 安全预检:销毁性 git 操作(checkout -- / reset --hard)先检查未提交改动,
|
|
159
|
+
// 有则拒绝——防一键清掉几小时工作(像今天 git checkout -- 六个文件那次)
|
|
160
|
+
const DESTRUCTIVE_GIT = /^git\s+(?:checkout\s+--?\s+|reset\s+--hard\b)/
|
|
161
|
+
if (DESTRUCTIVE_GIT.test(args.command)) {
|
|
162
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
163
|
+
cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
164
|
+
}).trim()
|
|
165
|
+
if (status) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n\n${status}`
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
152
172
|
return new Promise((resolve) => {
|
|
153
173
|
const child = spawn(args.command, {
|
|
154
174
|
cwd: ctx.cwd,
|
|
@@ -170,6 +190,8 @@ const bashTool = {
|
|
|
170
190
|
// 编码嗅探:cmd 自带消息是 GBK,git/node 等程序是 UTF-8,平台判断不了。
|
|
171
191
|
// 策略:纯 ASCII 段两种编码一致,直接透传不判定;遇到高位字节才用
|
|
172
192
|
// fatal UTF-8 试解(容忍尾部 1~3 字节截断),失败则判 GBK;一经判定不再变更。
|
|
193
|
+
// 已知边界:GBK 字节流极低概率恰好构成合法 UTF-8 序列,会误判为 UTF-8 产生乱码。
|
|
194
|
+
// 更严谨的做法是 chcp 探测控制台代码页,但当前策略覆盖 99.9% 场景,不值得那份复杂度。
|
|
173
195
|
let decoder = null
|
|
174
196
|
let pending = Buffer.alloc(0)
|
|
175
197
|
const feed = (d, flush = false) => {
|
|
@@ -374,14 +396,16 @@ const websearchTool = {
|
|
|
374
396
|
required: ["query"],
|
|
375
397
|
},
|
|
376
398
|
readonly: true,
|
|
377
|
-
async execute(args) {
|
|
399
|
+
async execute(args, ctx) {
|
|
378
400
|
const limit = args.limit ?? 8
|
|
379
401
|
const url = `https://www.bing.com/search?q=${encodeURIComponent(args.query)}`
|
|
380
402
|
let html
|
|
381
403
|
try {
|
|
382
404
|
const response = await fetch(url, {
|
|
383
405
|
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
|
|
384
|
-
signal:
|
|
406
|
+
signal: ctx?.signal
|
|
407
|
+
? AbortSignal.any([ctx.signal, AbortSignal.timeout(15_000)])
|
|
408
|
+
: AbortSignal.timeout(15_000),
|
|
385
409
|
})
|
|
386
410
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
387
411
|
html = await response.text()
|
|
@@ -471,14 +495,16 @@ const fetchTool = {
|
|
|
471
495
|
required: ["url"],
|
|
472
496
|
},
|
|
473
497
|
readonly: true,
|
|
474
|
-
async execute(args) {
|
|
498
|
+
async execute(args, ctx) {
|
|
475
499
|
if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
|
|
476
500
|
let response
|
|
477
501
|
try {
|
|
478
502
|
response = await fetch(args.url, {
|
|
479
503
|
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" },
|
|
480
504
|
redirect: "follow",
|
|
481
|
-
signal:
|
|
505
|
+
signal: ctx?.signal
|
|
506
|
+
? AbortSignal.any([ctx.signal, AbortSignal.timeout(20_000)])
|
|
507
|
+
: AbortSignal.timeout(20_000),
|
|
482
508
|
})
|
|
483
509
|
} catch (error) {
|
|
484
510
|
throw new Error(`fetch failed: ${error.cause?.code ?? error.message}`)
|