thincoder 0.2.0 → 0.4.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,14 @@
1
+ You are a coding subagent. The parent agent dispatched you to handle a self-contained coding task. The parent CANNOT see your context — it only sees your final report.
2
+
3
+ Guidelines:
4
+ - Work independently: read files, make edits, run tests
5
+ - Be thorough: include what you did, which files you changed, why, and any caveats
6
+ - If the task is ambiguous, note the ambiguity in your report; do not ask the user
7
+ - BEFORE finishing, verify your changes:
8
+ 1. Run the project's tests — confirm they pass
9
+ 2. Read every file you changed — catch leftover debug code, stale comments, or incomplete edits
10
+ 3. Check that comments and docstrings match what the code actually does
11
+ - Your last message IS the report the parent sees — make it complete and self-contained
12
+ - List every file you changed (with paths), why you changed it, and whether tests passed
13
+
14
+ IMPORTANT — Tool permissions: when you see "permission denied by user" for a tool, it means the parent has not granted that tool. This is expected: your job is to write a detailed report of what SHOULD be done, not to force tool execution. Describe the needed changes clearly in your report so the parent agent can apply them.
package/src/config.mjs CHANGED
@@ -27,7 +27,7 @@ const DEFAULTS = {
27
27
  providers: [{ name: "deepseek", ...deepseekPreset }],
28
28
  activeProvider: "deepseek",
29
29
  agent: {
30
- maxTurns: 50,
30
+ maxTurns: 100,
31
31
  compactThreshold: 100000,
32
32
  },
33
33
  memory: {
@@ -39,6 +39,9 @@ const DEFAULTS = {
39
39
  baseURL: "https://api.siliconflow.cn/v1",
40
40
  model: "BAAI/bge-m3",
41
41
  },
42
+ mcp: {
43
+ servers: [],
44
+ },
42
45
  }
43
46
 
44
47
  /**
@@ -60,7 +63,9 @@ const MODEL_CONTEXT_WINDOWS = [
60
63
  ["qwen", 128_000],
61
64
  ]
62
65
  const DEFAULT_CONTEXT_WINDOW = 128_000
63
- const COMPACT_RATIO = 0.6
66
+ // 窗口利用率上限:0.8(DeepSeek 内部即全窗口;压缩本身要花一次 LLM 调用,过早压缩是纯浪费。
67
+ // 留 20% 余量给压缩后的尾部增长与输出 token)
68
+ const COMPACT_RATIO = 0.8
64
69
 
65
70
  export function contextWindowForModel(model) {
66
71
  const m = (model ?? "").toLowerCase()
@@ -133,10 +138,15 @@ export function loadConfig() {
133
138
  if (process.env.THINCODER_BASE_URL) runtimeProvider.baseURL = process.env.THINCODER_BASE_URL
134
139
  if (process.env.THINCODER_MODEL) runtimeProvider.model = process.env.THINCODER_MODEL
135
140
 
136
- // apiKey 还可用通用环境变量兜底(当 providers 里没配 key 时)
141
+ // apiKey 还可用环境变量兜底(当 providers 里没配 key 时)
142
+ // 提供商专用的环境变量只对同名 provider 生效,避免 key 串到错误的端点
143
+ if (!runtimeProvider.apiKey) {
144
+ const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
145
+ const keyVar = envMap[merged.activeProvider]
146
+ if (keyVar && process.env[keyVar]) runtimeProvider.apiKey = process.env[keyVar]
147
+ }
137
148
  if (!runtimeProvider.apiKey) {
138
- runtimeProvider.apiKey =
139
- process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
149
+ runtimeProvider.apiKey = process.env.THINCODER_API_KEY
140
150
  }
141
151
 
142
152
  // embedding apiKey
package/src/context.mjs CHANGED
@@ -6,11 +6,12 @@
6
6
 
7
7
  import { chat } from "./provider.mjs"
8
8
 
9
- /** 粗估一组消息的 token 数(正文 + tool_calls 参数) */
9
+ /** 粗估一组消息的 token 数(正文 + 思考链 + tool_calls 参数) */
10
10
  export function estimateTokens(messages) {
11
11
  let chars = 0
12
12
  for (const m of messages) {
13
13
  if (typeof m.content === "string") chars += m.content.length
14
+ if (typeof m.reasoning_content === "string") chars += m.reasoning_content.length
14
15
  for (const tc of m.tool_calls ?? []) {
15
16
  chars += (tc.function?.name?.length ?? 0) + (tc.function?.arguments?.length ?? 0)
16
17
  }
@@ -30,9 +31,16 @@ const SUMMARIZE_PROMPT = `你是一个对话压缩器。把下面的 agent 工
30
31
  工作记录:
31
32
  `
32
33
 
34
+ /** 压缩后的上下文前缀,告知 agent 发生了什么 */
35
+ const COMPACTION_PREFIX =
36
+ "[Context was automatically compacted. Below is a summary of earlier work. " +
37
+ "Trust its conclusions — don't redo what it reports as done — but re-verify " +
38
+ "transient state (open files, running processes) with tools.]\n\n"
39
+
33
40
  /**
34
41
  * 如果历史超长则压缩。返回是否发生了压缩。
35
42
  * 只在循环的安全点调用(history 末尾是 user 消息时)。
43
+ * 压缩后自动回注 task 列表状态。
36
44
  */
37
45
  export async function compressIfNeeded(agent, threshold) {
38
46
  const history = agent.history
@@ -63,14 +71,41 @@ export async function compressIfNeeded(agent, threshold) {
63
71
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
64
72
  })
65
73
 
74
+ // 摘要正文内嵌 task 快照(对齐 kimi-code 的 postProcessSummary)——
75
+ // 否则二次压缩时 task 列表会随旧提醒消息一起被摘要器丢掉
76
+ let compacted = COMPACTION_PREFIX + summary.content
77
+ if (agent.tasks.length > 0) {
78
+ const taskSummary = agent.tasks.map((t) => `- [${t.status}] ${t.title}`).join("\n")
79
+ compacted += `\n\n## Task List\n${taskSummary}`
80
+ }
81
+
66
82
  agent.history = [
67
83
  ...head,
68
- {
69
- role: "user",
70
- content: `[前文摘要:以下是更早对话的压缩记录]\n${summary.content}`,
71
- },
72
- { role: "assistant", content: "了解,我会基于这份摘要和最近的对话继续工作。" },
84
+ { role: "user", content: compacted },
85
+ { role: "assistant", content: "Understood. I'll continue from this summary, re-verifying anything transient." },
73
86
  ...tail,
74
87
  ]
88
+
89
+ // 压缩后回注 task 列表(agent 需要知道自己做到哪了)
90
+ if (agent.tasks.length > 0) {
91
+ const taskSummary = agent.tasks.map((t) => `- [${t.status}] ${t.title}`).join("\n")
92
+ agent.history.push({
93
+ role: "user",
94
+ content: `[System reminder: your current task list after compaction:\n${taskSummary}\nContinue from where you left off.]`,
95
+ })
96
+ }
97
+
98
+ // 重置跟踪计数器(上下文已重建,从头开始计数)
99
+ agent._turnsSinceTaskUpdate = 0
100
+ agent._turnsInPlanMode = 0
101
+
102
+ // plan mode 中压缩:重新注入 plan 模式引导
103
+ if (agent.planMode) {
104
+ agent.history.push({
105
+ role: "user",
106
+ content: "[System reminder: plan mode is active. Explore the codebase read-only, design your solution, then call plan with action='exit' to present it for user approval.]",
107
+ })
108
+ }
109
+
75
110
  return true
76
111
  }
@@ -0,0 +1,10 @@
1
+ You are a codebase exploration specialist — an explore subagent. Your role is to search, read, and analyze. You do NOT have file editing tools.
2
+
3
+ Guidelines:
4
+ - On start, quickly orient yourself: run `git branch --show-current`, `git status --short`, and `git log -5 --oneline` to understand the repo state
5
+ - Use Glob for file discovery, Grep for content search, Read for known paths
6
+ - Run read-only shell commands (git log, git diff, ls, find) when helpful
7
+ - Use WebSearch or Fetch when external context is needed (docs, error messages)
8
+ - Issue parallel tool calls whenever possible — read multiple files at once
9
+ - Complete the search efficiently and report findings in a structured format
10
+ - If something is ambiguous, note it in your report; do not ask the user
package/src/mcp.mjs ADDED
@@ -0,0 +1,359 @@
1
+ /**
2
+ * mcp.mjs — MCP (Model Context Protocol) client
3
+ * 零依赖:stdio transport (spawn + JSON-RPC) + HTTP transport (fetch + SSE)。
4
+ * config: { command, args?, name } 或 { url, name, headers? }
5
+ */
6
+
7
+ import { spawn } from "node:child_process"
8
+
9
+ const INIT_TIMEOUT_MS = 30_000
10
+ const CALL_TIMEOUT_MS = 120_000
11
+
12
+ // ---- JSON-RPC helpers ----
13
+
14
+ let nextRpcId = 0
15
+ function rpcId() {
16
+ return String(++nextRpcId) // 自增:随机数可能碰撞串响应
17
+ }
18
+
19
+ // ---- stdio transport ----
20
+
21
+ function stdioTransport(command, args) {
22
+ // Windows 上 npx 等命令是 .cmd,Node 不带 shell 拒 spawn(EINVAL);
23
+ // shell:true 又触发 DEP0190 且不转义参数——显式走 cmd.exe 并自己加引号;
24
+ // windowsVerbatimArguments 防止 Node 把内层引号转义成 \"(cmd 不认,会把引号当字面量传下去)
25
+ const spawnOptions = { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env: { ...process.env } }
26
+ const child =
27
+ process.platform === "win32" && !/\.exe$/i.test(command)
28
+ ? spawn("cmd.exe", ["/d", "/s", "/c", [command, ...(args ?? [])].map(quoteArg).join(" ")], {
29
+ ...spawnOptions,
30
+ windowsVerbatimArguments: true,
31
+ })
32
+ : spawn(command, args ?? [], spawnOptions)
33
+
34
+ const pending = new Map()
35
+ let buffer = ""
36
+ let stderrTail = "" // 诊断用:server 起不来时给用户一点线索
37
+ let spawnError = null
38
+ let closed = false
39
+
40
+ const failAll = (message) => {
41
+ for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
42
+ pending.clear()
43
+ }
44
+
45
+ child.stdout.on("data", (chunk) => {
46
+ buffer += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
47
+ const lines = buffer.split("\n")
48
+ buffer = lines.pop() ?? ""
49
+ for (const line of lines) {
50
+ if (!line.trim()) continue
51
+ try {
52
+ const msg = JSON.parse(line)
53
+ const resolver = pending.get(msg.id)
54
+ if (resolver) {
55
+ pending.delete(msg.id)
56
+ resolver(msg)
57
+ }
58
+ } catch {
59
+ // 非 JSON 行忽略
60
+ }
61
+ }
62
+ })
63
+
64
+ child.stderr.on("data", (chunk) => {
65
+ stderrTail = (stderrTail + chunk.toString()).slice(-2000)
66
+ })
67
+
68
+ // spawn 失败(命令不存在/EINVAL):没有这个监听,error 事件会崩掉整个进程
69
+ child.on("error", (error) => {
70
+ spawnError = error
71
+ closed = true
72
+ failAll(`spawn failed: ${error.message}`)
73
+ })
74
+
75
+ child.on("close", () => {
76
+ closed = true
77
+ const lastLine = stderrTail.trim().split("\n").pop()
78
+ failAll(`Connection closed${lastLine ? ` | stderr: ${lastLine}` : ""}`)
79
+ })
80
+
81
+ const send = (method, params) => {
82
+ if (spawnError) return Promise.resolve({ id: null, error: { code: -32000, message: `spawn failed: ${spawnError.message}` } })
83
+ if (closed) return Promise.reject(new Error("MCP connection closed"))
84
+ const id = rpcId()
85
+ const promise = new Promise((resolve) => pending.set(id, resolve))
86
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
87
+ return withTimeout(promise, CALL_TIMEOUT_MS).finally(() => pending.delete(id))
88
+ }
89
+
90
+ // notification:无 id,不期待响应(协议要求)
91
+ const notify = (method, params) => {
92
+ if (!closed) child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
93
+ }
94
+
95
+ return { send, notify, close: () => { if (!closed) child.kill() } }
96
+ }
97
+
98
+ // ---- HTTP + SSE transport (Streamable HTTP) ----
99
+
100
+ function httpTransport(baseURL, extraHeaders = {}) {
101
+ const url = baseURL.replace(/\/+$/, "")
102
+ let sessionId = null
103
+ let closed = false
104
+ let eventSource = null
105
+ let abortController = null
106
+
107
+ const headers = () => {
108
+ const h = { "Content-Type": "application/json", Accept: "text/event-stream, application/json", ...extraHeaders }
109
+ if (sessionId) h["Mcp-Session-Id"] = sessionId
110
+ return h
111
+ }
112
+
113
+ const pending = new Map()
114
+
115
+ // SSE 解析器:从 response body 逐行读,处理 data: / event: / id: / 空行(dispatch)
116
+ async function* parseSSE(response) {
117
+ const reader = response.body.getReader()
118
+ const decoder = new TextDecoder()
119
+ let buf = ""
120
+ let current = { data: "", event: "message" }
121
+ try {
122
+ while (true) {
123
+ const { done, value } = await reader.read()
124
+ if (done) break
125
+ buf += decoder.decode(value, { stream: true })
126
+ const lines = buf.split("\n")
127
+ buf = lines.pop() ?? ""
128
+ for (const raw of lines) {
129
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw
130
+ if (line === "") {
131
+ if (current.data) {
132
+ yield { event: current.event, data: current.data.trimEnd() }
133
+ current = { data: "", event: "message" }
134
+ }
135
+ } else if (line.startsWith("data:")) {
136
+ current.data += (current.data ? "\n" : "") + line.slice(5).replace(/^ /, "")
137
+ } else if (line.startsWith("event:")) {
138
+ current.event = line.slice(6).trim()
139
+ }
140
+ }
141
+ }
142
+ } finally {
143
+ reader.releaseLock()
144
+ }
145
+ }
146
+
147
+ // 打开 SSE 长连接(用于接收服务端推送)
148
+ async function openSSE() {
149
+ if (closed) return
150
+ abortController?.abort()
151
+ abortController = new AbortController()
152
+ const resp = await fetch(url + "/sse", {
153
+ method: "GET",
154
+ headers: { Accept: "text/event-stream" },
155
+ signal: abortController.signal,
156
+ })
157
+ if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
158
+ eventSource = parseSSE(resp)
159
+
160
+ // 后台消费 SSE 事件并分发到 pending
161
+ ;(async () => {
162
+ try {
163
+ for await (const { data } of eventSource) {
164
+ if (closed) break
165
+ try {
166
+ const msg = JSON.parse(data)
167
+ const resolver = pending.get(msg.id)
168
+ if (resolver) {
169
+ pending.delete(msg.id)
170
+ resolver(msg)
171
+ }
172
+ // 没有 pending resolver 的可能是通知,忽略
173
+ } catch { /* 非 JSON,忽略 */ }
174
+ }
175
+ } catch (error) {
176
+ if (!closed) {
177
+ for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: `SSE error: ${error.message}` } })
178
+ }
179
+ }
180
+ })()
181
+ }
182
+
183
+ // POST JSON-RPC 请求,同时监听响应
184
+ async function postRequest(method, params) {
185
+ const id = rpcId()
186
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params })
187
+
188
+ // 如果有活跃的 SSE 连接,服务器会通过 SSE 推回响应
189
+ if (eventSource) {
190
+ return new Promise((resolve) => {
191
+ pending.set(id, resolve)
192
+ fetch(url + "/messages", { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
193
+ .catch((e) => {
194
+ pending.delete(id)
195
+ resolve({ id, error: { code: -32000, message: `POST failed: ${e.message}` } })
196
+ })
197
+ })
198
+ }
199
+
200
+ // 没有 SSE:纯 HTTP POST,响应就是 JSON-RPC
201
+ const resp = await fetch(url + "/messages", {
202
+ method: "POST",
203
+ headers: headers(),
204
+ body,
205
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
206
+ })
207
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
208
+
209
+ const ct = resp.headers.get("content-type") ?? ""
210
+ const newSessionId = resp.headers.get("Mcp-Session-Id")
211
+ if (newSessionId) sessionId = newSessionId
212
+
213
+ if (ct.includes("text/event-stream")) {
214
+ // 服务器返回 SSE:第一个事件是响应
215
+ const sse = parseSSE(resp)
216
+ for await (const { data } of sse) {
217
+ try {
218
+ const msg = JSON.parse(data)
219
+ if (msg.id === id) return msg
220
+ // 可能是通知
221
+ } catch { /* skip */ }
222
+ }
223
+ return { id, error: { code: -32000, message: "No JSON-RPC response in SSE stream" } }
224
+ }
225
+
226
+ // 纯 JSON 响应
227
+ return resp.json()
228
+ }
229
+
230
+ const send = async (method, params) => withTimeout(postRequest(method, params), CALL_TIMEOUT_MS)
231
+
232
+ // notification:无 id,不期待响应(协议要求)
233
+ const notify = (method, params) => {
234
+ fetch(url + "/messages", {
235
+ method: "POST",
236
+ headers: headers(),
237
+ body: JSON.stringify({ jsonrpc: "2.0", method, params }),
238
+ signal: AbortSignal.timeout(10_000),
239
+ }).catch(() => {})
240
+ }
241
+
242
+ const close = () => {
243
+ closed = true
244
+ abortController?.abort()
245
+ for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: "Connection closed" } })
246
+ pending.clear()
247
+ }
248
+
249
+ return { send, notify, close, openSSE, url, headers: extraHeaders }
250
+ }
251
+
252
+ // ---- MCP lifecycle ----
253
+
254
+ function buildTools(mcpTools, transport, config) {
255
+ const prefix = config.name ? `${config.name}_` : "mcp_"
256
+ return mcpTools.map((t) => ({
257
+ name: prefix + sanitizeToolName(t.name),
258
+ description: t.description ?? `MCP tool: ${t.name}`,
259
+ parameters: t.inputSchema ?? { type: "object", properties: {} },
260
+ readonly: false,
261
+ async execute(args) {
262
+ const resp = await transport.send("tools/call", { name: t.name, arguments: args })
263
+ if (resp.error) throw new Error(`MCP tool "${t.name}": ${resp.error.message}`)
264
+ const content = resp.result?.content ?? []
265
+ return content
266
+ .map((c) => (c.type === "text" ? c.text : c.type === "resource" ? `[resource: ${c.resource?.uri}]` : JSON.stringify(c)))
267
+ .join("\n") || "(no output)"
268
+ },
269
+ _mcpTransport: transport,
270
+ _mcpName: config.name,
271
+ }))
272
+ }
273
+
274
+ async function doInitialize(transport, name) {
275
+ const initResp = await withTimeout(
276
+ transport.send("initialize", {
277
+ protocolVersion: "2024-11-05",
278
+ capabilities: {},
279
+ clientInfo: { name: "thincoder", version: "1.0.0" },
280
+ }),
281
+ INIT_TIMEOUT_MS,
282
+ )
283
+ if (initResp.error) throw new Error(`initialize error: ${initResp.error.message}`)
284
+ transport.notify?.("notifications/initialized", {})
285
+
286
+ const toolsResp = await transport.send("tools/list", {})
287
+ if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
288
+ return toolsResp.result?.tools ?? []
289
+ }
290
+
291
+ /**
292
+ * 连接一个 MCP server。
293
+ * stdio: { name, command, args? }
294
+ * http: { name, url, headers? }
295
+ */
296
+ export async function connectMcpServer(config) {
297
+ if (config.url) {
298
+ const transport = httpTransport(config.url, config.headers ?? {})
299
+ try {
300
+ await transport.openSSE()
301
+ } catch {
302
+ // 不支持 GET /sse 的 server(纯 Streamable HTTP POST):降级为无 SSE 模式
303
+ }
304
+ const mcpTools = await doInitialize(transport, config.name ?? config.url)
305
+ return buildTools(mcpTools, transport, config)
306
+ }
307
+
308
+ if (config.command) {
309
+ const transport = stdioTransport(config.command, config.args ?? [])
310
+ try {
311
+ const mcpTools = await doInitialize(transport, config.name ?? config.command)
312
+ return buildTools(mcpTools, transport, config)
313
+ } catch (error) {
314
+ transport.close()
315
+ throw error
316
+ }
317
+ }
318
+
319
+ throw new Error(`MCP server "${config.name}": needs either 'command' (stdio) or 'url' (http)`)
320
+ }
321
+
322
+ export function closeAllMcp(agent) {
323
+ for (const t of agent.tools) {
324
+ if (t._mcpTransport) t._mcpTransport.close()
325
+ }
326
+ }
327
+
328
+ export function removeMcpTools(agent, serverName) {
329
+ const keep = []
330
+ for (const t of agent.tools) {
331
+ if (t._mcpName === serverName) {
332
+ if (t._mcpTransport) t._mcpTransport.close()
333
+ } else {
334
+ keep.push(t)
335
+ }
336
+ }
337
+ agent.tools = keep
338
+ }
339
+
340
+ // ---- helpers ----
341
+
342
+ function sanitizeToolName(name) {
343
+ return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
344
+ }
345
+
346
+ /** cmd.exe 参数加引号(含空格/引号时) */
347
+ function quoteArg(s) {
348
+ return /[\s"]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
349
+ }
350
+
351
+ function withTimeout(promise, ms) {
352
+ let timer
353
+ const timeout = new Promise((_, reject) => {
354
+ timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)
355
+ timer.unref?.() // 不拖住进程退出
356
+ })
357
+ // 竞速结束后清掉定时器,不留垃圾
358
+ return Promise.race([promise.finally(() => clearTimeout(timer)), timeout])
359
+ }
package/src/session.mjs CHANGED
@@ -23,6 +23,8 @@ export function saveSession(agent) {
23
23
  updatedAt: Date.now(),
24
24
  history: agent.history,
25
25
  tasks: agent.tasks ?? [],
26
+ planMode: agent.planMode ?? false,
27
+ goal: agent.goal ?? null,
26
28
  }
27
29
  const p = sessionPath(agent.cwd)
28
30
  mkdirSync(dirname(p), { recursive: true })
package/src/skills.mjs ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * skills.mjs — 技能系统
3
+ * 从 .thincoder/skills/ 目录发现 .md 技能文件,
4
+ * 注入到 system prompt 供 agent 按需加载。
5
+ * 用 skill 工具激活指定技能,内容以 <skill-loaded> 包裹写入对话历史。
6
+ */
7
+
8
+ import { readFile, readdir, stat } from "node:fs/promises"
9
+ import { join } from "node:path"
10
+
11
+ /**
12
+ * 扫描 .thincoder/skills/ 目录,返回技能列表。
13
+ * 每个技能:{ name, path, description } — name 取文件名(去扩展名)。
14
+ * 目录不存在或无文件返回空数组。
15
+ */
16
+ export async function loadSkills(cwd) {
17
+ const dir = join(cwd, ".thincoder", "skills")
18
+ let entries
19
+ try {
20
+ entries = await readdir(dir)
21
+ } catch {
22
+ return []
23
+ }
24
+ const skills = []
25
+ for (const name of entries) {
26
+ if (!/^[a-zA-Z0-9_-]+\.md$/.test(name)) continue // 与 readSkill 的名字校验一致,防"列得出、读不了"
27
+ const p = join(dir, name)
28
+ try {
29
+ const s = await stat(p)
30
+ if (!s.isFile()) continue
31
+ // 提取描述(前 400 字符里第一段非空、非标题行)
32
+ const head = await readFile(p, "utf8")
33
+ const body = head.slice(0, 400).split("\n")
34
+ let desc = ""
35
+ for (const line of body) {
36
+ const t = line.trim()
37
+ if (t && !t.startsWith("#") && !t.startsWith("---")) {
38
+ desc = t.slice(0, 120)
39
+ break
40
+ }
41
+ }
42
+ skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
43
+ } catch {
44
+ // 读失败跳过
45
+ }
46
+ }
47
+ return skills
48
+ }
49
+
50
+ /**
51
+ * 生成技能列表文本,注入 system prompt。
52
+ * 最多 3 个(占位少),超过则标 "... and N more"。
53
+ */
54
+ export function formatSkillListing(skills) {
55
+ if (skills.length === 0) return ""
56
+ const listed = skills.slice(0, 3)
57
+ const lines = listed.map((s) => `- **${s.name}**: ${s.description}`)
58
+ 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
+ }
61
+
62
+ /**
63
+ * 读取指定技能文件的完整内容。
64
+ * 返回文本,找不到返回 null。
65
+ */
66
+ export async function readSkill(cwd, name) {
67
+ // 安全检查:技能名只能是字母数字 + 连字符/下划线
68
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null
69
+ const p = join(cwd, ".thincoder", "skills", `${name}.md`)
70
+ try {
71
+ return await readFile(p, "utf8")
72
+ } catch {
73
+ return null
74
+ }
75
+ }
@@ -0,0 +1,13 @@
1
+ Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.
2
+
3
+ Parameters:
4
+ - command (required): Shell command to execute
5
+ - timeout: Timeout in milliseconds (default 120000, max ~300000)
6
+
7
+ Notes:
8
+ - There is NO TTY — editors, pagers (vim, less), and interactive prompts WILL hang. Always pass non-interactive flags: `git commit -m`, `git --no-pager`, `-y`/`--yes` where applicable
9
+ - The environment sets GIT_PAGER=cat, PAGER=cat, EDITOR=true, TERM=dumb — but still always use non-interactive flags
10
+ - Output is capped at ~50000 chars; if you need more, redirect to a file and read it
11
+ - On Windows, use Unix shell syntax inside bash commands (Git Bash): forward slashes, `/dev/null` not `NUL`
12
+ - Never use bash to read, copy, or transmit secret files (.env, keys, tokens)
13
+ - Do NOT run destructive commands (rm -rf, force-push, drop table) without explicit user confirmation
@@ -0,0 +1,9 @@
1
+ Delete a file. Use when the agent created a temporary or junk file that should be cleaned up, or when the user explicitly asks to delete something. Refuses to delete git-tracked files as a safety measure — tracked files should be edited or removed via bash with explicit user confirmation.
2
+
3
+ Parameters:
4
+ - path (required): File path, relative to cwd or absolute
5
+
6
+ Notes:
7
+ - Untracked or non-git files are deleted immediately
8
+ - Tracked files require force=true (user must confirm separately)
9
+ - Directories must be removed with bash (rm -rf)
@@ -0,0 +1,12 @@
1
+ Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.
2
+
3
+ Parameters:
4
+ - path (required): File path
5
+ - old_string (required): Exact text to find and replace
6
+ - new_string (required): Replacement text
7
+ - replace_all: Replace all occurrences instead of just one (default false)
8
+
9
+ Notes:
10
+ - Prefer this over write for targeted edits — it's safer and keeps diffs small
11
+ - If old_string matches zero times: error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
12
+ - Never fabricate the old_string — copy it verbatim from the actual file using read first
@@ -0,0 +1,10 @@
1
+ Fetch a URL and return its content as text. HTML pages are stripped to readable text. Use after websearch to read full documents.
2
+
3
+ Parameters:
4
+ - url (required): http/https URL
5
+
6
+ Notes:
7
+ - Follows redirects automatically
8
+ - Timeout: 20 seconds
9
+ - HTML pages are converted to plain text (scripts, styles, navigation stripped)
10
+ - Non-HTML responses are returned as-is (truncated at ~50000 chars)
@@ -0,0 +1,11 @@
1
+ Show git diff (unified format). Use to see uncommitted changes, staged changes, or diff against a specific ref.
2
+
3
+ Parameters:
4
+ - staged: Show staged changes (default false, shows working tree diff)
5
+ - path: File or directory to diff (default all)
6
+ - ref: Compare against a ref (default HEAD)
7
+
8
+ Notes:
9
+ - Only works inside a git repository
10
+ - Output is standard unified diff — LLMs understand this natively
11
+ - If no changes, returns "(no changes)"
@@ -0,0 +1,10 @@
1
+ Show recent git commit history. Use to understand the project's recent changes, conventions, and pace.
2
+
3
+ Parameters:
4
+ - count: Number of commits to show (default 10)
5
+ - path: File or directory to show history for (default all)
6
+ - oneline: Compact one-line-per-commit format (default false)
7
+
8
+ Notes:
9
+ - Only works inside a git repository
10
+ - Output includes hash, author, date, and message
@@ -0,0 +1,8 @@
1
+ Show structured git status. Use to understand the current working tree state: what files are staged, modified, untracked, or conflicting.
2
+
3
+ Returns categorized lists: staged, unstaged, untracked, conflicts.
4
+
5
+ Notes:
6
+ - Only works inside a git repository
7
+ - Output is categorized for easy parsing by the agent
8
+ - No side effects — read-only