thincoder 0.1.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/LICENSE +21 -0
- package/README.md +162 -0
- package/bin/thincoder.mjs +383 -0
- package/package.json +29 -0
- package/src/agent.mjs +351 -0
- package/src/checkpoint.mjs +135 -0
- package/src/config.mjs +106 -0
- package/src/context.mjs +76 -0
- package/src/distill.mjs +117 -0
- package/src/embedding.mjs +107 -0
- package/src/gitmem.mjs +87 -0
- package/src/markdown.mjs +99 -0
- package/src/memory.mjs +495 -0
- package/src/provider.mjs +153 -0
- package/src/session.mjs +53 -0
- package/src/tools.mjs +513 -0
- package/src/tui.mjs +912 -0
package/src/agent.mjs
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent.mjs — Agent 主循环
|
|
3
|
+
* LLM ↔ 工具调用循环,直到任务完成。
|
|
4
|
+
* 工具执行用两段式:权限确认串行,只读工具并行、有副作用工具串行。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chat } from "./provider.mjs"
|
|
8
|
+
import { compressIfNeeded } from "./context.mjs"
|
|
9
|
+
import { search as memorySearch } from "./memory.mjs"
|
|
10
|
+
import { toOpenAISchema } from "./tools.mjs"
|
|
11
|
+
import { readFile } from "node:fs/promises"
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX_TURNS = 50
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 修复断头 tool_calls:assistant 消息带了 tool_calls 但后面缺对应的 tool 结果
|
|
18
|
+
* (进程在工具执行中途被杀、会话中断等)。为每个缺失的 tool_call_id 补一条
|
|
19
|
+
* 中断占位消息,否则 API 会整单拒绝(invalid_request_error)。
|
|
20
|
+
* 返回修复后的新数组;无问题时返回原数组。
|
|
21
|
+
*/
|
|
22
|
+
export function repairHistory(history) {
|
|
23
|
+
const out = []
|
|
24
|
+
let dirty = false
|
|
25
|
+
for (let i = 0; i < history.length; i++) {
|
|
26
|
+
const m = history[i]
|
|
27
|
+
out.push(m)
|
|
28
|
+
if (m.role !== "assistant" || !m.tool_calls?.length) continue
|
|
29
|
+
|
|
30
|
+
// 收集紧随其后(下一个非 tool 消息之前)的 tool 结果 id
|
|
31
|
+
const answered = new Set()
|
|
32
|
+
let j = i + 1
|
|
33
|
+
while (j < history.length && history[j].role === "tool") {
|
|
34
|
+
answered.add(history[j].tool_call_id)
|
|
35
|
+
out.push(history[j])
|
|
36
|
+
j++
|
|
37
|
+
}
|
|
38
|
+
i = j - 1 // 外层 for 会再 +1
|
|
39
|
+
|
|
40
|
+
for (const tc of m.tool_calls) {
|
|
41
|
+
if (!answered.has(tc.id)) {
|
|
42
|
+
dirty = true
|
|
43
|
+
out.push({
|
|
44
|
+
role: "tool",
|
|
45
|
+
tool_call_id: tc.id,
|
|
46
|
+
content: "[Tool execution was interrupted: session ended before the result was recorded]",
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return dirty ? out : history
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const VALID_TASK_STATUS = new Set(["pending", "in_progress", "done"])
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* subagent 工具:派生子 agent 处理独立子任务(隔离上下文,只带回报告)。
|
|
58
|
+
* - 一批多个 subagent 调用走并行通道(parallel: true),适合广发探索
|
|
59
|
+
* - 不递归:子 agent 的 tools 里不含 subagent(depth > 0 不注入)
|
|
60
|
+
* - 普通模式:子 agent 的有副作用工具一律拒绝(研究型用法);
|
|
61
|
+
* auto 模式(agent.autoApprove):全部放行
|
|
62
|
+
*/
|
|
63
|
+
export const subagentTool = {
|
|
64
|
+
name: "subagent",
|
|
65
|
+
description:
|
|
66
|
+
"Spawn a sub-agent to handle an independent subtask in an isolated context. The sub-agent has the same tools (except subagent itself) and returns only its final report. Spawn MULTIPLE subagents in the SAME response for parallel research—they run concurrently. Do not give parallel subagents tasks that edit the same files.",
|
|
67
|
+
parameters: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
task: { type: "string", description: "Self-contained task description for the sub-agent" },
|
|
71
|
+
context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation)" },
|
|
72
|
+
},
|
|
73
|
+
required: ["task"],
|
|
74
|
+
},
|
|
75
|
+
readonly: false,
|
|
76
|
+
parallel: true,
|
|
77
|
+
async execute(args, ctx) {
|
|
78
|
+
const parent = ctx.agent
|
|
79
|
+
const child = createAgent({
|
|
80
|
+
provider: parent.provider,
|
|
81
|
+
tools: parent.tools,
|
|
82
|
+
config: parent.config,
|
|
83
|
+
cwd: parent.cwd,
|
|
84
|
+
memory: parent.memory,
|
|
85
|
+
})
|
|
86
|
+
// 子 agent 的权限策略:auto 模式全放行;普通模式只读(拒绝写操作)
|
|
87
|
+
const childPermission = parent.autoApprove ? async () => true : async () => false
|
|
88
|
+
const input = args.context ? `背景:\n${args.context}\n\n任务:\n${args.task}` : args.task
|
|
89
|
+
const report = await runAgent(child, input, { onPermissionRequest: childPermission }, { depth: (ctx.depth ?? 0) + 1 })
|
|
90
|
+
const maxLen = 32000
|
|
91
|
+
return report.length > maxLen
|
|
92
|
+
? report.slice(0, maxLen) + `\n\n[... report truncated: ${report.length} chars total, ${report.length - maxLen} omitted. Ask a follow-up if you need the missing details.]`
|
|
93
|
+
: report
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* task 工具:多步任务规划与进度跟踪(Claude Code 的 todo 模式)。
|
|
99
|
+
* 每次调用整体替换列表;只改 agent 内部状态、不碰外部世界,故 readonly。
|
|
100
|
+
* 通过 ctx.agent 访问调用方 agent(由 runAgent 注入)。
|
|
101
|
+
* 注:ctx.agent 的回写是有意的轻量耦合——task 本质是主循环的内建能力而非普通工具,
|
|
102
|
+
* 伪装成工具是为了让 LLM 用统一的 tool calling 协议调用它;替代方案(主循环特判)
|
|
103
|
+
* 会让循环代码更绕,不值。
|
|
104
|
+
*/
|
|
105
|
+
export const taskTool = {
|
|
106
|
+
name: "task",
|
|
107
|
+
description:
|
|
108
|
+
"Plan and track a task list for complex multi-step work. Replaces the entire list on each call. Use for requests needing 3+ steps: break work into items, keep exactly one in_progress, and CALL THIS TOOL AGAIN to mark each item done as soon as you complete it—a stale list is worse than none. Statuses: pending | in_progress | done.",
|
|
109
|
+
parameters: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
items: {
|
|
113
|
+
type: "array",
|
|
114
|
+
items: {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
title: { type: "string" },
|
|
118
|
+
status: { type: "string", enum: ["pending", "in_progress", "done"] },
|
|
119
|
+
},
|
|
120
|
+
required: ["title", "status"],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
required: ["items"],
|
|
125
|
+
},
|
|
126
|
+
readonly: true,
|
|
127
|
+
async execute(args, ctx) {
|
|
128
|
+
const items = (args.items ?? []).map((it) => ({
|
|
129
|
+
title: String(it.title ?? "").slice(0, 200),
|
|
130
|
+
status: VALID_TASK_STATUS.has(it.status) ? it.status : "pending",
|
|
131
|
+
}))
|
|
132
|
+
ctx.agent.tasks = items
|
|
133
|
+
ctx.agent._onTaskUpdate?.(items)
|
|
134
|
+
const done = items.filter((i) => i.status === "done").length
|
|
135
|
+
const open = items.length - done
|
|
136
|
+
return `Task list updated: ${done}/${items.length} done` +
|
|
137
|
+
(open > 0 ? ` — ${open} item(s) still open; call task again as you complete them.` : " — all done.")
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 项目指令文件候选(按优先级拼接所有存在的) */
|
|
142
|
+
const INSTRUCTION_FILES = ["AGENTS.md", "agents.md", "PROJECT_RULES.md", "project_rules.md", ".thincoder/rules.md"]
|
|
143
|
+
const MAX_INSTRUCTION_CHARS = 8000
|
|
144
|
+
|
|
145
|
+
/** 读取项目指令(AGENTS.md / project_rules 等),没有则返回空串 */
|
|
146
|
+
export async function loadProjectInstructions(cwd) {
|
|
147
|
+
const parts = []
|
|
148
|
+
for (const name of INSTRUCTION_FILES) {
|
|
149
|
+
try {
|
|
150
|
+
const text = await readFile(join(cwd, name), "utf8")
|
|
151
|
+
if (text.trim()) parts.push(`# ${name}\n${text.trim()}`)
|
|
152
|
+
} catch {
|
|
153
|
+
// 文件不存在,跳过
|
|
154
|
+
}
|
|
155
|
+
if (parts.join("\n").length > MAX_INSTRUCTION_CHARS) break
|
|
156
|
+
}
|
|
157
|
+
return parts.join("\n\n").slice(0, MAX_INSTRUCTION_CHARS)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const SYSTEM_PROMPT = `You are ThinCoder, a coding agent. Thin means sharp: you are a terse, precise engineer who cuts straight to the point—no fluff, no showing off, no filler. You write the most minimal, elegant code that solves the problem, and you say things in as few words as the truth allows.
|
|
161
|
+
|
|
162
|
+
Rules:
|
|
163
|
+
- Prefer tool calls over guessing. Read files before modifying them.
|
|
164
|
+
- When you need multiple independent pieces of information (e.g. reading several files), make all independent tool calls in the SAME response so they can run in parallel.
|
|
165
|
+
- Be concise in your final answers. Report what you did, not what you plan to do.
|
|
166
|
+
- If the request is ambiguous at a decision that matters, stop and ask in your reply instead of guessing—but ask at most once, then proceed with the most reasonable interpretation.
|
|
167
|
+
- When the user shares an observation or opinion, don't mistake it for a command—confirm before making changes.
|
|
168
|
+
- For complex multi-step requests (3+ steps), use the task tool to plan and track progress; keep exactly one item in_progress, and update the list as you complete items—never finish with stale pending items.
|
|
169
|
+
- For independent research/exploration subtasks, spawn subagents in the SAME response to run them in parallel—they work in isolated contexts and return final reports. Delegate breadth-first exploration; do precision edits yourself. Never assign parallel subagents tasks that edit the same files.
|
|
170
|
+
- Never fabricate file contents or command outputs; only trust tool results.
|
|
171
|
+
- Before declaring a coding task complete, verify it: run the project's relevant tests/build if they exist. If you could not verify, say so explicitly—never present unverified work as done.
|
|
172
|
+
- Run shell commands non-interactively: git commit -m, git --no-pager, -y/--yes flags where applicable. There is no TTY; editors and pagers (vim, less) cannot be used.
|
|
173
|
+
- You have long-term memory via memory_put/memory_search. When you learn a durable fact about this project (convention, decision, debugging insight), save it with memory_put. Relevant memories may be injected below—use them.`
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 创建 agent。
|
|
177
|
+
* { provider, tools, config, cwd, memory? } —— memory 可空(无记忆模式)
|
|
178
|
+
*/
|
|
179
|
+
export function createAgent({ provider, tools, config, cwd, memory = null }) {
|
|
180
|
+
return {
|
|
181
|
+
provider,
|
|
182
|
+
tools,
|
|
183
|
+
config,
|
|
184
|
+
cwd,
|
|
185
|
+
memory,
|
|
186
|
+
history: [], // OpenAI 格式的对话历史(不含 system)
|
|
187
|
+
tasks: [], // task 工具维护的任务列表
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 跑一轮任务。
|
|
193
|
+
* callbacks: {
|
|
194
|
+
* onToken(text), onReasoning(text),
|
|
195
|
+
* onToolCall(name, args), onToolResult(name, result),
|
|
196
|
+
* onPermissionRequest(name, args) => Promise<boolean> // 有副作用工具调用前询问;不提供则默认拒绝
|
|
197
|
+
* }
|
|
198
|
+
* 返回最终文本。
|
|
199
|
+
*/
|
|
200
|
+
export async function runAgent(agent, input, callbacks = {}, { depth = 0 } = {}) {
|
|
201
|
+
const maxTurns = agent.config?.agent?.maxTurns ?? DEFAULT_MAX_TURNS
|
|
202
|
+
const threshold = agent.config?.agent?.compactThreshold ?? 100_000
|
|
203
|
+
// 先修复历史(恢复的会话可能有中断的 tool_calls),再追加新输入
|
|
204
|
+
agent.history = repairHistory(agent.history)
|
|
205
|
+
agent.history.push({ role: "user", content: input })
|
|
206
|
+
|
|
207
|
+
// task 工具随主循环注入(内建能力);subagent 只在顶层注入(禁止递归)
|
|
208
|
+
const tools = [...agent.tools, taskTool, ...(depth === 0 ? [subagentTool] : [])]
|
|
209
|
+
const toolSchemas = tools.map(toOpenAISchema)
|
|
210
|
+
const toolByName = new Map(tools.map((t) => [t.name, t]))
|
|
211
|
+
agent._onTaskUpdate = callbacks.onTaskUpdate
|
|
212
|
+
|
|
213
|
+
// 记忆注入:按用户输入检索相关记忆,附加到 system prompt
|
|
214
|
+
let systemPrompt = SYSTEM_PROMPT
|
|
215
|
+
const projectRules = await loadProjectInstructions(agent.cwd)
|
|
216
|
+
if (projectRules) {
|
|
217
|
+
systemPrompt += `\n\nProject instructions (follow these as project conventions):\n${projectRules}`
|
|
218
|
+
}
|
|
219
|
+
if (agent.memory) {
|
|
220
|
+
const memories = await memorySearch(agent.memory, input, { limit: 3 })
|
|
221
|
+
if (memories.length > 0) {
|
|
222
|
+
systemPrompt +=
|
|
223
|
+
"\n\nRelevant memories from previous sessions:\n" +
|
|
224
|
+
memories.map((m) => `- [${m.type}] ${m.title}: ${m.content}`).join("\n")
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (let turn = 0; turn < maxTurns; turn++) {
|
|
229
|
+
// 每轮 LLM 调用前检查上下文长度,超阈值先压缩(末尾是 user 或 tool 消息都是安全点;
|
|
230
|
+
// 但压缩只在末尾是 user 时最干净,tool 结尾说明在工具循环中段,下一轮再压)
|
|
231
|
+
if (agent.history.at(-1)?.role === "user") {
|
|
232
|
+
if (await compressIfNeeded(agent, threshold)) {
|
|
233
|
+
callbacks.onCompress?.()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const messages = [{ role: "system", content: systemPrompt }, ...agent.history]
|
|
238
|
+
|
|
239
|
+
const response = await chat(agent.provider, {
|
|
240
|
+
messages,
|
|
241
|
+
tools: toolSchemas,
|
|
242
|
+
onToken: callbacks.onToken,
|
|
243
|
+
onReasoning: callbacks.onReasoning,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// 无工具调用:最终回答,收尾
|
|
247
|
+
if (response.toolCalls.length === 0) {
|
|
248
|
+
agent.history.push({ role: "assistant", content: response.content })
|
|
249
|
+
return response.content
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 有工具调用:assistant 消息(含 tool_calls)入历史
|
|
253
|
+
agent.history.push({
|
|
254
|
+
role: "assistant",
|
|
255
|
+
content: response.content || null,
|
|
256
|
+
tool_calls: response.toolCalls.map((tc) => ({
|
|
257
|
+
id: tc.id,
|
|
258
|
+
type: "function",
|
|
259
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
260
|
+
})),
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const results = await executeToolCalls(agent, toolByName, response.toolCalls, callbacks, depth)
|
|
264
|
+
|
|
265
|
+
// 结果按 toolCallId 配对回喂(协议按 ID 不按位置,完成乱序无影响)
|
|
266
|
+
for (const { toolCall, result } of results) {
|
|
267
|
+
agent.history.push({
|
|
268
|
+
role: "tool",
|
|
269
|
+
tool_call_id: toolCall.id,
|
|
270
|
+
content: result,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
throw new Error(`Agent exceeded max turns (${maxTurns})`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 两段式执行:
|
|
280
|
+
* 阶段一(串行):逐个解析参数 + 权限确认(有副作用工具)
|
|
281
|
+
* 阶段二(分类):只读工具 Promise.all 并行;有副作用工具逐个串行
|
|
282
|
+
* 返回按 toolCallId 配对的结果数组。
|
|
283
|
+
*/
|
|
284
|
+
async function executeToolCalls(agent, toolByName, toolCalls, callbacks, depth = 0) {
|
|
285
|
+
// ---- 阶段一:串行准备 ----
|
|
286
|
+
const prepared = []
|
|
287
|
+
for (const toolCall of toolCalls) {
|
|
288
|
+
const tool = toolByName.get(toolCall.name)
|
|
289
|
+
let args = {}
|
|
290
|
+
try {
|
|
291
|
+
args = JSON.parse(toolCall.arguments || "{}")
|
|
292
|
+
} catch {
|
|
293
|
+
prepared.push({ toolCall, tool: null, error: `Invalid tool arguments JSON: ${toolCall.arguments}` })
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!tool) {
|
|
298
|
+
prepared.push({ toolCall, tool: null, error: `Unknown tool: ${toolCall.name}` })
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!tool.readonly) {
|
|
303
|
+
const allowed = callbacks.onPermissionRequest
|
|
304
|
+
? await callbacks.onPermissionRequest(toolCall.name, args)
|
|
305
|
+
: false
|
|
306
|
+
if (!allowed) {
|
|
307
|
+
prepared.push({ toolCall, tool, denied: true })
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
callbacks.onToolCall?.(toolCall.name, args)
|
|
313
|
+
prepared.push({ toolCall, tool, args })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- 阶段二:分类执行 ----
|
|
317
|
+
const runOne = async (item) => {
|
|
318
|
+
if (item.error) return { ...item, result: `Error: ${item.error}` }
|
|
319
|
+
if (item.denied) return { ...item, result: "Error: permission denied by user" }
|
|
320
|
+
try {
|
|
321
|
+
const result = await item.tool.execute(item.args, {
|
|
322
|
+
cwd: agent.cwd,
|
|
323
|
+
agent,
|
|
324
|
+
depth,
|
|
325
|
+
onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk),
|
|
326
|
+
})
|
|
327
|
+
callbacks.onToolResult?.(item.toolCall.name, result)
|
|
328
|
+
return { ...item, result: String(result) }
|
|
329
|
+
} catch (error) {
|
|
330
|
+
return { ...item, result: `Error: ${error.message}` }
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 并行通道:只读工具 + 显式声明 parallel 的工具(subagent);其余串行
|
|
335
|
+
const parallelItems = prepared.filter((p) => p.tool?.readonly || p.tool?.parallel)
|
|
336
|
+
const serialItems = prepared.filter((p) => p.tool && !p.tool.readonly && !p.tool.parallel)
|
|
337
|
+
const failedItems = prepared.filter((p) => !p.tool)
|
|
338
|
+
|
|
339
|
+
const parallelResults = await Promise.all(parallelItems.map(runOne))
|
|
340
|
+
const serialResults = []
|
|
341
|
+
for (const item of [...serialItems, ...failedItems]) {
|
|
342
|
+
serialResults.push(await runOne(item))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 按原始 toolCall 顺序合并(保持历史可读性;协议层靠 ID 配对,顺序无关正确性)
|
|
346
|
+
const resultByCallId = new Map()
|
|
347
|
+
for (const r of [...parallelResults, ...serialResults]) {
|
|
348
|
+
resultByCallId.set(r.toolCall.id, r)
|
|
349
|
+
}
|
|
350
|
+
return toolCalls.map((tc) => resultByCallId.get(tc.id))
|
|
351
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkpoint.mjs — 工作区快照与回滚
|
|
3
|
+
* 快照 = git diff HEAD 补丁 + 未跟踪文件副本(尊重 .gitignore)。
|
|
4
|
+
* 仅 git 仓库内可用。回滚前会先打新快照(回滚可逆)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process"
|
|
8
|
+
import { createHash } from "node:crypto"
|
|
9
|
+
import { existsSync } from "node:fs"
|
|
10
|
+
import { cp, mkdir, readFile, readdir, rm, writeFile, copyFile } from "node:fs/promises"
|
|
11
|
+
import { dirname, join, relative } from "node:path"
|
|
12
|
+
import { configDir } from "./config.mjs"
|
|
13
|
+
|
|
14
|
+
const MAX_CHECKPOINTS = 20
|
|
15
|
+
|
|
16
|
+
function git(cwd, args, { allowFail = false } = {}) {
|
|
17
|
+
try {
|
|
18
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (allowFail) return null
|
|
21
|
+
throw new Error(`git ${args.join(" ")} failed: ${error.stderr?.toString().trim() || error.message}`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function checkpointRoot(cwd) {
|
|
26
|
+
const hash = createHash("sha1").update(cwd).digest("hex").slice(0, 12)
|
|
27
|
+
return join(configDir, "checkpoints", hash)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 当前目录是否 git 仓库 */
|
|
31
|
+
export function isGitRepo(cwd) {
|
|
32
|
+
return git(cwd, ["rev-parse", "--is-inside-work-tree"], { allowFail: true }) === "true"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 打快照。返回 { id, time, files } 或 null(非 git 仓库)。
|
|
37
|
+
*/
|
|
38
|
+
export async function createCheckpoint(cwd) {
|
|
39
|
+
if (!isGitRepo(cwd)) return null
|
|
40
|
+
|
|
41
|
+
const id = Date.now().toString(36)
|
|
42
|
+
const dir = join(checkpointRoot(cwd), id)
|
|
43
|
+
await mkdir(join(dir, "untracked"), { recursive: true })
|
|
44
|
+
|
|
45
|
+
// 跟踪文件的改动 → 补丁
|
|
46
|
+
const patch = git(cwd, ["diff", "HEAD", "--binary"], { allowFail: true }) ?? ""
|
|
47
|
+
await writeFile(join(dir, "patch.diff"), patch, "utf8")
|
|
48
|
+
|
|
49
|
+
// 未跟踪文件(尊重 .gitignore)→ 原样复制
|
|
50
|
+
const untrackedRaw = git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? ""
|
|
51
|
+
const untracked = untrackedRaw ? untrackedRaw.split("\n").filter(Boolean) : []
|
|
52
|
+
for (const rel of untracked) {
|
|
53
|
+
const src = join(cwd, rel)
|
|
54
|
+
const dst = join(dir, "untracked", rel)
|
|
55
|
+
await mkdir(dirname(dst), { recursive: true })
|
|
56
|
+
await copyFile(src, dst).catch(() => {}) // 复制失败(socket/设备文件等)跳过
|
|
57
|
+
}
|
|
58
|
+
await writeFile(join(dir, "meta.json"), JSON.stringify({ id, time: Date.now(), untracked }, null, 2), "utf8")
|
|
59
|
+
|
|
60
|
+
await pruneCheckpoints(cwd)
|
|
61
|
+
return { id, time: Date.now(), files: untracked.length + (patch ? 1 : 0) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 列出快照(新→旧) */
|
|
65
|
+
export async function listCheckpoints(cwd) {
|
|
66
|
+
const root = checkpointRoot(cwd)
|
|
67
|
+
if (!existsSync(root)) return []
|
|
68
|
+
const ids = (await readdir(root)).sort().reverse()
|
|
69
|
+
const out = []
|
|
70
|
+
for (const id of ids) {
|
|
71
|
+
try {
|
|
72
|
+
const meta = JSON.parse(await readFile(join(root, id, "meta.json"), "utf8"))
|
|
73
|
+
out.push({ id, time: meta.time, untracked: meta.untracked.length })
|
|
74
|
+
} catch {
|
|
75
|
+
// 损坏的快照跳过
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 回滚到指定快照(先把当前状态存成新快照,保证回滚可逆)。
|
|
83
|
+
* 返回恢复摘要。
|
|
84
|
+
*/
|
|
85
|
+
export async function rewind(cwd, id) {
|
|
86
|
+
const dir = join(checkpointRoot(cwd), id)
|
|
87
|
+
if (!existsSync(join(dir, "meta.json"))) throw new Error(`checkpoint ${id} not found`)
|
|
88
|
+
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8"))
|
|
89
|
+
|
|
90
|
+
// 回滚也可逆:先给当前状态打快照
|
|
91
|
+
await createCheckpoint(cwd)
|
|
92
|
+
|
|
93
|
+
// 1. 跟踪文件 → HEAD,再应用快照补丁 → 快照时状态
|
|
94
|
+
git(cwd, ["checkout", "--", "."])
|
|
95
|
+
const patch = await readFile(join(dir, "patch.diff"), "utf8")
|
|
96
|
+
if (patch.trim()) {
|
|
97
|
+
const patchFile = join(dir, "patch.diff")
|
|
98
|
+
git(cwd, ["apply", "--whitespace=nowarn", patchFile])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. 快照之后新建的未跟踪文件 → 删除
|
|
102
|
+
const nowUntracked = (git(cwd, ["ls-files", "--others", "--exclude-standard"], { allowFail: true }) ?? "")
|
|
103
|
+
.split("\n")
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
const checkpointSet = new Set(meta.untracked)
|
|
106
|
+
let deleted = 0
|
|
107
|
+
for (const rel of nowUntracked) {
|
|
108
|
+
if (!checkpointSet.has(rel)) {
|
|
109
|
+
await rm(join(cwd, rel), { force: true })
|
|
110
|
+
deleted++
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. 快照时存在、现在被改/被删的未跟踪文件 → 还原
|
|
115
|
+
let restored = 0
|
|
116
|
+
for (const rel of meta.untracked) {
|
|
117
|
+
const src = join(dir, "untracked", rel)
|
|
118
|
+
if (existsSync(src)) {
|
|
119
|
+
await mkdir(dirname(join(cwd, rel)), { recursive: true })
|
|
120
|
+
await cp(src, join(cwd, rel), { force: true })
|
|
121
|
+
restored++
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { deleted, restored, patchApplied: Boolean(patch.trim()) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 只留最近 MAX_CHECKPOINTS 个 */
|
|
129
|
+
async function pruneCheckpoints(cwd) {
|
|
130
|
+
const root = checkpointRoot(cwd)
|
|
131
|
+
const ids = (await readdir(root)).sort()
|
|
132
|
+
while (ids.length > MAX_CHECKPOINTS) {
|
|
133
|
+
await rm(join(root, ids.shift()), { recursive: true, force: true })
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.mjs — 配置加载与保存
|
|
3
|
+
* 配置文件:~/.thincoder/config.json;API key 可用环境变量兜底。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
7
|
+
import { homedir } from "node:os"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
|
|
10
|
+
export const configDir = join(homedir(), ".thincoder")
|
|
11
|
+
export const configPath = join(configDir, "config.json")
|
|
12
|
+
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
provider: {
|
|
15
|
+
baseURL: "https://api.deepseek.com/v1",
|
|
16
|
+
model: "deepseek-chat",
|
|
17
|
+
},
|
|
18
|
+
agent: {
|
|
19
|
+
maxTurns: 50,
|
|
20
|
+
compactThreshold: 100000,
|
|
21
|
+
},
|
|
22
|
+
memory: {
|
|
23
|
+
dbPath: join(configDir, "memory.db"),
|
|
24
|
+
projectDir: ".thincoder/memory",
|
|
25
|
+
team: null,
|
|
26
|
+
},
|
|
27
|
+
embedding: {
|
|
28
|
+
baseURL: "https://api.siliconflow.cn/v1",
|
|
29
|
+
model: "BAAI/bge-m3",
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 已知模型的上下文窗口(前缀匹配,长的在前)。
|
|
35
|
+
* compactThreshold 未显式配置时,按窗口 * COMPACT_RATIO 自动推导。
|
|
36
|
+
*/
|
|
37
|
+
const MODEL_CONTEXT_WINDOWS = [
|
|
38
|
+
["deepseek-v4-pro", 1_000_000],
|
|
39
|
+
["deepseek-v4-flash", 256_000],
|
|
40
|
+
["deepseek-reasoner", 64_000],
|
|
41
|
+
["deepseek-chat", 64_000],
|
|
42
|
+
["moonshot", 256_000],
|
|
43
|
+
["kimi", 256_000],
|
|
44
|
+
["gpt-4.1", 1_000_000],
|
|
45
|
+
["gpt-4o", 128_000],
|
|
46
|
+
["qwen", 128_000],
|
|
47
|
+
]
|
|
48
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000
|
|
49
|
+
const COMPACT_RATIO = 0.6
|
|
50
|
+
|
|
51
|
+
export function contextWindowForModel(model) {
|
|
52
|
+
const m = (model ?? "").toLowerCase()
|
|
53
|
+
for (const [prefix, window] of MODEL_CONTEXT_WINDOWS) {
|
|
54
|
+
if (m.startsWith(prefix)) return window
|
|
55
|
+
}
|
|
56
|
+
return DEFAULT_CONTEXT_WINDOW
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 推导压缩阈值;explicit 为配置文件中显式设置的值(优先),否则按模型自动算 */
|
|
60
|
+
export function resolveCompactThreshold(explicit, model) {
|
|
61
|
+
if (explicit != null) return { value: explicit, auto: false }
|
|
62
|
+
return { value: Math.floor(contextWindowForModel(model) * COMPACT_RATIO), auto: true }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 加载配置:文件 + 环境变量兜底(key 不明文落盘时走 env)。
|
|
67
|
+
* 环境变量优先级:THINCODER_API_KEY > DEEPSEEK_API_KEY > OPENAI_API_KEY
|
|
68
|
+
*/
|
|
69
|
+
export function loadConfig() {
|
|
70
|
+
let config = {}
|
|
71
|
+
if (existsSync(configPath)) {
|
|
72
|
+
config = JSON.parse(readFileSync(configPath, "utf8"))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const merged = {
|
|
76
|
+
...DEFAULTS,
|
|
77
|
+
...config,
|
|
78
|
+
provider: { ...DEFAULTS.provider, ...config.provider },
|
|
79
|
+
agent: { ...DEFAULTS.agent, ...config.agent },
|
|
80
|
+
memory: { ...DEFAULTS.memory, ...config.memory },
|
|
81
|
+
embedding: { ...DEFAULTS.embedding, ...config.embedding },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!merged.provider.apiKey) {
|
|
85
|
+
merged.provider.apiKey =
|
|
86
|
+
process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
|
|
87
|
+
}
|
|
88
|
+
if (process.env.THINCODER_BASE_URL) merged.provider.baseURL = process.env.THINCODER_BASE_URL
|
|
89
|
+
if (process.env.THINCODER_MODEL) merged.provider.model = process.env.THINCODER_MODEL
|
|
90
|
+
if (!merged.embedding.apiKey) {
|
|
91
|
+
merged.embedding.apiKey = process.env.SILICONFLOW_API_KEY || process.env.THINCODER_EMBEDDING_API_KEY
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 压缩阈值跟模型走:配置文件显式设置的优先,否则按模型上下文窗口自动推导
|
|
95
|
+
const explicitThreshold = config.agent?.compactThreshold
|
|
96
|
+
const { value, auto } = resolveCompactThreshold(explicitThreshold, merged.provider.model)
|
|
97
|
+
merged.agent.compactThreshold = value
|
|
98
|
+
merged.agent.compactThresholdAuto = auto
|
|
99
|
+
|
|
100
|
+
return merged
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function saveConfig(config) {
|
|
104
|
+
mkdirSync(configDir, { recursive: true })
|
|
105
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8")
|
|
106
|
+
}
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context.mjs — 上下文管理与压缩
|
|
3
|
+
* token 用 length/4 粗估(不引 tokenizer 依赖)。
|
|
4
|
+
* 压缩策略:保留最早 2 条 + 最近 N 条,中间由 LLM 摘要成一条(学 kimi-code,简化版)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { chat } from "./provider.mjs"
|
|
8
|
+
|
|
9
|
+
/** 粗估一组消息的 token 数(正文 + tool_calls 参数) */
|
|
10
|
+
export function estimateTokens(messages) {
|
|
11
|
+
let chars = 0
|
|
12
|
+
for (const m of messages) {
|
|
13
|
+
if (typeof m.content === "string") chars += m.content.length
|
|
14
|
+
for (const tc of m.tool_calls ?? []) {
|
|
15
|
+
chars += (tc.function?.name?.length ?? 0) + (tc.function?.arguments?.length ?? 0)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return Math.ceil(chars / 4)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KEEP_HEAD = 2 // 最早的用户意图,不能丢
|
|
22
|
+
const KEEP_TAIL = 10 // 最近的工作现场,不能丢
|
|
23
|
+
|
|
24
|
+
const SUMMARIZE_PROMPT = `你是一个对话压缩器。把下面的 agent 工作记录压缩成一份紧凑的摘要,供后续对话作为上下文使用。
|
|
25
|
+
要求:
|
|
26
|
+
- 保留:用户的原始需求、做出的决策、修改过的文件及原因、未解决的问题、下一步计划
|
|
27
|
+
- 丢弃:客套话、重复内容、工具输出的细枝末节
|
|
28
|
+
- 用中文条目式输出,控制在 500 字以内
|
|
29
|
+
|
|
30
|
+
工作记录:
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 如果历史超长则压缩。返回是否发生了压缩。
|
|
35
|
+
* 只在循环的安全点调用(history 末尾是 user 消息时)。
|
|
36
|
+
*/
|
|
37
|
+
export async function compressIfNeeded(agent, threshold) {
|
|
38
|
+
const history = agent.history
|
|
39
|
+
if (estimateTokens(history) <= threshold) return false
|
|
40
|
+
if (history.length <= KEEP_HEAD + KEEP_TAIL + 1) return false
|
|
41
|
+
|
|
42
|
+
// 切分:head / middle(被摘要) / tail
|
|
43
|
+
// tail 起点必须避开孤儿 tool 消息(其 assistant tool_calls 在 middle 里无妨,middle 会被整体摘要成纯文本)
|
|
44
|
+
let tailStart = history.length - KEEP_TAIL
|
|
45
|
+
while (tailStart > KEEP_HEAD && history[tailStart].role === "tool") {
|
|
46
|
+
tailStart++
|
|
47
|
+
}
|
|
48
|
+
if (tailStart <= KEEP_HEAD) return false // 没有可压缩的中间段
|
|
49
|
+
|
|
50
|
+
const head = history.slice(0, KEEP_HEAD)
|
|
51
|
+
const middle = history.slice(KEEP_HEAD, tailStart)
|
|
52
|
+
const tail = history.slice(tailStart)
|
|
53
|
+
|
|
54
|
+
const serialized = middle
|
|
55
|
+
.map((m) => {
|
|
56
|
+
const toolNote = m.tool_calls ? ` [调用了工具: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
|
|
57
|
+
const content = typeof m.content === "string" ? m.content.slice(0, 2000) : ""
|
|
58
|
+
return `[${m.role}]${toolNote} ${content}`
|
|
59
|
+
})
|
|
60
|
+
.join("\n")
|
|
61
|
+
|
|
62
|
+
const summary = await chat(agent.provider, {
|
|
63
|
+
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
agent.history = [
|
|
67
|
+
...head,
|
|
68
|
+
{
|
|
69
|
+
role: "user",
|
|
70
|
+
content: `[前文摘要:以下是更早对话的压缩记录]\n${summary.content}`,
|
|
71
|
+
},
|
|
72
|
+
{ role: "assistant", content: "了解,我会基于这份摘要和最近的对话继续工作。" },
|
|
73
|
+
...tail,
|
|
74
|
+
]
|
|
75
|
+
return true
|
|
76
|
+
}
|