thincoder 0.8.2 → 0.8.4

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.
Files changed (98) hide show
  1. package/README.md +2 -1
  2. package/bin/thincoder.mjs +25 -25
  3. package/package.json +1 -1
  4. package/src/agent/dispatch.mjs +17 -12
  5. package/src/agent/helpers.mjs +22 -11
  6. package/src/agent/setup.mjs +32 -10
  7. package/src/agent-tools/goal.mjs +12 -11
  8. package/src/agent-tools/plan.mjs +5 -6
  9. package/src/agent-tools/recent-changes.mjs +3 -3
  10. package/src/agent-tools/skill.mjs +6 -6
  11. package/src/agent-tools/subagent.mjs +19 -18
  12. package/src/agent-tools/task.mjs +7 -26
  13. package/src/agent-tools/verify.mjs +21 -19
  14. package/src/agent-tools.mjs +3 -3
  15. package/src/agent.mjs +82 -92
  16. package/src/cli/distill-command.mjs +2 -2
  17. package/src/cli/make-agent.mjs +9 -9
  18. package/src/cli/memory-command.mjs +1 -1
  19. package/src/cli/permission.mjs +3 -3
  20. package/src/cli/setup-wizard.mjs +15 -15
  21. package/src/config.mjs +56 -53
  22. package/src/context.mjs +59 -61
  23. package/src/distill.mjs +35 -35
  24. package/src/embedding.mjs +17 -17
  25. package/src/git/checkpoint.mjs +211 -38
  26. package/src/git/gitmem.mjs +21 -20
  27. package/src/markdown.mjs +13 -13
  28. package/src/mcp/helpers.mjs +6 -1
  29. package/src/mcp/transport-http.mjs +2 -1
  30. package/src/mcp/transport-stdio.mjs +3 -2
  31. package/src/mcp/transport-ws.mjs +3 -2
  32. package/src/mcp.mjs +5 -2
  33. package/src/memory/code-index.mjs +16 -14
  34. package/src/memory/code-sync.mjs +36 -26
  35. package/src/memory/core.mjs +42 -35
  36. package/src/memory/docs.mjs +24 -15
  37. package/src/memory/schema.mjs +28 -27
  38. package/src/memory.mjs +2 -2
  39. package/src/prompts/coder.md +0 -6
  40. package/src/prompts/discipline.md +9 -0
  41. package/src/prompts/main.md +24 -24
  42. package/src/prompts/system.md +21 -16
  43. package/src/provider/core.mjs +11 -6
  44. package/src/provider/index.mjs +2 -2
  45. package/src/provider/rate.mjs +11 -11
  46. package/src/session.mjs +73 -42
  47. package/src/skills.mjs +17 -17
  48. package/src/tools/checkpoint.md +6 -2
  49. package/src/tools/file.mjs +20 -14
  50. package/src/tools/git.mjs +83 -12
  51. package/src/tools/index.mjs +1 -1
  52. package/src/tools/ls.md +1 -1
  53. package/src/tools/patch.mjs +20 -18
  54. package/src/tools/repomap-parse.mjs +17 -17
  55. package/src/tools/repomap.mjs +29 -29
  56. package/src/tools/shared.mjs +55 -28
  57. package/src/tools/system.mjs +163 -118
  58. package/src/tools/web.mjs +6 -6
  59. package/src/tui/agent-turn.mjs +82 -26
  60. package/src/tui/ansi.mjs +5 -3
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-auto.mjs +3 -12
  63. package/src/tui/cmd-clear.mjs +1 -1
  64. package/src/tui/cmd-config.mjs +108 -23
  65. package/src/tui/cmd-exit.mjs +2 -2
  66. package/src/tui/cmd-extract.mjs +11 -3
  67. package/src/tui/cmd-goal.mjs +3 -12
  68. package/src/tui/cmd-help.mjs +2 -2
  69. package/src/tui/cmd-init.mjs +3 -3
  70. package/src/tui/cmd-mcp.mjs +38 -25
  71. package/src/tui/cmd-model.mjs +1 -1
  72. package/src/tui/cmd-new.mjs +33 -13
  73. package/src/tui/cmd-plan.mjs +3 -12
  74. package/src/tui/cmd-reindex.mjs +2 -2
  75. package/src/tui/cmd-restore.mjs +1 -1
  76. package/src/tui/cmd-session.mjs +1 -1
  77. package/src/tui/cmd-skills.mjs +1 -1
  78. package/src/tui/cmd-think.mjs +4 -11
  79. package/src/tui/config-helpers.mjs +6 -6
  80. package/src/tui/distill-cmd.mjs +4 -3
  81. package/src/tui/index.mjs +131 -72
  82. package/src/tui/interaction.mjs +13 -12
  83. package/src/tui/key-handler.mjs +34 -26
  84. package/src/tui/layout.mjs +22 -17
  85. package/src/tui/pickers.mjs +19 -19
  86. package/src/tui/render-frame.mjs +37 -11
  87. package/src/tui/render.mjs +25 -24
  88. package/src/tui/slash-commands.mjs +22 -22
  89. package/src/tui/startup.mjs +15 -14
  90. package/src/tui/wizard.mjs +11 -11
  91. package/src/tui.mjs +2 -2
  92. package/bin/thincoder.js +0 -4
  93. package/src/tools/bash.mjs +0 -144
  94. package/src/tools/glob.mjs +0 -51
  95. package/src/tools/grep.mjs +0 -100
  96. package/src/tools/ls.mjs +0 -36
  97. package/src/tools.mjs +0 -2
  98. package/src/tui-render.mjs +0 -4
@@ -1,8 +1,8 @@
1
1
  /**
2
- * slash-commands.mjs — 斜杠命令定义、分发、Tab 补全。
3
- * 每个子命令的具体实现都在独立的 cmd-*.mjs 中,本文件只做 dispatch
2
+ * slash-commands.mjs — slash command definitions, dispatch, Tab completion.
3
+ * Each subcommand's implementation lives in its own cmd-*.mjs file; this file only does dispatch.
4
4
  *
5
- * ctx 对象由 index.mjs 注入,透传给各 handler
5
+ * ctx object is injected by index.mjs and forwarded to each handler:
6
6
  * { agent, state, distillOpts, pushLine, pushLabel, render,
7
7
  * openPicker, openModelPicker, setProviderKey, runDistill,
8
8
  * persistRaw, syncProviderField, maskKey, exit, SLASH_COMMANDS }
@@ -33,21 +33,21 @@ export const SLASH_COMMANDS = [
33
33
  { name: "/model", group: "Agent", desc: "select model & manage providers" },
34
34
  { name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
35
35
  { name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
36
- { name: "/init", group: "Tools", desc: "generate project AGENTS.md skeleton" },
37
- { name: "/skills", group: "Tools", desc: "list project skills" },
38
- { name: "/mcp", group: "Tools", desc: "manage MCP servers" },
39
- { name: "/config", group: "Config", desc: "config management (embedding / agent)" },
40
- { name: "/reindex", group: "Config", desc: "rebuild memory index" },
36
+ { name: "/config", group: "Agent", desc: "config management (embedding / agent)" },
41
37
  { name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
42
38
  { name: "/session", group: "Session", desc: "list/switch archived sessions" },
43
39
  { name: "/clear", group: "Session", desc: "clear screen" },
44
40
  { name: "/extract", group: "Session", desc: "extract knowledge from session" },
45
- { name: "/restore", group: "Session", desc: "restore checkpoint" },
46
- { name: "/exit", group: "Session", desc: "exit" },
47
- { name: "/help", group: "", desc: "this list" },
41
+ { name: "/init", group: "Project", desc: "generate project AGENTS.md skeleton" },
42
+ { name: "/skills", group: "Project", desc: "list project skills" },
43
+ { name: "/mcp", group: "Project", desc: "manage MCP servers" },
44
+ { name: "/reindex", group: "Project", desc: "rebuild memory index" },
45
+ { name: "/restore", group: "Project", desc: "restore checkpoint" },
46
+ { name: "/exit", group: "System", desc: "exit" },
47
+ { name: "/help", group: "System", desc: "this list" },
48
48
  ]
49
49
 
50
- /** 命令 → handler 映射表 */
50
+ /** Command → handler mapping table */
51
51
  const HANDLERS = {
52
52
  "/clear": handleClearCommand,
53
53
  "/new": handleNewCommand,
@@ -69,17 +69,17 @@ const HANDLERS = {
69
69
  }
70
70
 
71
71
  /**
72
- * 创建斜杠命令处理器。
73
- * 返回 { handleSlash, completions, handleTab }
72
+ * Creates the slash command processor.
73
+ * Returns { handleSlash, completions, handleTab }.
74
74
  */
75
75
  export function createSlashCommands(ctx) {
76
76
  const { agent, state, render } = ctx
77
- // /help 传 SLASH_COMMANDS
77
+ // forward SLASH_COMMANDS to /help
78
78
  const handlerCtx = { ...ctx, SLASH_COMMANDS }
79
79
 
80
80
  async function handleSlash(text) {
81
81
  const [cmd] = text.split(/\s+/)
82
- // 高频命令缩写
82
+ // high-frequency command aliases
83
83
  const aliases = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
84
84
  const resolved = aliases[cmd] ?? cmd
85
85
  const handler = HANDLERS[resolved]
@@ -90,18 +90,18 @@ export function createSlashCommands(ctx) {
90
90
  ctx.pushLine(`Unknown command: ${cmd} (/help for available commands)`, C.error)
91
91
  }
92
92
 
93
- /** Tab 补全候选:Commands / Commands / provider / 预设名 / think 参数 */
93
+ /** Tab completion candidates: command names / subcommands / provider names / preset names / think params */
94
94
  function completions(input) {
95
95
  if (!input.startsWith("/")) return []
96
96
  const parts = input.split(/\s+/)
97
- // 还在敲第一个 token:补 Commands
97
+ // still typing the first token: complete command names
98
98
  if (parts.length === 1) {
99
99
  return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
100
100
  }
101
101
  const cmd = parts[0]
102
- const last = parts.at(-1) // 结尾是空格时,列出全部候选
102
+ const last = parts.at(-1) // when trailing space, list all candidates
103
103
  const head = parts.slice(0, -1).join(" ")
104
- const argIndex = parts.length - 2 // 正在敲第几个参数 (0 基)
104
+ const argIndex = parts.length - 2 // which parameter is being typed (0-based)
105
105
  const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
106
106
  if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
107
107
  if (cmd === "/think") {
@@ -117,11 +117,11 @@ export function createSlashCommands(ctx) {
117
117
  return []
118
118
  }
119
119
 
120
- /** Tab:计算候选并循环替换输入 */
120
+ /** Tab: compute candidates and cycle through replacement */
121
121
  function handleTab() {
122
122
  const input = state.input.join("")
123
123
  if (state.completion && input === state.completion.candidates[state.completion.index]) {
124
- // 上一次的候选还在输入框:循环到下一个
124
+ // previous candidate still in input box: cycle to next
125
125
  state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
126
126
  } else {
127
127
  const candidates = completions(input)
@@ -2,13 +2,13 @@ import { listSlots } from "../session.mjs"
2
2
  import { sliceByWidth } from "./render.mjs"
3
3
  import { ansi, C } from "./ansi.mjs"
4
4
 
5
- /** 启动画面 + 会话恢复 + 后台索引。
6
- * index.mjs 抽出。
5
+ /** Startup screen + session recovery + background indexing.
6
+ * Extracted from index.mjs.
7
7
  * ctx: { agent, state, opts, pushLine, pushLabel, render, startWizard } */
8
8
  export function showStartup(ctx) {
9
9
  const { agent, state, opts, pushLine, pushLabel, render, startWizard } = ctx
10
10
 
11
- // 启动画面
11
+ // Startup screen
12
12
  if (!agent.provider.apiKey) {
13
13
  pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
14
14
  pushLine("No API key configured yet — entering initial setup (Esc to skip anytime)", C.text)
@@ -18,13 +18,14 @@ export function showStartup(ctx) {
18
18
  }
19
19
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
20
20
 
21
- // 恢复上次会话:重建对话区显示 (tool 结果行省略,保持清爽)
21
+ // Recover previous session: rebuild conversation display (tool result lines omitted, keep it clean)
22
22
  if (opts.restored?.display?.length) {
23
- // 用户视角的恢复:display 是退出前对话区的原样快照,所见即所得
23
+ // User-facing recovery: display is a WYSIWYG snapshot of the conversation before exit
24
24
  state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
25
- pushLabel(`── Restored previous session; /new for a fresh session ──`, C.warn)
25
+ const recoveryNote = opts.restored._recovered ? " (recovered from backup)" : ""
26
+ pushLabel(`── Restored previous session${recoveryNote}; /new for a fresh session ──`, C.warn)
26
27
  } else if (opts.restored?.history?.length) {
27
- // 重建对话区:user/assistant 消息逐条展示,tool 结果行只保留首行摘要
28
+ // Rebuild conversation: user/assistant messages shown one by one, tool result lines show only first-line summary
28
29
  for (let i = 0; i < opts.restored.history.length; i++) {
29
30
  const m = opts.restored.history[i]
30
31
  if (m.role === "user") {
@@ -35,27 +36,27 @@ export function showStartup(ctx) {
35
36
  pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
36
37
  if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
37
38
  for (const tc of m.tool_calls ?? []) {
38
- // 找到下一条对应的 tool 结果,显示首行摘要
39
+ // Find the next corresponding tool result, show first-line summary
39
40
  const toolResult = opts.restored.history[i + 1]
40
41
  const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
41
42
  const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
42
43
  pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
43
44
  }
44
45
  }
45
- // tool 消息本身不单独渲染——已在 assistant tool_calls 后以摘要形式展示
46
+ // Tool messages aren't rendered separately — already shown as summary after assistant's tool_calls
46
47
  }
47
48
  pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
48
49
  }
49
50
 
50
- // 有归档槽位时给个提示
51
+ // Hint when archived slots exist
51
52
  if (listSlots(agent.cwd).length > 0) {
52
53
  pushLine("Tip: archived sessions available — /session to view/switch", C.dim)
53
54
  }
54
55
  render()
55
56
  }
56
57
 
57
- /** 后台索引 (进界面后再跑,不阻塞启动);进度走底部状态栏,不往对话区塞行。
58
- * 优先用 git diff 增量(快),git 不可用或首次运行时退到全量扫描。 */
58
+ /** Background indexing (runs after startup screen, non-blocking); progress shown in status bar, not conversation.
59
+ * Prefers git diff incremental (fast); falls back to full scan when git is unavailable or on first run. */
59
60
  export async function backgroundIndex(ctx) {
60
61
  const { agent, state, render } = ctx
61
62
  const { codeSync, docSync, gitSync } = await import("../memory.mjs")
@@ -75,11 +76,11 @@ export async function backgroundIndex(ctx) {
75
76
  })
76
77
 
77
78
  if (gitRes !== null) {
78
- // git 增量成功,直接统计
79
+ // Git incremental succeeded, count directly
79
80
  codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
80
81
  docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
81
82
  } else {
82
- // 退到全量扫描(codeSync docSync 并行——读写不同表,SQLite WAL 天然支持)
83
+ // Fall back to full scan (codeSync and docSync in parallel — read/write different tables, SQLite WAL supports this natively)
83
84
  const [codeRes, docRes] = await Promise.allSettled([
84
85
  codeSync(agent.memory, cwd, {
85
86
  onProgress: (p) => {
@@ -1,7 +1,7 @@
1
1
  /**
2
- * wizard.mjs — 首次启动 Config 向导
3
- * index.mjs 抽出:provider 选择逐项输入 (name/baseURL/model/key/embedkey) → 持久化接模型选择器。
4
- * 通过 ctx 对象访问 startTUI 闭包中的共享状态与 UI 函数。
2
+ * wizard.mjs — first-launch config wizard
3
+ * Extracted from index.mjs: provider selectstep-by-step input (name/baseURL/model/key/embedkey) → persistthen model picker.
4
+ * Accesses shared state and UI functions from the startTUI closure via ctx object.
5
5
  * ctx: { agent, state, pushLine, pushLabel, render, persistRaw, openModelPicker }
6
6
  */
7
7
 
@@ -9,13 +9,13 @@ import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
9
9
  import { ansi, C } from "./ansi.mjs"
10
10
 
11
11
  /**
12
- * 创建向导控制器。
13
- * 返回 { startWizard, renderWizard, wizardChooseProvider, wizardSubmitText, cancelWizard, finishWizard }
12
+ * Creates the wizard controller.
13
+ * Returns { startWizard, renderWizard, wizardChooseProvider, wizardSubmitText, cancelWizard, finishWizard }
14
14
  */
15
15
  export function createWizard(ctx) {
16
16
  const { agent, state, pushLine, pushLabel, render, persistRaw } = ctx
17
17
 
18
- /** 菜单步的候选项:已有 provider (no key 的标注)+ 未添加的预设 + 自定义 */
18
+ /** Candidates for the menu step: existing providers (marked "no key" if missing), unadded presets, custom */
19
19
  function wizardProviderItems() {
20
20
  const items = []
21
21
  for (const p of agent.providers) {
@@ -30,7 +30,7 @@ export function createWizard(ctx) {
30
30
  return items
31
31
  }
32
32
 
33
- /** 文本步骤定义:提示语 + 校验 (通过返回 true,否则返回错误文案) */
33
+ /** Text step definitions: prompt + validation (returns true if valid, otherwise error message) */
34
34
  const WIZARD_STEPS = {
35
35
  name: {
36
36
  prompt: "Name this provider (alphanumeric/-/_ e.g. my-openai)",
@@ -51,7 +51,7 @@ export function createWizard(ctx) {
51
51
  },
52
52
  embedkey: {
53
53
  prompt: "Optional: embedding API key (SiliconFlow, for memory vector search; press Enter to skip)",
54
- validate: () => true, // 可跳过
54
+ validate: () => true, // skippable
55
55
  },
56
56
  }
57
57
  const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
@@ -123,11 +123,11 @@ export function createWizard(ctx) {
123
123
 
124
124
  function cancelWizard() {
125
125
  state.wizard = null
126
- pushLine("Skipped initial setup. Add a provider anytime via /provider add, set its key via /provider key.", C.dim)
126
+ pushLine("Skipped initial setup. Use /model to add providers and configure API keys anytime.", C.dim)
127
127
  render()
128
128
  }
129
129
 
130
- /** 向导完成:写入 provider (有则更新)、设为激活、持久化,然后接模型选择器 */
130
+ /** Wizard complete: write provider (update if exists), set active, persist, then open model picker */
131
131
  async function finishWizard() {
132
132
  const f = state.wizard.fields
133
133
  state.wizard = null
@@ -147,7 +147,7 @@ export function createWizard(ctx) {
147
147
  agent.config.activeProvider = f.name
148
148
  pushLabel(`❯ Setup`, ansi.bold + C.tool)
149
149
  pushLine(`Setup complete: ${f.name} / ${f.model} (saved to config)`, C.tool)
150
- // embedding key:配了就启用向量检索,没配提示事后通道
150
+ // embedding key: if provided, enable vector search; if not, show how to enable later
151
151
  if (f.embedkey) {
152
152
  agent.config.embedding ??= {}
153
153
  agent.config.embedding.apiKey = f.embedkey
package/src/tui.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * tui.mjs — 终端 UI(重新导出中心)
3
- * 子模块在 src/tui/ 目录下。
2
+ * tui.mjs — Terminal UI (re-export hub).
3
+ * Submodules under src/tui/ directory.
4
4
  */
5
5
  export { startTUI } from "./tui/index.mjs"
package/bin/thincoder.js DELETED
@@ -1,4 +0,0 @@
1
- #!/usr/bin/env node
2
- // Thin shim — npm's "bin" field doesn't accept .mjs entries.
3
- // Delegates to the real entry point.
4
- import "./thincoder.mjs"
@@ -1,144 +0,0 @@
1
- import {
2
- DESC,
3
- sanitizeOutput,
4
- truncate,
5
- makeDecoder,
6
- BASH_TIMEOUT_MS,
7
- hasFileRedirection,
8
- shellSegments,
9
- isDestructiveGitSegment,
10
- isDestructiveCommand,
11
- insideGitRepo,
12
- } from "./shared.mjs"
13
- import { spawn, execFileSync } from "node:child_process"
14
-
15
- /** 子进程环境变量白名单:只透传安全变量,隔离 API key 等敏感信息 */
16
- const SAFE_ENV_KEYS = new Set([
17
- "PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
18
- "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "SHELL",
19
- "ComSpec", "PATHEXT", "SystemRoot", "windir",
20
- "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "OS",
21
- "PYTHONIOENCODING", "GIT_EDITOR", "EDITOR", "VISUAL",
22
- "GIT_PAGER", "PAGER", "TERM",
23
- ])
24
-
25
- export const bashTool = {
26
- name: "bash",
27
- description: DESC("bash"),
28
- parameters: {
29
- type: "object",
30
- properties: {
31
- command: { type: "string", description: "Shell command to execute" },
32
- timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
33
- },
34
- required: ["command"],
35
- },
36
- readonly: false,
37
- async execute(args, ctx) {
38
- // 安全预检:禁止 shell 重定向(> >> <)——应改用 write/edit/insert_after 工具
39
- if (hasFileRedirection(args.command)) {
40
- throw new Error("File redirection via bash is not allowed — use the write/edit/insert_after tools instead")
41
- }
42
- // 安全预检:破坏性非 git 命令(rm -rf / DROP TABLE 等)直接拒绝
43
- if (shellSegments(args.command).some(isDestructiveCommand)) {
44
- throw new Error("Destructive command blocked — use specific tools or confirm with the user first")
45
- }
46
- // 安全预检:销毁性 git 操作先检查未提交改动,有则拒绝——防一键清掉几小时工作
47
- if (shellSegments(args.command).some(isDestructiveGitSegment)) {
48
- if (!insideGitRepo(ctx.cwd)) {
49
- throw new Error(`Refusing destructive git command: not a git repository: ${ctx.cwd}`)
50
- }
51
- const status = execFileSync("git", ["status", "--porcelain"], {
52
- cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
53
- }).trim()
54
- if (status) {
55
- throw new Error(
56
- `Refusing destructive git command: uncommitted changes exist. Commit or stash first.\n` +
57
- `(If uncommitted work was already lost, the checkpoint tool can restore the auto-snapshot: action=list, then action=rewind.)\n\n${status}`
58
- )
59
- }
60
- }
61
-
62
- return new Promise((resolve) => {
63
- // detached: 让子进程成为进程组组长,超时/中断时才能整树杀掉(POSIX 用负 pid 组杀,
64
- // Windows 用 taskkill /T)——只 kill 壳进程会把孙进程(如 npm test)留在后台继续跑
65
- const killTree = () => {
66
- if (process.platform === "win32") {
67
- try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
68
- } else {
69
- try { process.kill(-child.pid, "SIGKILL") } catch {}
70
- try { child.kill("SIGKILL") } catch {} // 组杀失败时兜底杀本体
71
- }
72
- }
73
- // Windows 中文系统默认代码页是 GBK (CP936),cmd.exe 重定向写文件时用 ANSI 代码页,
74
- // chcp 65001 也改不了重定向的编码。bash 工具写含 CJK 的文件会产生 GBK——
75
- // 提示词层已禁止用 bash 写文件(用 write/edit 工具替代),这里设 PYTHONIOENCODING
76
- // 覆盖 Python 脚本的 stdout 编码(Python 是唯一可能正确响应环境变量的子进程)
77
- const winCmd = process.platform === "win32"
78
- const child = spawn(args.command, {
79
- cwd: ctx.cwd,
80
- shell: true,
81
- windowsHide: true,
82
- detached: process.platform !== "win32",
83
- stdio: ["ignore", "pipe", "pipe"],
84
- env: {
85
- ...Object.fromEntries(
86
- Object.entries(process.env).filter(([k]) => SAFE_ENV_KEYS.has(k))
87
- ),
88
- GIT_EDITOR: "true",
89
- EDITOR: "true",
90
- VISUAL: "true",
91
- GIT_PAGER: "cat",
92
- PAGER: "cat",
93
- TERM: "dumb",
94
- ...(winCmd ? { PYTHONIOENCODING: "utf-8" } : {}),
95
- },
96
- })
97
- // stdout / stderr 各自独立解码(同进程通常同编码,但分开收集更干净,
98
- // 且允许模型按 stderr 快速定位错误)
99
- const outDecoder = makeDecoder()
100
- const errDecoder = makeDecoder()
101
- let outBuf = ""
102
- let errBuf = ""
103
- let truncatedNote = ""
104
-
105
- const onStdout = (d) => {
106
- const s = sanitizeOutput(outDecoder(d))
107
- if (s) {
108
- ctx.onOutput?.(s)
109
- if (outBuf.length < 2_000_000) outBuf += s
110
- else if (!truncatedNote) truncatedNote = "\n[... output exceeded 2MB, remainder discarded]"
111
- }
112
- }
113
- const onStderr = (d) => {
114
- const s = sanitizeOutput(errDecoder(d)) // 始终解码,防 pending 无限累积
115
- if (errBuf.length < 2_000_000) errBuf += s
116
- }
117
-
118
- child.stdout.on("data", onStdout)
119
- child.stderr.on("data", onStderr)
120
-
121
- const timer = setTimeout(killTree, args.timeout ?? BASH_TIMEOUT_MS)
122
- if (ctx.signal) {
123
- ctx.signal.addEventListener("abort", killTree, { once: true })
124
- }
125
- child.on("error", (error) => {
126
- clearTimeout(timer)
127
- resolve(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
128
- })
129
- child.on("close", (code, signal) => {
130
- clearTimeout(timer)
131
- // 冲刷解码器尾部
132
- outBuf += sanitizeOutput(outDecoder(Buffer.alloc(0), true))
133
- errBuf += sanitizeOutput(errDecoder(Buffer.alloc(0), true))
134
- const status = signal
135
- ? `killed: ${ctx.signal?.aborted ? "user interrupted" : "timeout"}`
136
- : `exit code ${code}`
137
- const parts = [`[stdout]:\n${outBuf.trim() || "(empty)"}`]
138
- if (errBuf.trim()) parts.push(`[stderr]:\n${errBuf.trim()}`)
139
- parts.push(`(${status})`)
140
- resolve(truncate(parts.join("\n\n") + truncatedNote))
141
- })
142
- })
143
- },
144
- }
@@ -1,51 +0,0 @@
1
- import { DESC, truncate, resolveInCwd, globToRegex, IGNORED_DIRS } from "./shared.mjs"
2
- import { readdir } from "node:fs/promises"
3
- import { join } from "node:path"
4
-
5
- export const globTool = {
6
- name: "glob",
7
- description: DESC("glob"),
8
- parameters: {
9
- type: "object",
10
- properties: {
11
- pattern: { type: "string", description: "Glob pattern" },
12
- path: { type: "string", description: "Directory to search in (default cwd)" },
13
- },
14
- required: ["pattern"],
15
- },
16
- readonly: true,
17
- async execute(args, ctx) {
18
- const base = resolveInCwd(ctx, args.path ?? ".")
19
- const regex = globToRegex(args.pattern)
20
- const results = []
21
- for await (const relPath of walkFiles(base)) {
22
- if (regex.test(relPath)) {
23
- results.push(relPath)
24
- if (results.length >= 1000) break
25
- }
26
- }
27
- if (results.length === 0) return "(no matches)"
28
- return truncate(results.sort().join("\n"))
29
- },
30
- }
31
-
32
- // ---------------------------------------------------------- 内部实现
33
-
34
- /** 递归遍历文件,产出相对路径(跳过 IGNORED_DIRS) */
35
- async function* walkFiles(dir, rel = "") {
36
- let entries
37
- try {
38
- entries = await readdir(dir, { withFileTypes: true })
39
- } catch {
40
- return
41
- }
42
- for (const e of entries) {
43
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
44
- const relPath = rel ? `${rel}/${e.name}` : e.name
45
- if (e.isDirectory()) {
46
- yield* walkFiles(join(dir, e.name), relPath)
47
- } else {
48
- yield relPath
49
- }
50
- }
51
- }
@@ -1,100 +0,0 @@
1
- import { DESC, truncate, resolveInCwd, globToRegex, IGNORED_DIRS } from "./shared.mjs"
2
- import { readFile, readdir } from "node:fs/promises"
3
- import { stat, lstat } from "node:fs/promises"
4
- import { join } from "node:path"
5
-
6
- export const grepTool = {
7
- name: "grep",
8
- description: DESC("grep"),
9
- parameters: {
10
- type: "object",
11
- properties: {
12
- pattern: { type: "string", description: "Regular expression" },
13
- path: { type: "string", description: "Directory or file to search (default cwd)" },
14
- glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
15
- before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
16
- after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
17
- },
18
- required: ["pattern"],
19
- },
20
- readonly: true,
21
- async execute(args, ctx) {
22
- const base = resolveInCwd(ctx, args.path ?? ".")
23
- const regex = new RegExp(args.pattern)
24
- const fileFilter = args.glob ? globToRegex(args.glob) : null
25
- const before = Math.max(0, Math.floor(args.before ?? 0))
26
- const after = Math.max(0, Math.floor(args.after ?? 0))
27
- const wantCtx = before > 0 || after > 0
28
- const hits = [] // { file, line(1-based), text }
29
- const fileLines = new Map() // file -> string[](仅 wantCtx 时缓存)
30
-
31
- async function search(file) {
32
- let content
33
- try {
34
- // 大文件保护:超 10MB 跳过,防 OOM
35
- const fst = await stat(file)
36
- if (fst.size > 10_000_000) return
37
- content = await readFile(file, "utf8")
38
- } catch {
39
- return // 不可读文件跳过;二进制会被按 utf8 读入并照常搜索(可能产生乱码匹配)
40
- }
41
- const lines = content.split("\n")
42
- if (wantCtx) fileLines.set(file, lines)
43
- for (let i = 0; i < lines.length; i++) {
44
- if (regex.test(lines[i])) {
45
- hits.push({ file, line: i + 1, text: lines[i] })
46
- if (hits.length >= 200) return
47
- }
48
- }
49
- }
50
-
51
- async function walk(target) {
52
- if (hits.length >= 200) return
53
- // 用 lstat 不跟随符号链接——防 ./evil → /etc 时 grep 读遍 /etc
54
- let s
55
- try { s = await lstat(target) } catch { return }
56
- if (!s.isDirectory()) {
57
- if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
58
- return
59
- }
60
- let entries
61
- try {
62
- entries = await readdir(target, { withFileTypes: true })
63
- } catch {
64
- return
65
- }
66
- for (const e of entries) {
67
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
68
- await walk(join(target, e.name))
69
- }
70
- }
71
-
72
- await walk(base)
73
- if (hits.length === 0) return "(no matches)"
74
-
75
- // 无上下文:保持原 path:line: content 格式
76
- if (!wantCtx) {
77
- return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
78
- }
79
-
80
- // 带上下文:匹配行用 ':',上下文行用 '-'(同 ripgrep);同文件相邻区间去重合并
81
- const fileMatched = new Map() // file -> Set<line>
82
- for (const h of hits) {
83
- if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
84
- fileMatched.get(h.file).add(h.line)
85
- }
86
- const out = []
87
- for (const [file, matchedLines] of fileMatched) {
88
- const lines = fileLines.get(file) ?? []
89
- const lineSet = new Set()
90
- for (const ml of matchedLines) {
91
- for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
92
- }
93
- for (const l of [...lineSet].sort((a, b) => a - b)) {
94
- const sep = matchedLines.has(l) ? ":" : "-"
95
- out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
96
- }
97
- }
98
- return truncate(out.join("\n"))
99
- },
100
- }
package/src/tools/ls.mjs DELETED
@@ -1,36 +0,0 @@
1
- import { DESC, truncate, resolveInCwd } from "./shared.mjs"
2
- import { readdir } from "node:fs/promises"
3
- import { stat } from "node:fs/promises"
4
- import { join } from "node:path"
5
-
6
- export const lsTool = {
7
- name: "ls",
8
- description: DESC("ls"),
9
- parameters: {
10
- type: "object",
11
- properties: {
12
- path: { type: "string", description: "Directory path (default cwd)" },
13
- },
14
- },
15
- readonly: true,
16
- async execute(args, ctx) {
17
- const abs = resolveInCwd(ctx, args.path ?? ".")
18
- const entries = await readdir(abs, { withFileTypes: true })
19
- const rows = await Promise.all(
20
- entries.slice(0, 500).map(async (e) => {
21
- const s = await stat(join(abs, e.name)).catch(() => null)
22
- const isDir = e.isDirectory()
23
- return {
24
- dir: isDir,
25
- name: e.name + (isDir ? "/" : ""),
26
- size: s?.size ?? 0,
27
- mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
28
- }
29
- }),
30
- )
31
- rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
32
- if (rows.length === 0) return "(empty directory)"
33
- const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
34
- return truncate(out.join("\n"))
35
- },
36
- }
package/src/tools.mjs DELETED
@@ -1,2 +0,0 @@
1
- // Stub — see src/tools/index.mjs for actual implementation
2
- export * from "./tools/index.mjs"
@@ -1,4 +0,0 @@
1
- /**
2
- * tui-render.mjs — 终端显示工具(重新导出)
3
- */
4
- export { charWidth, stringWidth, sliceByWidth, formatTables, layoutInput, sanitizeDisplay, wrapText } from "./tui/render.mjs"