@kkelly-offical/kkcode 0.1.2

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 (196) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +445 -0
  3. package/package.json +46 -0
  4. package/src/agent/agent.mjs +170 -0
  5. package/src/agent/custom-agent-loader.mjs +158 -0
  6. package/src/agent/generator.mjs +115 -0
  7. package/src/agent/prompt/architect.txt +36 -0
  8. package/src/agent/prompt/build-fixer.txt +71 -0
  9. package/src/agent/prompt/build.txt +101 -0
  10. package/src/agent/prompt/compaction.txt +12 -0
  11. package/src/agent/prompt/explore.txt +29 -0
  12. package/src/agent/prompt/guide.txt +40 -0
  13. package/src/agent/prompt/longagent.txt +178 -0
  14. package/src/agent/prompt/plan.txt +50 -0
  15. package/src/agent/prompt/researcher.txt +23 -0
  16. package/src/agent/prompt/reviewer.txt +44 -0
  17. package/src/agent/prompt/security-reviewer.txt +62 -0
  18. package/src/agent/prompt/tdd-guide.txt +84 -0
  19. package/src/agent/prompt/title.txt +8 -0
  20. package/src/command/custom-commands.mjs +57 -0
  21. package/src/commands/agent.mjs +71 -0
  22. package/src/commands/audit.mjs +77 -0
  23. package/src/commands/background.mjs +86 -0
  24. package/src/commands/chat.mjs +114 -0
  25. package/src/commands/command.mjs +41 -0
  26. package/src/commands/config.mjs +44 -0
  27. package/src/commands/doctor.mjs +148 -0
  28. package/src/commands/hook.mjs +29 -0
  29. package/src/commands/init.mjs +141 -0
  30. package/src/commands/longagent.mjs +100 -0
  31. package/src/commands/mcp.mjs +89 -0
  32. package/src/commands/permission.mjs +36 -0
  33. package/src/commands/prompt.mjs +42 -0
  34. package/src/commands/review.mjs +266 -0
  35. package/src/commands/rule.mjs +34 -0
  36. package/src/commands/session.mjs +235 -0
  37. package/src/commands/theme.mjs +98 -0
  38. package/src/commands/usage.mjs +91 -0
  39. package/src/config/defaults.mjs +195 -0
  40. package/src/config/import-config.mjs +76 -0
  41. package/src/config/load-config.mjs +76 -0
  42. package/src/config/schema.mjs +509 -0
  43. package/src/context.mjs +40 -0
  44. package/src/core/constants.mjs +46 -0
  45. package/src/core/errors.mjs +57 -0
  46. package/src/core/events.mjs +29 -0
  47. package/src/core/types.mjs +57 -0
  48. package/src/github/api.mjs +78 -0
  49. package/src/github/auth.mjs +286 -0
  50. package/src/github/flow.mjs +298 -0
  51. package/src/github/workspace.mjs +212 -0
  52. package/src/index.mjs +82 -0
  53. package/src/knowledge/api-design.txt +9 -0
  54. package/src/knowledge/cpp.txt +10 -0
  55. package/src/knowledge/docker.txt +10 -0
  56. package/src/knowledge/dotnet.txt +9 -0
  57. package/src/knowledge/electron.txt +10 -0
  58. package/src/knowledge/flutter.txt +10 -0
  59. package/src/knowledge/go.txt +9 -0
  60. package/src/knowledge/graphql.txt +10 -0
  61. package/src/knowledge/java.txt +9 -0
  62. package/src/knowledge/kotlin.txt +10 -0
  63. package/src/knowledge/loader.mjs +125 -0
  64. package/src/knowledge/next.txt +8 -0
  65. package/src/knowledge/node.txt +8 -0
  66. package/src/knowledge/nuxt.txt +9 -0
  67. package/src/knowledge/php.txt +10 -0
  68. package/src/knowledge/python.txt +10 -0
  69. package/src/knowledge/react-native.txt +10 -0
  70. package/src/knowledge/react.txt +9 -0
  71. package/src/knowledge/ruby.txt +11 -0
  72. package/src/knowledge/rust.txt +9 -0
  73. package/src/knowledge/svelte.txt +9 -0
  74. package/src/knowledge/swift.txt +10 -0
  75. package/src/knowledge/tailwind.txt +10 -0
  76. package/src/knowledge/testing.txt +8 -0
  77. package/src/knowledge/typescript.txt +8 -0
  78. package/src/knowledge/vue.txt +9 -0
  79. package/src/mcp/client-http.mjs +157 -0
  80. package/src/mcp/client-sse.mjs +286 -0
  81. package/src/mcp/client-stdio.mjs +451 -0
  82. package/src/mcp/registry.mjs +394 -0
  83. package/src/mcp/stdio-framing.mjs +127 -0
  84. package/src/orchestration/background-manager.mjs +358 -0
  85. package/src/orchestration/background-worker.mjs +245 -0
  86. package/src/orchestration/longagent-manager.mjs +116 -0
  87. package/src/orchestration/stage-scheduler.mjs +489 -0
  88. package/src/orchestration/subagent-router.mjs +62 -0
  89. package/src/orchestration/task-scheduler.mjs +74 -0
  90. package/src/permission/engine.mjs +92 -0
  91. package/src/permission/exec-policy.mjs +372 -0
  92. package/src/permission/prompt.mjs +39 -0
  93. package/src/permission/rules.mjs +120 -0
  94. package/src/permission/workspace-trust.mjs +44 -0
  95. package/src/plugin/builtin-hooks/console-warn.mjs +41 -0
  96. package/src/plugin/builtin-hooks/extract-patterns.mjs +75 -0
  97. package/src/plugin/builtin-hooks/post-edit-format.mjs +57 -0
  98. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +61 -0
  99. package/src/plugin/builtin-hooks/strategic-compaction.mjs +38 -0
  100. package/src/plugin/hook-bus.mjs +154 -0
  101. package/src/provider/anthropic.mjs +389 -0
  102. package/src/provider/ollama.mjs +236 -0
  103. package/src/provider/openai-compatible.mjs +1 -0
  104. package/src/provider/openai.mjs +339 -0
  105. package/src/provider/retry-policy.mjs +68 -0
  106. package/src/provider/router.mjs +228 -0
  107. package/src/provider/sse.mjs +91 -0
  108. package/src/repl.mjs +2929 -0
  109. package/src/review/diff-parser.mjs +36 -0
  110. package/src/review/rejection-queue.mjs +62 -0
  111. package/src/review/review-store.mjs +21 -0
  112. package/src/review/risk-score.mjs +61 -0
  113. package/src/rules/load-rules.mjs +64 -0
  114. package/src/runtime.mjs +1 -0
  115. package/src/session/checkpoint.mjs +239 -0
  116. package/src/session/compaction.mjs +276 -0
  117. package/src/session/engine.mjs +225 -0
  118. package/src/session/instinct-manager.mjs +172 -0
  119. package/src/session/instruction-loader.mjs +25 -0
  120. package/src/session/longagent-plan.mjs +329 -0
  121. package/src/session/longagent-scaffold.mjs +100 -0
  122. package/src/session/longagent.mjs +1462 -0
  123. package/src/session/loop.mjs +905 -0
  124. package/src/session/memory-loader.mjs +75 -0
  125. package/src/session/project-context.mjs +367 -0
  126. package/src/session/prompt/anthropic.txt +151 -0
  127. package/src/session/prompt/beast.txt +37 -0
  128. package/src/session/prompt/max-steps.txt +6 -0
  129. package/src/session/prompt/plan.txt +9 -0
  130. package/src/session/prompt/qwen.txt +46 -0
  131. package/src/session/prompt-loader.mjs +18 -0
  132. package/src/session/recovery.mjs +52 -0
  133. package/src/session/store.mjs +503 -0
  134. package/src/session/system-prompt.mjs +260 -0
  135. package/src/session/task-validator.mjs +266 -0
  136. package/src/session/usability-gates.mjs +379 -0
  137. package/src/skill/builtin/backend-patterns.mjs +123 -0
  138. package/src/skill/builtin/commit.mjs +64 -0
  139. package/src/skill/builtin/debug.mjs +45 -0
  140. package/src/skill/builtin/frontend-patterns.mjs +120 -0
  141. package/src/skill/builtin/frontend.mjs +188 -0
  142. package/src/skill/builtin/init.mjs +220 -0
  143. package/src/skill/builtin/review.mjs +49 -0
  144. package/src/skill/builtin/security-checklist.mjs +80 -0
  145. package/src/skill/builtin/tdd.mjs +54 -0
  146. package/src/skill/generator.mjs +113 -0
  147. package/src/skill/registry.mjs +336 -0
  148. package/src/storage/audit-store.mjs +83 -0
  149. package/src/storage/event-log.mjs +82 -0
  150. package/src/storage/ghost-commit-store.mjs +235 -0
  151. package/src/storage/json-store.mjs +53 -0
  152. package/src/storage/paths.mjs +148 -0
  153. package/src/theme/color.mjs +64 -0
  154. package/src/theme/default-theme.mjs +29 -0
  155. package/src/theme/load-theme.mjs +71 -0
  156. package/src/theme/markdown.mjs +135 -0
  157. package/src/theme/schema.mjs +45 -0
  158. package/src/theme/status-bar.mjs +158 -0
  159. package/src/tool/audit-wrapper.mjs +38 -0
  160. package/src/tool/edit-transaction.mjs +126 -0
  161. package/src/tool/executor.mjs +109 -0
  162. package/src/tool/file-lock-manager.mjs +85 -0
  163. package/src/tool/git-auto.mjs +545 -0
  164. package/src/tool/git-full-auto.mjs +478 -0
  165. package/src/tool/image-util.mjs +276 -0
  166. package/src/tool/prompt/background_cancel.txt +1 -0
  167. package/src/tool/prompt/background_output.txt +1 -0
  168. package/src/tool/prompt/bash.txt +71 -0
  169. package/src/tool/prompt/codesearch.txt +18 -0
  170. package/src/tool/prompt/edit.txt +27 -0
  171. package/src/tool/prompt/enter_plan.txt +74 -0
  172. package/src/tool/prompt/exit_plan.txt +62 -0
  173. package/src/tool/prompt/glob.txt +33 -0
  174. package/src/tool/prompt/grep.txt +43 -0
  175. package/src/tool/prompt/list.txt +8 -0
  176. package/src/tool/prompt/multiedit.txt +20 -0
  177. package/src/tool/prompt/notebookedit.txt +21 -0
  178. package/src/tool/prompt/patch.txt +24 -0
  179. package/src/tool/prompt/question.txt +44 -0
  180. package/src/tool/prompt/read.txt +40 -0
  181. package/src/tool/prompt/task.txt +83 -0
  182. package/src/tool/prompt/todowrite.txt +117 -0
  183. package/src/tool/prompt/webfetch.txt +38 -0
  184. package/src/tool/prompt/websearch.txt +43 -0
  185. package/src/tool/prompt/write.txt +38 -0
  186. package/src/tool/prompt-loader.mjs +18 -0
  187. package/src/tool/question-prompt.mjs +86 -0
  188. package/src/tool/registry.mjs +1309 -0
  189. package/src/tool/task-tool.mjs +28 -0
  190. package/src/ui/activity-renderer.mjs +410 -0
  191. package/src/ui/repl-dashboard.mjs +357 -0
  192. package/src/usage/pricing.mjs +121 -0
  193. package/src/usage/usage-meter.mjs +113 -0
  194. package/src/util/git.mjs +496 -0
  195. package/src/util/template.mjs +10 -0
  196. package/src/util/yaml.mjs +100 -0
@@ -0,0 +1,1309 @@
1
+ import path from "node:path"
2
+ import { readdir, readFile } from "node:fs/promises"
3
+ import { access, stat, unlink } from "node:fs/promises"
4
+ import { exec as execCb } from "node:child_process"
5
+ import { promisify } from "node:util"
6
+ import { pathToFileURL } from "node:url"
7
+ import { atomicWriteFile, replaceInFileTransactional, replaceAllInFileTransactional, diffLineCount } from "./edit-transaction.mjs"
8
+ import { withFileLock } from "./file-lock-manager.mjs"
9
+ import { BackgroundManager } from "../orchestration/background-manager.mjs"
10
+ import { createTaskTool } from "./task-tool.mjs"
11
+ import { McpRegistry } from "../mcp/registry.mjs"
12
+ import { SkillRegistry } from "../skill/registry.mjs"
13
+ import { askQuestionInteractive } from "./question-prompt.mjs"
14
+ import { checkBashAllowed } from "../permission/exec-policy.mjs"
15
+ import { gitAutoTools } from "./git-auto.mjs"
16
+ import { gitFullAutoTools } from "./git-full-auto.mjs"
17
+
18
+ const exec = promisify(execCb)
19
+
20
+ const state = {
21
+ initialized: false,
22
+ tools: [],
23
+ loadedAt: 0,
24
+ lastSignature: "",
25
+ lastCwd: "",
26
+ lastConfig: null
27
+ }
28
+
29
+ function schema(type, description) {
30
+ return { type, description }
31
+ }
32
+
33
+ function safeStringify(value) {
34
+ if (typeof value === "string") return value
35
+ return JSON.stringify(value, null, 2)
36
+ }
37
+
38
+ function signatureFor(config = {}, cwd = process.cwd()) {
39
+ const payload = {
40
+ cwd,
41
+ tool: config.tool || {},
42
+ mcp: config.mcp || {},
43
+ runtime: config.runtime || {}
44
+ }
45
+ return JSON.stringify(payload)
46
+ }
47
+
48
+ async function exists(target) {
49
+ try {
50
+ await access(target)
51
+ return true
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ async function listDir(dir) {
58
+ const items = await readdir(dir, { withFileTypes: true })
59
+ return items.map((item) => `${item.isDirectory() ? "d" : "f"} ${item.name}`).join("\n")
60
+ }
61
+
62
+ // Track which files have been read in this session (for edit safety)
63
+ const fileReadTracker = new Map() // path -> { readAt: timestamp }
64
+
65
+ function markFileRead(filePath) {
66
+ fileReadTracker.set(filePath, { readAt: Date.now() })
67
+ }
68
+
69
+ function wasFileRead(filePath) {
70
+ return fileReadTracker.has(filePath)
71
+ }
72
+
73
+ async function runGlob(pattern, cwd, searchPath) {
74
+ if (!pattern) return "pattern is required"
75
+ const escaped = pattern.replace(/"/g, '\\"')
76
+ const target = searchPath ? path.resolve(cwd, searchPath) : "."
77
+ const command = `rg --files --glob "${escaped}" "${target}"`
78
+ const out = await exec(command, { cwd, timeout: 15000, encoding: "utf8" }).catch((error) => ({
79
+ stdout: error.stdout ?? "",
80
+ stderr: error.stderr ?? error.message
81
+ }))
82
+ const text = `${out.stdout || ""}`.trim()
83
+ if (!text) return "no files matched"
84
+ const lines = text.split("\n").filter(Boolean)
85
+ if (lines.length > 200) {
86
+ return lines.slice(0, 200).join("\n") + `\n... (+${lines.length - 200} more files)`
87
+ }
88
+ return `${lines.length} file(s):\n${text}`
89
+ }
90
+
91
+ async function runGrep(pattern, cwd, options = {}) {
92
+ if (!pattern) return "pattern is required"
93
+ const parts = ["rg"]
94
+ // Output mode
95
+ if (options.multiline) parts.push("-U", "--multiline-dotall")
96
+ if (options.outputMode === "count") parts.push("-c")
97
+ else if (options.outputMode === "files") parts.push("-l")
98
+ else parts.push("-n") // content mode (default)
99
+ // Context
100
+ if (options.beforeContext) parts.push("-B", String(options.beforeContext))
101
+ if (options.afterContext) parts.push("-A", String(options.afterContext))
102
+ if (options.context) parts.push("-C", String(options.context))
103
+ // Filters
104
+ if (options.type) parts.push("--type", options.type)
105
+ if (options.glob) parts.push("--glob", `"${options.glob}"`)
106
+ if (options.maxCount) parts.push("-m", String(options.maxCount))
107
+ if (options.ignoreCase) parts.push("-i")
108
+ const escaped = process.platform === "win32" ? `"${pattern}"` : `'${pattern}'`
109
+ const target = options.path ? `"${path.resolve(cwd, options.path)}"` : "."
110
+ parts.push(escaped, target)
111
+ const command = parts.join(" ")
112
+ const out = await exec(command, { cwd, timeout: 30000, encoding: "utf8" }).catch((error) => ({
113
+ stdout: error.stdout ?? "",
114
+ stderr: error.stderr ?? error.message
115
+ }))
116
+ let text = `${out.stdout || ""}${out.stderr || ""}`.trim()
117
+ // Post-process: offset + head_limit for pagination
118
+ if (text && (options.offset || options.headLimit)) {
119
+ const lines = text.split("\n")
120
+ const start = options.offset || 0
121
+ const limit = options.headLimit || lines.length
122
+ text = lines.slice(start, start + limit).join("\n")
123
+ }
124
+ return text || "no matches"
125
+ }
126
+
127
+ const LONG_RUNNING_PATTERNS = [
128
+ /\bnpm\s+run\s+dev\b/i,
129
+ /\bnpm\s+run\s+start\b/i,
130
+ /\bnpm\s+start\b/i,
131
+ /\byarn\s+dev\b/i,
132
+ /\byarn\s+start\b/i,
133
+ /\bpnpm\s+dev\b/i,
134
+ /\bpnpm\s+start\b/i,
135
+ /\bnpx\s+vite\b/i,
136
+ /\bnpx\s+next\s+dev\b/i,
137
+ /\bnpx\s+serve\b/i,
138
+ /\bnode\s+.*server/i,
139
+ /\bwebpack\s+serve\b/i,
140
+ /\bwebpack\s+--watch\b/i,
141
+ /\bjest\s+--watch\b/i,
142
+ /\bvitest(?!\s+--run)\b.*(?!--run)/i,
143
+ /\bnodemon\b/i,
144
+ /\btsc\s+--watch\b/i,
145
+ /\btailwindcss\s+--watch\b/i,
146
+ /\bnpm\s+run\s+serve\b/i,
147
+ /\bnpm\s+run\s+watch\b/i
148
+ ]
149
+
150
+ const BASH_TIMEOUT_MS = 120_000
151
+ const IS_WIN = process.platform === "win32"
152
+ function wrapCmd(cmd) { return IS_WIN ? `chcp 65001 >nul & ${cmd}` : cmd }
153
+
154
+ function isLongRunningCommand(command) {
155
+ const cmd = String(command || "").trim()
156
+ return LONG_RUNNING_PATTERNS.some((re) => re.test(cmd))
157
+ }
158
+
159
+ async function runBash(command, cwd, timeoutMs = BASH_TIMEOUT_MS) {
160
+ if (isLongRunningCommand(command)) {
161
+ return `[blocked] "${command}" looks like a long-running/dev-server command that would block execution. Please tell the user to run it manually in their terminal, or use run_in_background: true.`
162
+ }
163
+ const out = await exec(wrapCmd(command), { cwd, timeout: timeoutMs, encoding: "utf8" }).catch((error) => {
164
+ if (error.killed || error.signal === "SIGTERM") {
165
+ return {
166
+ stdout: error.stdout ?? "",
167
+ stderr: `${error.stderr || ""}\n[timeout] command killed after ${timeoutMs / 1000}s`
168
+ }
169
+ }
170
+ return {
171
+ stdout: error.stdout ?? "",
172
+ stderr: error.stderr ?? error.message
173
+ }
174
+ })
175
+ const raw = `${out.stdout || ""}${out.stderr || ""}`.trim() || "(empty output)"
176
+ if (raw.length > 30000) return raw.slice(0, 30000) + `\n\n[truncated] output exceeded 30000 chars (total: ${raw.length})`
177
+ return raw
178
+ }
179
+
180
+ function lockOptions(ctx = {}) {
181
+ const mode = String(ctx?.config?.tool?.write_lock?.mode || "file_lock")
182
+ const waitTimeoutMs = Math.max(0, Number(ctx?.config?.tool?.write_lock?.wait_timeout_ms || 120000))
183
+ const owner = String(ctx?.taskId || ctx?.sessionId || ctx?.turnId || "kkcode")
184
+ return { mode, waitTimeoutMs, owner }
185
+ }
186
+
187
+ async function loadDynamicTools(dirs) {
188
+ const loaded = []
189
+ for (const dir of dirs) {
190
+ const absolute = path.resolve(dir)
191
+ if (!(await exists(absolute))) continue
192
+ const entries = await readdir(absolute, { withFileTypes: true })
193
+ for (const entry of entries) {
194
+ if (!entry.isFile()) continue
195
+ if (![".mjs", ".js"].includes(path.extname(entry.name).toLowerCase())) continue
196
+ const file = path.join(absolute, entry.name)
197
+ try {
198
+ const mod = await import(pathToFileURL(file).href)
199
+ const def = mod.default || mod.tool || mod
200
+ if (!def || typeof def !== "object" || typeof def.name !== "string" || typeof def.execute !== "function") {
201
+ continue
202
+ }
203
+ loaded.push({
204
+ name: def.name,
205
+ description: def.description || `dynamic tool from ${file}`,
206
+ inputSchema: def.inputSchema || { type: "object", properties: {}, required: [] },
207
+ execute: def.execute
208
+ })
209
+ } catch {
210
+ // ignore invalid tool module
211
+ }
212
+ }
213
+ }
214
+ return loaded
215
+ }
216
+
217
+ function builtinTools(config) {
218
+ const listTool = {
219
+ name: "list",
220
+ description: "List files and subdirectories in a directory. Returns entry names with type prefix (d=directory, f=file). Use this for quick directory overview; use `glob` for recursive pattern matching.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: { path: schema("string", "directory path") },
224
+ required: []
225
+ },
226
+ async execute(args, ctx) {
227
+ const target = path.resolve(ctx.cwd, args.path || ".")
228
+ return listDir(target)
229
+ }
230
+ }
231
+
232
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"])
233
+ const IMAGE_MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp", ".bmp": "image/bmp", ".ico": "image/x-icon" }
234
+
235
+ function readNotebook(raw) {
236
+ const notebook = JSON.parse(raw)
237
+ if (!notebook.cells || !Array.isArray(notebook.cells)) return "Not a valid .ipynb file (missing cells array)"
238
+ const lines = []
239
+ notebook.cells.forEach((cell, i) => {
240
+ const type = cell.cell_type || "unknown"
241
+ lines.push(`--- Cell ${i} [${type}] ---`)
242
+ const source = Array.isArray(cell.source) ? cell.source.join("") : String(cell.source || "")
243
+ lines.push(source)
244
+ if (cell.outputs && cell.outputs.length > 0) {
245
+ lines.push("[Output]:")
246
+ for (const out of cell.outputs) {
247
+ if (out.text) lines.push(Array.isArray(out.text) ? out.text.join("") : String(out.text))
248
+ else if (out.data?.["text/plain"]) {
249
+ const plain = out.data["text/plain"]
250
+ lines.push(Array.isArray(plain) ? plain.join("") : String(plain))
251
+ }
252
+ }
253
+ }
254
+ lines.push("")
255
+ })
256
+ return lines.join("\n")
257
+ }
258
+
259
+ function extractPdfText(buffer) {
260
+ // Basic PDF text extraction: find text between BT/ET operators and parenthesized strings
261
+ const str = buffer.toString("latin1")
262
+ const texts = []
263
+ const tjRegex = /\(([^)]*)\)/g
264
+ // Extract strings from content streams
265
+ let match
266
+ while ((match = tjRegex.exec(str)) !== null) {
267
+ const decoded = match[1]
268
+ .replace(/\\n/g, "\n").replace(/\\r/g, "\r")
269
+ .replace(/\\t/g, "\t").replace(/\\\\/g, "\\")
270
+ .replace(/\\([()])/g, "$1")
271
+ if (decoded.trim()) texts.push(decoded)
272
+ }
273
+ if (texts.length === 0) return "(PDF contains no extractable text — may be image-based or encrypted)"
274
+ return texts.join(" ").replace(/\s+/g, " ").trim()
275
+ }
276
+
277
+ const readTool = {
278
+ name: "read",
279
+ description: "Read file content with line numbers. Supports text files, images (PNG/JPG/GIF/SVG/WebP/BMP/ICO as base64), PDF (text extraction), and Jupyter notebooks (.ipynb cell parsing). Use `offset` and `limit` to read specific line ranges. ALWAYS use this instead of `bash` with cat/head/tail. You MUST read a file before editing it with `edit`.",
280
+ inputSchema: {
281
+ type: "object",
282
+ properties: {
283
+ path: schema("string", "file path"),
284
+ offset: schema("number", "start line number (1-based, optional)"),
285
+ limit: schema("number", "max lines to return (optional)"),
286
+ encoding: schema("string", "file encoding (default: utf8)"),
287
+ pages: schema("string", "page range for PDF files, e.g. '1-5' (optional)")
288
+ },
289
+ required: ["path"]
290
+ },
291
+ async execute(args, ctx) {
292
+ const target = path.resolve(ctx.cwd, args.path)
293
+ const ext = path.extname(target).toLowerCase()
294
+ markFileRead(target)
295
+
296
+ // Image files: return base64 data URI
297
+ if (IMAGE_EXTENSIONS.has(ext)) {
298
+ const buffer = await readFile(target)
299
+ const base64 = buffer.toString("base64")
300
+ const mime = IMAGE_MIME[ext] || "application/octet-stream"
301
+ return {
302
+ type: "image",
303
+ output: `Image file: ${args.path} (${buffer.length} bytes, ${mime})`,
304
+ data: `data:${mime};base64,${base64}`
305
+ }
306
+ }
307
+
308
+ // PDF files: extract text
309
+ if (ext === ".pdf") {
310
+ const buffer = await readFile(target)
311
+ return extractPdfText(buffer)
312
+ }
313
+
314
+ // Jupyter notebooks: parse cells
315
+ if (ext === ".ipynb") {
316
+ const raw = await readFile(target, "utf8")
317
+ return readNotebook(raw)
318
+ }
319
+
320
+ // Default: text file with line numbers
321
+ const encoding = args.encoding || "utf8"
322
+ const content = await readFile(target, encoding)
323
+ const allLines = content.split("\n")
324
+ const start = Math.max(0, (Number(args.offset) || 1) - 1)
325
+ const count = Number(args.limit) || Math.min(allLines.length, 2000)
326
+ const slice = allLines.slice(start, start + count)
327
+ const numbered = slice.map((line, i) => {
328
+ const num = String(start + i + 1).padStart(6)
329
+ const truncated = line.length > 2000 ? line.slice(0, 2000) + "... (truncated)" : line
330
+ return `${num}→${truncated}`
331
+ })
332
+ return numbered.join("\n")
333
+ }
334
+ }
335
+
336
+ const writeTool = {
337
+ name: "write",
338
+ description: "Create or overwrite a file atomically. Auto-creates parent directories. Supports three modes: 'overwrite' (default, full replacement), 'append' (add to end of file), 'insert' (insert at a specific line). For large files (200+ lines), use mode='append' to build incrementally across multiple calls to avoid output truncation. Use `edit` instead when only a small part of an existing file needs to change.",
339
+ inputSchema: {
340
+ type: "object",
341
+ properties: {
342
+ path: schema("string", "file path"),
343
+ content: schema("string", "file content to write"),
344
+ mode: schema("string", "write mode: 'overwrite' (default), 'append' (add to end), 'insert' (insert at line number)"),
345
+ insert_at_line: schema("number", "1-based line number for insert mode. Content is inserted BEFORE this line.")
346
+ },
347
+ required: ["path", "content"]
348
+ },
349
+ async execute(args, ctx) {
350
+ const target = path.resolve(ctx.cwd, args.path)
351
+ const content = String(args.content ?? "")
352
+ const mode = String(args.mode || "overwrite")
353
+
354
+ // Guard: detect empty/parse-error writes that would destroy existing content
355
+ if (args.__parse_error) {
356
+ return {
357
+ output: `error: tool call arguments were corrupted (JSON parse failed). The write was NOT executed. This usually means the response was truncated — try using write with mode="append" to build the file incrementally.`,
358
+ metadata: { blocked: true, reason: "parse_error" }
359
+ }
360
+ }
361
+ if (!content && !args.content && mode === "overwrite") {
362
+ return {
363
+ output: `error: content is empty or missing. The write was NOT executed. If you intended to create an empty file, pass content as an empty string explicitly.`,
364
+ metadata: { blocked: true, reason: "empty_content" }
365
+ }
366
+ }
367
+
368
+ let previous = ""
369
+ const options = lockOptions(ctx)
370
+
371
+ const runWrite = async () => {
372
+ try {
373
+ previous = await readFile(target, "utf8")
374
+ } catch {
375
+ previous = ""
376
+ }
377
+
378
+ if (mode === "append") {
379
+ const separator = previous && !previous.endsWith("\n") ? "\n" : ""
380
+ await atomicWriteFile(target, previous + separator + content)
381
+ } else if (mode === "insert") {
382
+ const lineNum = Math.max(1, Number(args.insert_at_line) || 1)
383
+ const lines = previous ? previous.split("\n") : []
384
+ const insertIdx = Math.min(lineNum - 1, lines.length)
385
+ const newLines = content.split("\n")
386
+ lines.splice(insertIdx, 0, ...newLines)
387
+ await atomicWriteFile(target, lines.join("\n"))
388
+ } else {
389
+ // overwrite (default)
390
+ await atomicWriteFile(target, content)
391
+ }
392
+ }
393
+
394
+ if (options.mode === "file_lock") {
395
+ await withFileLock({
396
+ targetPath: target,
397
+ owner: options.owner,
398
+ waitTimeoutMs: options.waitTimeoutMs,
399
+ run: runWrite
400
+ })
401
+ } else {
402
+ await runWrite()
403
+ }
404
+
405
+ let finalContent
406
+ try { finalContent = await readFile(target, "utf8") } catch { finalContent = content }
407
+ const diff = diffLineCount(previous, finalContent)
408
+ const modeLabel = mode === "append" ? "appended" : mode === "insert" ? "inserted" : "written"
409
+ return {
410
+ output: `${modeLabel}: ${target}`,
411
+ metadata: {
412
+ fileChanges: [
413
+ {
414
+ path: String(args.path || target),
415
+ tool: "write",
416
+ addedLines: diff.added,
417
+ removedLines: diff.removed,
418
+ stageId: ctx.stageId || null,
419
+ taskId: ctx.logicalTaskId || ctx.taskId || null
420
+ }
421
+ ]
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ const editTool = {
428
+ name: "edit",
429
+ description: "Replace a specific text snippet in an existing file. Transactional with automatic rollback on failure. You MUST `read` the file first — edits on unread files are rejected. Provide enough surrounding context in `before` to ensure a unique match. Set `replace_all: true` to replace ALL occurrences.",
430
+ inputSchema: {
431
+ type: "object",
432
+ properties: {
433
+ path: schema("string", "file path"),
434
+ before: schema("string", "target snippet"),
435
+ after: schema("string", "replacement snippet"),
436
+ replace_all: schema("boolean", "replace all occurrences instead of requiring unique match (default: false)")
437
+ },
438
+ required: ["path", "before", "after"]
439
+ },
440
+ async execute(args, ctx) {
441
+ const target = path.resolve(ctx.cwd, args.path)
442
+ // Safety: warn if file was not read first
443
+ if (!wasFileRead(target)) {
444
+ const fileExists = await exists(target)
445
+ if (fileExists) {
446
+ return {
447
+ output: `warning: you should read "${args.path}" before editing it. Use the read tool first to understand the file content, then retry the edit.`,
448
+ metadata: { fileChanges: [] }
449
+ }
450
+ }
451
+ }
452
+ // Safety: check if file was modified externally since last read
453
+ const readInfo = fileReadTracker.get(target)
454
+ if (readInfo) {
455
+ try {
456
+ const fileStat = await stat(target)
457
+ if (fileStat.mtimeMs > readInfo.readAt + 500) {
458
+ return {
459
+ output: `warning: "${args.path}" was modified since you last read it. Read it again to see the latest content before editing.`,
460
+ metadata: { fileChanges: [] }
461
+ }
462
+ }
463
+ } catch { /* file may not exist yet */ }
464
+ }
465
+ const options = lockOptions(ctx)
466
+ const runEdit = async () =>
467
+ args.replace_all
468
+ ? replaceAllInFileTransactional(target, String(args.before), String(args.after))
469
+ : replaceInFileTransactional(target, String(args.before), String(args.after))
470
+ const result = options.mode === "file_lock"
471
+ ? await withFileLock({
472
+ targetPath: target,
473
+ owner: options.owner,
474
+ waitTimeoutMs: options.waitTimeoutMs,
475
+ run: runEdit
476
+ })
477
+ : await runEdit()
478
+ // Update read tracker after successful edit
479
+ markFileRead(target)
480
+ return {
481
+ output: result.output,
482
+ metadata: {
483
+ fileChanges: [
484
+ {
485
+ path: String(args.path || target),
486
+ tool: "edit",
487
+ addedLines: Number(result.addedLines || 0),
488
+ removedLines: Number(result.removedLines || 0),
489
+ stageId: ctx.stageId || null,
490
+ taskId: ctx.logicalTaskId || ctx.taskId || null
491
+ }
492
+ ]
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ const globTool = {
499
+ name: "glob",
500
+ description: "Find files by glob pattern recursively. Use this instead of `bash` with find/ls. Optionally specify a `path` to search within a specific directory. Returns up to 200 matching file paths.",
501
+ inputSchema: {
502
+ type: "object",
503
+ properties: {
504
+ pattern: schema("string", "glob pattern, e.g. **/*.mjs, src/**/*.ts"),
505
+ path: schema("string", "directory to search in (default: cwd)")
506
+ },
507
+ required: ["pattern"]
508
+ },
509
+ async execute(args, ctx) {
510
+ return runGlob(String(args.pattern || ""), ctx.cwd, args.path || null)
511
+ }
512
+ }
513
+
514
+ const grepTool = {
515
+ name: "grep",
516
+ description: "Search file contents by regex pattern. Use this instead of `bash` with grep/rg. Supports searching within a specific file or directory via `path`, output modes (content/files/count), multiline matching, context lines, and pagination.",
517
+ inputSchema: {
518
+ type: "object",
519
+ properties: {
520
+ pattern: schema("string", "regex or string pattern"),
521
+ path: schema("string", "file or directory to search in (default: cwd). Use this to search within a specific file."),
522
+ output_mode: schema("string", "output mode: 'content' (lines with numbers), 'files' (file paths only, default), 'count' (match counts per file)"),
523
+ type: schema("string", "file type filter, e.g. js, ts, py (optional)"),
524
+ glob: schema("string", "glob filter, e.g. *.mjs, src/**/*.ts (optional)"),
525
+ maxCount: schema("number", "max matches per file (optional)"),
526
+ context: schema("number", "lines of context around match, -C (optional)"),
527
+ before_context: schema("number", "lines before each match, -B (optional)"),
528
+ after_context: schema("number", "lines after each match, -A (optional)"),
529
+ ignoreCase: schema("boolean", "case insensitive search (optional)"),
530
+ multiline: schema("boolean", "enable cross-line matching (optional)"),
531
+ head_limit: schema("number", "limit output to first N lines/entries (optional)"),
532
+ offset: schema("number", "skip first N lines/entries before head_limit (optional)")
533
+ },
534
+ required: ["pattern"]
535
+ },
536
+ async execute(args, ctx) {
537
+ return runGrep(String(args.pattern || ""), ctx.cwd, {
538
+ path: args.path || null,
539
+ outputMode: args.output_mode || "files",
540
+ type: args.type || null,
541
+ glob: args.glob || null,
542
+ maxCount: args.maxCount || null,
543
+ context: args.context || null,
544
+ beforeContext: args.before_context || null,
545
+ afterContext: args.after_context || null,
546
+ ignoreCase: !!args.ignoreCase,
547
+ multiline: !!args.multiline,
548
+ headLimit: args.head_limit || null,
549
+ offset: args.offset || null
550
+ })
551
+ }
552
+ }
553
+
554
+ const bashTool = {
555
+ name: "bash",
556
+ description: "Run a shell command in cwd. ONLY use for commands that have no dedicated tool (e.g. git, npm, pip, docker). Do NOT use for: reading files (use `read`), searching files (use `grep`/`glob`), writing files (use `write`/`edit`). Long-running commands are blocked unless run_in_background is true.",
557
+ inputSchema: {
558
+ type: "object",
559
+ properties: {
560
+ command: schema("string", "shell command"),
561
+ timeout: schema("number", "timeout in ms (default 120000, max 600000)"),
562
+ description: schema("string", "human-readable description of what this command does (optional)"),
563
+ run_in_background: schema("boolean", "run as background task, returns task_id immediately (optional)")
564
+ },
565
+ required: ["command"]
566
+ },
567
+ async execute(args, ctx) {
568
+ const command = String(args.command || "")
569
+ const timeoutMs = Math.min(Math.max(Number(args.timeout) || BASH_TIMEOUT_MS, 1000), 600_000)
570
+
571
+ // 执行策略检查
572
+ const policyCheck = checkBashAllowed(command, ctx.config)
573
+ if (!policyCheck.allowed) {
574
+ return {
575
+ ok: false,
576
+ blocked: true,
577
+ error: "execution_policy_violation",
578
+ message: policyCheck.reason,
579
+ suggestion: "Use git_snapshot to create temporary snapshots, then manually commit when satisfied."
580
+ }
581
+ }
582
+
583
+ if (args.run_in_background) {
584
+ // Launch as background task
585
+ const task = await BackgroundManager.launch({
586
+ description: args.description || command,
587
+ payload: { command, cwd: ctx.cwd },
588
+ run: async () => {
589
+ const out = await exec(wrapCmd(command), { cwd: ctx.cwd, timeout: 600_000, encoding: "utf8" })
590
+ .catch(e => ({ stdout: e.stdout ?? "", stderr: e.stderr ?? e.message }))
591
+ return `${out.stdout || ""}${out.stderr || ""}`.trim() || "(empty output)"
592
+ },
593
+ config: ctx.config
594
+ })
595
+ return `background task launched: ${task.id}\nUse background_output to check results.`
596
+ }
597
+
598
+ return runBash(command, ctx.cwd, timeoutMs)
599
+ }
600
+ }
601
+
602
+ const outputTool = {
603
+ name: "background_output",
604
+ description: "Retrieve status, logs, and result of a background task launched via `task` with `run_in_background: true`. Returns the task object including status and output.",
605
+ inputSchema: {
606
+ type: "object",
607
+ properties: {
608
+ task_id: schema("string", "background task id")
609
+ },
610
+ required: ["task_id"]
611
+ },
612
+ async execute(args) {
613
+ const task = await BackgroundManager.get(String(args.task_id || ""))
614
+ if (!task) return "background task not found"
615
+ return task
616
+ }
617
+ }
618
+
619
+ const cancelTool = {
620
+ name: "background_cancel",
621
+ description: "Cancel a running background task by its task_id. Only works on tasks launched via `task` with `run_in_background: true`.",
622
+ inputSchema: {
623
+ type: "object",
624
+ properties: {
625
+ task_id: schema("string", "background task id")
626
+ },
627
+ required: ["task_id"]
628
+ },
629
+ async execute(args) {
630
+ const ok = await BackgroundManager.cancel(String(args.task_id || ""))
631
+ return ok ? "cancel requested" : "background task not found"
632
+ }
633
+ }
634
+
635
+ const todowriteTool = {
636
+ name: "todowrite",
637
+ description: "Create or update a structured task list for tracking multi-step work. ALWAYS create a todo list before starting any task with 2+ steps. Mark items in_progress/completed as you work. Only ONE item should be in_progress at a time.",
638
+ inputSchema: {
639
+ type: "object",
640
+ properties: {
641
+ todos: {
642
+ type: "array",
643
+ description: "The updated todo list",
644
+ items: {
645
+ type: "object",
646
+ properties: {
647
+ content: schema("string", "task description in imperative form (e.g. 'Run tests')"),
648
+ activeForm: schema("string", "present continuous form shown during execution (e.g. 'Running tests')"),
649
+ status: { type: "string", enum: ["pending", "in_progress", "completed"], description: "task status" }
650
+ },
651
+ required: ["content", "status"]
652
+ }
653
+ }
654
+ },
655
+ required: ["todos"]
656
+ },
657
+ async execute(args, ctx) {
658
+ const todos = args.todos || []
659
+ ctx._todoState = todos
660
+ const summary = todos.map((t) => {
661
+ const active = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : ""
662
+ return `[${t.status}] ${t.content}${active}`
663
+ }).join("\n")
664
+ return `Todo list updated (${todos.length} items):\n${summary}`
665
+ }
666
+ }
667
+
668
+ const questionTool = {
669
+ name: "question",
670
+ description: "Ask the user one or more structured questions and wait for their answers. Use when you need user input to proceed — e.g. ambiguous requirements, implementation choices, or missing information. Supports predefined options, multi-select, and custom text input. Returns actual user answers.",
671
+ inputSchema: {
672
+ type: "object",
673
+ properties: {
674
+ questions: {
675
+ type: "array",
676
+ description: "questions to ask the user",
677
+ items: {
678
+ type: "object",
679
+ properties: {
680
+ id: schema("string", "unique question identifier"),
681
+ text: schema("string", "question text"),
682
+ header: schema("string", "short label for tab chip (max 12 chars)"),
683
+ description: schema("string", "supplementary description (optional)"),
684
+ options: {
685
+ type: "array",
686
+ items: {
687
+ type: "object",
688
+ properties: {
689
+ label: schema("string", "option display text"),
690
+ value: schema("string", "option value (defaults to label)"),
691
+ description: schema("string", "option description (optional)")
692
+ },
693
+ required: ["label"]
694
+ },
695
+ description: "predefined choices (optional)"
696
+ },
697
+ multi: schema("boolean", "allow multiple selections (default false)"),
698
+ allowCustom: schema("boolean", "allow custom text input (default true)")
699
+ },
700
+ required: ["id", "text"]
701
+ }
702
+ }
703
+ },
704
+ required: ["questions"]
705
+ },
706
+ async execute(args) {
707
+ if (args && args._allowQuestion === false) {
708
+ return "question tool disabled in this phase"
709
+ }
710
+ const questions = Array.isArray(args.questions) ? args.questions : []
711
+ if (questions.length === 0) {
712
+ return "error: at least one question is required"
713
+ }
714
+ // Normalize questions
715
+ const normalized = questions.map((q, i) => ({
716
+ id: String(q.id || `q${i}`),
717
+ text: String(q.text || ""),
718
+ description: q.description ? String(q.description) : "",
719
+ options: Array.isArray(q.options) ? q.options.map((o) => ({
720
+ label: String(o.label || ""),
721
+ value: String(o.value || o.label || ""),
722
+ description: o.description ? String(o.description) : ""
723
+ })) : [],
724
+ multi: !!q.multi,
725
+ allowCustom: q.allowCustom !== false
726
+ }))
727
+ const answers = await askQuestionInteractive({ questions: normalized })
728
+ // Format response
729
+ const lines = normalized.map((q) => {
730
+ const answer = answers[q.id] ?? "(skipped)"
731
+ return `[${q.id}] ${q.text} → ${answer}`
732
+ })
733
+ return lines.join("\n")
734
+ }
735
+ }
736
+
737
+ const webfetchTool = {
738
+ name: "webfetch",
739
+ description: "Fetch content from a public URL and return it as text. HTML is converted to markdown. Content over 50KB is truncated. Only use for public, unauthenticated URLs. Do NOT use for local file reading — use `read` instead.",
740
+ inputSchema: {
741
+ type: "object",
742
+ properties: {
743
+ url: schema("string", "URL to fetch"),
744
+ prompt: schema("string", "optional processing instruction")
745
+ },
746
+ required: ["url"]
747
+ },
748
+ async execute(args) {
749
+ const url = String(args.url || "")
750
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
751
+ return "error: URL must start with http:// or https://"
752
+ }
753
+ try {
754
+ const response = await fetch(url, {
755
+ headers: { "user-agent": "kkcode/0.1" },
756
+ signal: AbortSignal.timeout(30000)
757
+ })
758
+ if (!response.ok) return `error: HTTP ${response.status}`
759
+ const text = await response.text()
760
+ const truncated = text.length > 50000 ? text.slice(0, 50000) + "\n...(truncated)" : text
761
+ return truncated
762
+ } catch (error) {
763
+ return `error: ${error.message}`
764
+ }
765
+ }
766
+ }
767
+
768
+ const skillTool = {
769
+ name: "skill",
770
+ description: "Invoke a registered skill by name. Skills are pre-built prompt templates or programmable modules that provide specialized capabilities. Use this when a task matches an available skill listed in the system prompt, or when the user mentions a slash command like '/commit'.",
771
+ inputSchema: {
772
+ type: "object",
773
+ properties: {
774
+ skill: schema("string", "skill name without '/' prefix (e.g. 'commit', 'init', 'frontend')"),
775
+ args: schema("string", "optional arguments to pass to the skill (e.g. 'vue' for /init vue)")
776
+ },
777
+ required: ["skill"]
778
+ },
779
+ async execute(args, ctx) {
780
+ const name = String(args.skill || "").trim()
781
+ if (!name) return "error: skill name is required"
782
+ if (!SkillRegistry.isReady()) return "error: skill registry not initialized"
783
+ const skill = SkillRegistry.get(name)
784
+ if (!skill) {
785
+ const available = SkillRegistry.list().map(s => s.name).join(", ")
786
+ return `error: skill "${name}" not found. Available: ${available}`
787
+ }
788
+ const result = await SkillRegistry.execute(name, String(args.args || ""), {
789
+ cwd: ctx.cwd,
790
+ mode: ctx.mode || "agent",
791
+ model: ctx.model || "",
792
+ provider: ctx.provider || ""
793
+ })
794
+ if (!result) return `skill /${name} returned no output`
795
+ return result
796
+ }
797
+ }
798
+
799
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp"
800
+ const EXA_TIMEOUT_MS = 25000
801
+
802
+ async function callExaMcp(toolName, args, signal) {
803
+ const body = JSON.stringify({
804
+ jsonrpc: "2.0",
805
+ id: 1,
806
+ method: "tools/call",
807
+ params: { name: toolName, arguments: args }
808
+ })
809
+ const response = await fetch(EXA_MCP_URL, {
810
+ method: "POST",
811
+ headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
812
+ body,
813
+ signal: signal || AbortSignal.timeout(EXA_TIMEOUT_MS)
814
+ })
815
+ if (!response.ok) {
816
+ const err = await response.text().catch(() => "")
817
+ throw new Error(`Exa search error (${response.status}): ${err}`)
818
+ }
819
+ const text = await response.text()
820
+ for (const line of text.split("\n")) {
821
+ if (line.startsWith("data: ")) {
822
+ const data = JSON.parse(line.slice(6))
823
+ if (data.result?.content?.[0]?.text) return data.result.content[0].text
824
+ }
825
+ }
826
+ return null
827
+ }
828
+
829
+ const websearchTool = {
830
+ name: "websearch",
831
+ description: "Search the web for up-to-date information. Use this PROACTIVELY when you are unsure about facts, APIs, library versions, error messages, or anything beyond your training data. Reduces hallucination by grounding answers in real search results. Returns relevant web page content.",
832
+ inputSchema: {
833
+ type: "object",
834
+ properties: {
835
+ query: schema("string", "search query"),
836
+ numResults: schema("number", "number of results to return (default: 5)"),
837
+ type: schema("string", "search type: 'auto' (default), 'fast' (quick), 'deep' (comprehensive)")
838
+ },
839
+ required: ["query"]
840
+ },
841
+ async execute(args, ctx) {
842
+ const query = String(args.query || "").trim()
843
+ if (!query) return "error: query is required"
844
+ try {
845
+ const result = await callExaMcp("web_search_exa", {
846
+ query,
847
+ numResults: Number(args.numResults) || 5,
848
+ type: args.type || "auto",
849
+ livecrawl: "fallback"
850
+ }, ctx.signal)
851
+ return result || "No results found. Try a different query."
852
+ } catch (error) {
853
+ if (error.name === "AbortError" || error.name === "TimeoutError") return "error: search request timed out"
854
+ return `error: ${error.message}`
855
+ }
856
+ }
857
+ }
858
+
859
+ const codesearchTool = {
860
+ name: "codesearch",
861
+ description: "Search for code examples, API documentation, and SDK usage. Use this PROACTIVELY when working with unfamiliar libraries, frameworks, or APIs. Returns relevant code snippets and documentation from the web. Especially useful for: correct API signatures, configuration examples, migration guides, and best practices.",
862
+ inputSchema: {
863
+ type: "object",
864
+ properties: {
865
+ query: schema("string", "search query for APIs, libraries, SDKs (e.g. 'Express.js middleware', 'React useState hook')"),
866
+ tokensNum: schema("number", "amount of context to return, 1000-50000 (default: 5000)")
867
+ },
868
+ required: ["query"]
869
+ },
870
+ async execute(args, ctx) {
871
+ const query = String(args.query || "").trim()
872
+ if (!query) return "error: query is required"
873
+ try {
874
+ const result = await callExaMcp("get_code_context_exa", {
875
+ query,
876
+ tokensNum: Math.min(Math.max(Number(args.tokensNum) || 5000, 1000), 50000)
877
+ }, ctx.signal)
878
+ return result || "No code context found. Try a more specific query."
879
+ } catch (error) {
880
+ if (error.name === "AbortError" || error.name === "TimeoutError") return "error: code search request timed out"
881
+ return `error: ${error.message}`
882
+ }
883
+ }
884
+ }
885
+
886
+ const multieditTool = {
887
+ name: "multiedit",
888
+ description: "Apply multiple file edits atomically in a single operation. All changes succeed together or are rolled back entirely. Use this instead of multiple sequential `edit` calls when modifying related code across files (e.g. renaming an export and updating all imports). Each file must have been `read` first.",
889
+ inputSchema: {
890
+ type: "object",
891
+ properties: {
892
+ changes: {
893
+ type: "array",
894
+ description: "list of file changes to apply atomically",
895
+ items: {
896
+ type: "object",
897
+ properties: {
898
+ path: schema("string", "file path"),
899
+ before: schema("string", "text to find (required for edits, omit for new file creation)"),
900
+ after: schema("string", "replacement text (for edits) or full content (for new files)"),
901
+ replace_all: schema("boolean", "replace all occurrences of before (default: false)")
902
+ },
903
+ required: ["path", "after"]
904
+ }
905
+ }
906
+ },
907
+ required: ["changes"]
908
+ },
909
+ async execute(args, ctx) {
910
+ const changes = Array.isArray(args.changes) ? args.changes : []
911
+ if (!changes.length) return "error: at least one change is required"
912
+
913
+ // Phase 1: validate all changes and collect original content for rollback
914
+ const snapshots = [] // { path, original, isNew }
915
+ const resolved = []
916
+ for (const change of changes) {
917
+ const target = path.resolve(ctx.cwd, change.path)
918
+ const isCreate = !change.before && change.before !== ""
919
+ if (!isCreate && !wasFileRead(target)) {
920
+ const fileExists = await exists(target)
921
+ if (fileExists) {
922
+ return `error: you must read "${change.path}" before editing it. Use the read tool first.`
923
+ }
924
+ }
925
+ let original = null
926
+ try {
927
+ original = await readFile(target, "utf8")
928
+ } catch { /* new file */ }
929
+
930
+ if (!isCreate) {
931
+ const matches = (original || "").split(change.before).length - 1
932
+ if (matches === 0) return `error: no match for "before" in ${change.path}. Re-read the file and check your snippet.`
933
+ if (matches > 1 && !change.replace_all) return `error: ${matches} matches in ${change.path} — set replace_all: true or provide more context.`
934
+ }
935
+
936
+ snapshots.push({ path: target, original, isNew: original === null })
937
+ resolved.push({ target, ...change, isCreate })
938
+ }
939
+
940
+ // Phase 2: apply all changes
941
+ const applied = []
942
+ try {
943
+ for (const change of resolved) {
944
+ if (change.isCreate) {
945
+ await atomicWriteFile(change.target, String(change.after))
946
+ } else {
947
+ const content = await readFile(change.target, "utf8")
948
+ const next = change.replace_all
949
+ ? content.replaceAll(change.before, change.after)
950
+ : content.replace(change.before, change.after)
951
+ await atomicWriteFile(change.target, next)
952
+ }
953
+ markFileRead(change.target)
954
+ applied.push(change.target)
955
+ }
956
+ } catch (error) {
957
+ // Rollback all applied changes
958
+ for (let i = applied.length - 1; i >= 0; i--) {
959
+ const snap = snapshots.find(s => s.path === applied[i])
960
+ if (!snap) continue
961
+ try {
962
+ if (snap.isNew) {
963
+ await unlink(applied[i]).catch(() => {})
964
+ } else if (snap.original !== null) {
965
+ await atomicWriteFile(applied[i], snap.original)
966
+ }
967
+ } catch { /* best effort rollback */ }
968
+ }
969
+ return `error: failed at ${applied.length + 1}/${resolved.length} — all changes rolled back. Cause: ${error.message}`
970
+ }
971
+
972
+ // Phase 3: summarize
973
+ const summary = resolved.map(c => ` ${c.isCreate ? "+" : "~"} ${c.path}`).join("\n")
974
+ return {
975
+ output: `${resolved.length} file(s) updated atomically:\n${summary}`,
976
+ metadata: {
977
+ fileChanges: resolved.map(c => ({
978
+ path: String(c.path || c.target),
979
+ tool: "multiedit",
980
+ stageId: ctx.stageId || null,
981
+ taskId: ctx.logicalTaskId || ctx.taskId || null
982
+ }))
983
+ }
984
+ }
985
+ }
986
+ }
987
+
988
+ const enterPlanTool = {
989
+ name: "enter_plan",
990
+ description: "Enter planning mode. Use this PROACTIVELY when the task is non-trivial and requires architectural decisions, multi-file changes, or when multiple valid approaches exist. After calling this, outline your plan, then call `exit_plan` to present it to the user for approval.",
991
+ inputSchema: {
992
+ type: "object",
993
+ properties: {
994
+ reason: schema("string", "why planning is needed (shown to user)")
995
+ },
996
+ required: []
997
+ },
998
+ async execute(args, ctx) {
999
+ ctx._planMode = true
1000
+ return `Planning mode entered. Outline your plan now, then call exit_plan to present it for user approval.${args.reason ? ` Reason: ${args.reason}` : ""}`
1001
+ }
1002
+ }
1003
+
1004
+ const exitPlanTool = {
1005
+ name: "exit_plan",
1006
+ description: "Present your plan to the user for approval. The user will see the plan and can approve, reject, or request changes. Only call this after enter_plan and after you have outlined a complete plan in your response.",
1007
+ inputSchema: {
1008
+ type: "object",
1009
+ properties: {
1010
+ plan: schema("string", "the complete plan text to present to the user"),
1011
+ files: {
1012
+ type: "array", items: { type: "string" },
1013
+ description: "list of files that will be created or modified"
1014
+ }
1015
+ },
1016
+ required: ["plan"]
1017
+ },
1018
+ async execute(args, ctx) {
1019
+ ctx._planMode = false
1020
+ return {
1021
+ output: "Plan submitted for user approval.",
1022
+ metadata: {
1023
+ planApproval: true,
1024
+ plan: String(args.plan || ""),
1025
+ files: Array.isArray(args.files) ? args.files : []
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ const notebookeditTool = {
1032
+ name: "notebookedit",
1033
+ description: "Edit a Jupyter notebook (.ipynb) cell. Supports replace, insert, and delete operations on individual cells. Use this instead of `write` when modifying notebooks — it preserves cell metadata and outputs.",
1034
+ inputSchema: {
1035
+ type: "object",
1036
+ properties: {
1037
+ path: schema("string", "notebook file path (.ipynb)"),
1038
+ cell_number: schema("number", "0-indexed cell number to operate on (default: 0)"),
1039
+ new_source: schema("string", "new cell source content"),
1040
+ cell_type: { type: "string", enum: ["code", "markdown"], description: "cell type (required for insert)" },
1041
+ edit_mode: { type: "string", enum: ["replace", "insert", "delete"], description: "operation type (default: replace)" }
1042
+ },
1043
+ required: ["path", "new_source"]
1044
+ },
1045
+ async execute(args, ctx) {
1046
+ const target = path.resolve(ctx.cwd, args.path)
1047
+ const raw = await readFile(target, "utf8")
1048
+ const notebook = JSON.parse(raw)
1049
+ if (!notebook.cells || !Array.isArray(notebook.cells)) {
1050
+ return "error: not a valid .ipynb file (missing cells array)"
1051
+ }
1052
+ const mode = args.edit_mode || "replace"
1053
+ const cellNum = Number(args.cell_number ?? 0)
1054
+ const source = String(args.new_source ?? "")
1055
+ const sourceLines = source.split("\n").map((line, i, arr) => i < arr.length - 1 ? line + "\n" : line)
1056
+
1057
+ if (mode === "insert") {
1058
+ const cellType = args.cell_type
1059
+ if (!cellType || !["code", "markdown"].includes(cellType)) {
1060
+ return "error: cell_type is required for insert mode (must be 'code' or 'markdown')"
1061
+ }
1062
+ const newCell = {
1063
+ cell_type: cellType,
1064
+ metadata: {},
1065
+ source: sourceLines
1066
+ }
1067
+ if (cellType === "code") {
1068
+ newCell.execution_count = null
1069
+ newCell.outputs = []
1070
+ }
1071
+ const insertAt = cellNum < 0 ? 0 : Math.min(cellNum + 1, notebook.cells.length)
1072
+ notebook.cells.splice(insertAt, 0, newCell)
1073
+ } else if (mode === "delete") {
1074
+ if (cellNum < 0 || cellNum >= notebook.cells.length) {
1075
+ return `error: cell_number ${cellNum} out of range (0-${notebook.cells.length - 1})`
1076
+ }
1077
+ notebook.cells.splice(cellNum, 1)
1078
+ } else {
1079
+ // replace
1080
+ if (cellNum < 0 || cellNum >= notebook.cells.length) {
1081
+ return `error: cell_number ${cellNum} out of range (0-${notebook.cells.length - 1})`
1082
+ }
1083
+ const cell = notebook.cells[cellNum]
1084
+ cell.source = sourceLines
1085
+ if (args.cell_type && args.cell_type !== cell.cell_type) {
1086
+ cell.cell_type = args.cell_type
1087
+ if (args.cell_type === "markdown") {
1088
+ delete cell.execution_count
1089
+ delete cell.outputs
1090
+ } else if (args.cell_type === "code") {
1091
+ cell.execution_count = null
1092
+ cell.outputs = []
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ await atomicWriteFile(target, JSON.stringify(notebook, null, 1) + "\n")
1098
+ markFileRead(target)
1099
+ const actionLabel = mode === "insert" ? "inserted" : mode === "delete" ? "deleted" : "replaced"
1100
+ return {
1101
+ output: `${actionLabel} cell ${cellNum} in ${args.path} (${notebook.cells.length} cells total)`,
1102
+ metadata: {
1103
+ fileChanges: [{
1104
+ path: String(args.path || target),
1105
+ tool: "notebookedit",
1106
+ stageId: ctx.stageId || null,
1107
+ taskId: ctx.logicalTaskId || ctx.taskId || null
1108
+ }]
1109
+ }
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ const patchTool = {
1115
+ name: "patch",
1116
+ description: "Replace a range of lines in a file by line numbers. Read the file first with `read` (use offset/limit for large files) to see line numbers, then specify the line range to replace. Lines are 1-based and inclusive. Ideal for modifying specific sections of large files without needing to match exact text.",
1117
+ inputSchema: {
1118
+ type: "object",
1119
+ properties: {
1120
+ path: schema("string", "file path"),
1121
+ start_line: schema("number", "first line to replace (1-based, inclusive)"),
1122
+ end_line: schema("number", "last line to replace (1-based, inclusive)"),
1123
+ content: schema("string", "replacement content (replaces the line range). Empty string deletes lines.")
1124
+ },
1125
+ required: ["path", "start_line", "end_line", "content"]
1126
+ },
1127
+ async execute(args, ctx) {
1128
+ const target = path.resolve(ctx.cwd, args.path)
1129
+
1130
+ if (!wasFileRead(target)) {
1131
+ try {
1132
+ await access(target)
1133
+ return {
1134
+ output: `warning: you should read "${args.path}" before patching it. Use the read tool first.`,
1135
+ metadata: { fileChanges: [] }
1136
+ }
1137
+ } catch { /* new file — allow */ }
1138
+ }
1139
+
1140
+ const readInfo = fileReadTracker.get(target)
1141
+ if (readInfo) {
1142
+ try {
1143
+ const fileStat = await stat(target)
1144
+ if (fileStat.mtimeMs > readInfo.readAt + 500) {
1145
+ return {
1146
+ output: `warning: "${args.path}" was modified since you last read it. Read it again before patching.`,
1147
+ metadata: { fileChanges: [] }
1148
+ }
1149
+ }
1150
+ } catch { /* ok */ }
1151
+ }
1152
+
1153
+ const startLine = Math.max(1, Number(args.start_line) || 1)
1154
+ const endLine = Math.max(startLine, Number(args.end_line) || startLine)
1155
+ const content = String(args.content ?? "")
1156
+
1157
+ const options = lockOptions(ctx)
1158
+ let result
1159
+ const runPatch = async () => {
1160
+ const existing = await readFile(target, "utf8")
1161
+ const lines = existing.split("\n")
1162
+ if (startLine > lines.length) {
1163
+ throw new Error(`start_line ${startLine} exceeds file length (${lines.length} lines)`)
1164
+ }
1165
+ const startIdx = startLine - 1
1166
+ const endIdx = Math.min(endLine, lines.length)
1167
+ const newLines = content === "" ? [] : content.split("\n")
1168
+ lines.splice(startIdx, endIdx - startIdx, ...newLines)
1169
+ const final = lines.join("\n")
1170
+ await atomicWriteFile(target, final)
1171
+ return { removedCount: endIdx - startIdx, insertedCount: newLines.length, previous: existing, final }
1172
+ }
1173
+
1174
+ if (options.mode === "file_lock") {
1175
+ result = await withFileLock({ targetPath: target, owner: options.owner, waitTimeoutMs: options.waitTimeoutMs, run: runPatch })
1176
+ } else {
1177
+ result = await runPatch()
1178
+ }
1179
+
1180
+ markFileRead(target)
1181
+ return {
1182
+ output: `patched ${args.path}: replaced lines ${startLine}-${endLine} (removed ${result.removedCount}, inserted ${result.insertedCount})`,
1183
+ metadata: {
1184
+ fileChanges: [{
1185
+ path: String(args.path || target),
1186
+ tool: "patch",
1187
+ addedLines: result.insertedCount,
1188
+ removedLines: result.removedCount,
1189
+ stageId: ctx.stageId || null,
1190
+ taskId: ctx.logicalTaskId || ctx.taskId || null
1191
+ }]
1192
+ }
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ const gitTools = config?.git_auto?.enabled !== false ? gitAutoTools : []
1198
+ const gitFullAutoToolsList = config?.git_auto?.full_auto === true ? gitFullAutoTools : []
1199
+
1200
+ return [listTool, readTool, writeTool, editTool, patchTool, multieditTool, globTool, grepTool, bashTool, createTaskTool(), outputTool, cancelTool, todowriteTool, questionTool, skillTool, webfetchTool, websearchTool, codesearchTool, notebookeditTool, enterPlanTool, exitPlanTool, ...gitTools, ...gitFullAutoToolsList]
1201
+ }
1202
+
1203
+ function mcpTools() {
1204
+ return McpRegistry.listTools().map((tool) => ({
1205
+ name: tool.id,
1206
+ description: `[mcp:${tool.server}] ${tool.description}`,
1207
+ inputSchema: tool.inputSchema,
1208
+ async execute(args, ctx) {
1209
+ const result = await McpRegistry.callTool(tool.id, args || {}, ctx.signal || null)
1210
+ return result.output
1211
+ }
1212
+ }))
1213
+ }
1214
+
1215
+ function toolAllowedByMode(toolName, mode) {
1216
+ if (mode === "ask" || mode === "plan") {
1217
+ return !["write", "edit", "patch", "bash", "task", "git_snapshot", "git_restore", "git_apply_patch", "git_delete_snapshot"].includes(toolName)
1218
+ }
1219
+ return true
1220
+ }
1221
+
1222
+ export const ToolRegistry = {
1223
+ async initialize({ config = {}, cwd = process.cwd(), force = false } = {}) {
1224
+ const ttlMs = Math.max(0, Number(config.runtime?.tool_registry_cache_ttl_ms || 30000))
1225
+ const sig = signatureFor(config, cwd)
1226
+ const cacheValid =
1227
+ state.initialized &&
1228
+ !force &&
1229
+ state.lastSignature === sig &&
1230
+ state.lastCwd === cwd &&
1231
+ Date.now() - state.loadedAt <= ttlMs
1232
+ if (cacheValid) return
1233
+
1234
+ const tools = []
1235
+
1236
+ if (config.tool?.sources?.builtin !== false) {
1237
+ tools.push(...builtinTools(config))
1238
+ }
1239
+
1240
+ if (config.tool?.sources?.local !== false) {
1241
+ const localDirs = (config.tool?.local_dirs || []).map((dir) => path.resolve(cwd, dir))
1242
+ tools.push(...(await loadDynamicTools(localDirs)))
1243
+ }
1244
+
1245
+ if (config.tool?.sources?.plugin !== false) {
1246
+ const pluginDirs = (config.tool?.plugin_dirs || []).map((dir) => path.resolve(cwd, dir))
1247
+ tools.push(...(await loadDynamicTools(pluginDirs)))
1248
+ }
1249
+
1250
+ if (config.tool?.sources?.mcp !== false) {
1251
+ await McpRegistry.initialize(config, { cwd })
1252
+ tools.push(...mcpTools())
1253
+ }
1254
+
1255
+ state.tools = tools
1256
+ state.initialized = true
1257
+ state.loadedAt = Date.now()
1258
+ state.lastSignature = sig
1259
+ state.lastCwd = cwd
1260
+ state.lastConfig = config
1261
+ },
1262
+
1263
+ isReady() {
1264
+ return state.initialized
1265
+ },
1266
+
1267
+ async list({ mode, cwd = process.cwd(), config = undefined } = {}) {
1268
+ const resolvedConfig = config === undefined ? state.lastConfig || {} : config
1269
+ if (!state.initialized) {
1270
+ await this.initialize({ config: resolvedConfig, cwd })
1271
+ } else {
1272
+ await this.initialize({ config: resolvedConfig, cwd, force: false })
1273
+ }
1274
+ return state.tools
1275
+ .filter((tool) => toolAllowedByMode(tool.name, mode))
1276
+ .map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }))
1277
+ },
1278
+
1279
+ async get(toolName) {
1280
+ return state.tools.find((tool) => tool.name === toolName) || null
1281
+ },
1282
+
1283
+ async call(toolName, args, ctx) {
1284
+ const tool = await this.get(toolName)
1285
+ if (!tool) {
1286
+ return {
1287
+ name: toolName,
1288
+ status: "error",
1289
+ output: `unknown tool: ${toolName}`,
1290
+ error: `unknown tool: ${toolName}`
1291
+ }
1292
+ }
1293
+ try {
1294
+ const output = await tool.execute(args || {}, ctx)
1295
+ return {
1296
+ name: toolName,
1297
+ status: "completed",
1298
+ output: safeStringify(output)
1299
+ }
1300
+ } catch (error) {
1301
+ return {
1302
+ name: toolName,
1303
+ status: "error",
1304
+ output: error.message,
1305
+ error: error.message
1306
+ }
1307
+ }
1308
+ }
1309
+ }