thincoder 0.4.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.
@@ -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
- return readSSE(response, { onToken, onReasoning })
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
- for await (const chunk of response.body) {
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
+ }
@@ -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 哈希)保存最近一个会话到 ~/.thincoder/sessions/。
4
- * 退出时存、启动时恢复;agent.history 本来就是可 JSON 序列化的。
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
- export function saveSession(agent) {
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: agent.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
- const p = sessionPath(agent.cwd)
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
- return null
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
- if (existsSync(p)) writeFileSync(p, JSON.stringify({ version: 2, cwd, history: [], tasks: [] }), "utf8")
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 "Available skills (use the skill tool to load one):\n" + lines.join("\n")
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
- const MAX_OUTPUT_CHARS = 50_000
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;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
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
- return resolve(ctx.cwd, p)
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: AbortSignal.timeout(15_000),
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: AbortSignal.timeout(20_000),
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}`)