@kkelly-offical/kkcode 0.1.3 → 0.1.7

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 (66) hide show
  1. package/README.md +110 -172
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +220 -170
  4. package/src/agent/prompt/bug-hunter.txt +90 -0
  5. package/src/agent/prompt/frontend-designer.txt +58 -0
  6. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  7. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  8. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  9. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  10. package/src/config/defaults.mjs +260 -195
  11. package/src/config/schema.mjs +71 -6
  12. package/src/core/constants.mjs +91 -46
  13. package/src/index.mjs +1 -1
  14. package/src/knowledge/frontend-aesthetics.txt +39 -0
  15. package/src/knowledge/loader.mjs +2 -1
  16. package/src/knowledge/tailwind.txt +12 -3
  17. package/src/mcp/client-http.mjs +141 -157
  18. package/src/mcp/client-sse.mjs +288 -286
  19. package/src/mcp/client-stdio.mjs +533 -451
  20. package/src/mcp/constants.mjs +2 -0
  21. package/src/mcp/registry.mjs +479 -394
  22. package/src/mcp/stdio-framing.mjs +133 -127
  23. package/src/mcp/tool-result.mjs +24 -0
  24. package/src/observability/index.mjs +42 -0
  25. package/src/observability/metrics.mjs +137 -0
  26. package/src/observability/tracer.mjs +137 -0
  27. package/src/orchestration/background-manager.mjs +372 -358
  28. package/src/orchestration/background-worker.mjs +305 -245
  29. package/src/orchestration/longagent-manager.mjs +171 -116
  30. package/src/orchestration/stage-scheduler.mjs +728 -489
  31. package/src/permission/exec-policy.mjs +9 -11
  32. package/src/provider/anthropic.mjs +1 -0
  33. package/src/provider/openai.mjs +340 -339
  34. package/src/provider/retry-policy.mjs +68 -68
  35. package/src/provider/router.mjs +241 -228
  36. package/src/provider/sse.mjs +104 -91
  37. package/src/repl.mjs +59 -7
  38. package/src/session/checkpoint.mjs +66 -3
  39. package/src/session/compaction.mjs +298 -276
  40. package/src/session/engine.mjs +232 -225
  41. package/src/session/longagent-4stage.mjs +460 -0
  42. package/src/session/longagent-hybrid.mjs +1097 -0
  43. package/src/session/longagent-plan.mjs +365 -329
  44. package/src/session/longagent-project-memory.mjs +53 -0
  45. package/src/session/longagent-scaffold.mjs +291 -100
  46. package/src/session/longagent-task-bus.mjs +54 -0
  47. package/src/session/longagent-utils.mjs +472 -0
  48. package/src/session/longagent.mjs +900 -1462
  49. package/src/session/loop.mjs +65 -40
  50. package/src/session/project-context.mjs +30 -0
  51. package/src/session/prompt/agent.txt +25 -0
  52. package/src/session/prompt/plan.txt +31 -9
  53. package/src/session/rollback.mjs +196 -0
  54. package/src/session/store.mjs +519 -503
  55. package/src/session/system-prompt.mjs +273 -260
  56. package/src/session/task-validator.mjs +4 -3
  57. package/src/skill/builtin/design.mjs +76 -0
  58. package/src/skill/builtin/frontend.mjs +8 -0
  59. package/src/skill/registry.mjs +390 -336
  60. package/src/storage/ghost-commit-store.mjs +18 -8
  61. package/src/tool/executor.mjs +11 -0
  62. package/src/tool/git-auto.mjs +0 -19
  63. package/src/tool/question-prompt.mjs +93 -86
  64. package/src/tool/registry.mjs +71 -37
  65. package/src/ui/activity-renderer.mjs +664 -410
  66. package/src/util/git.mjs +23 -0
@@ -6,6 +6,9 @@ const GHOST_COMMIT_DIR = "ghost-commits"
6
6
  const MAX_GHOST_COMMITS_PER_REPO = 50 // 每个仓库最多保留的幽灵提交数
7
7
  const GHOST_COMMIT_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7天过期
8
8
 
9
+ // 防止并发 cleanup 竞态:per-repo 锁
10
+ const cleanupLocks = new Map()
11
+
9
12
  /**
10
13
  * Ghost Commit 存储管理
11
14
  *
@@ -152,16 +155,23 @@ export async function deleteGhostCommit(repoPath, ghostCommitId) {
152
155
  * @param {string} repoPath - 仓库路径
153
156
  */
154
157
  export async function cleanupOldGhostCommits(repoPath) {
155
- const commits = await listGhostCommits(repoPath, { includeExpired: true })
158
+ // 同一 repo cleanup 串行化,防止并发竞态
159
+ if (cleanupLocks.get(repoPath)) return
160
+ cleanupLocks.set(repoPath, true)
161
+ try {
162
+ const commits = await listGhostCommits(repoPath, { includeExpired: true })
156
163
 
157
- if (commits.length <= MAX_GHOST_COMMITS_PER_REPO) {
158
- return
159
- }
164
+ if (commits.length <= MAX_GHOST_COMMITS_PER_REPO) {
165
+ return
166
+ }
160
167
 
161
- // 删除多余的旧提交
162
- const toDelete = commits.slice(MAX_GHOST_COMMITS_PER_REPO)
163
- for (const commit of toDelete) {
164
- await deleteGhostCommit(repoPath, commit.id)
168
+ // 删除多余的旧提交
169
+ const toDelete = commits.slice(MAX_GHOST_COMMITS_PER_REPO)
170
+ for (const commit of toDelete) {
171
+ await deleteGhostCommit(repoPath, commit.id)
172
+ }
173
+ } finally {
174
+ cleanupLocks.delete(repoPath)
165
175
  }
166
176
  }
167
177
 
@@ -2,6 +2,10 @@ import { makeToolResult } from "../core/types.mjs"
2
2
  import { EventBus } from "../core/events.mjs"
3
3
  import { EVENT_TYPES } from "../core/constants.mjs"
4
4
  import { withAudit } from "./audit-wrapper.mjs"
5
+ import { autoSnapshotBeforeEdit } from "../session/checkpoint.mjs"
6
+
7
+ const FILE_EDIT_TOOLS = new Set(["write", "edit", "multiedit", "patch", "notebookedit"])
8
+ const snapshotted = new Set()
5
9
 
6
10
  export async function executeTool({ tool, args, sessionId, turnId, context, signal = null }) {
7
11
  return withAudit({
@@ -44,6 +48,13 @@ export async function executeTool({ tool, args, sessionId, turnId, context, sign
44
48
  return cancelled
45
49
  }
46
50
 
51
+ // Auto snapshot before first file edit per turn
52
+ if (FILE_EDIT_TOOLS.has(tool.name) && !snapshotted.has(turnId)) {
53
+ snapshotted.add(turnId)
54
+ if (snapshotted.size > 200) snapshotted.clear()
55
+ autoSnapshotBeforeEdit(sessionId, context.cwd, context.config).catch(() => {})
56
+ }
57
+
47
58
  const raw = await tool.execute(args || {}, context)
48
59
  let output = ""
49
60
  let metadata = {}
@@ -524,22 +524,3 @@ export async function getLastSnapshotId(sessionId) {
524
524
  return state.lastSnapshotId
525
525
  }
526
526
 
527
- /**
528
- * 在修改前自动创建快照(如果配置启用)
529
- */
530
- export async function autoSnapshotBeforeEdit(sessionId, cwd, config = {}) {
531
- if (!config.git_auto?.enabled || !config.git_auto?.auto_snapshot) {
532
- return { skipped: true, reason: "auto_snapshot_disabled" }
533
- }
534
-
535
- if (!(await isGitRepo(cwd))) {
536
- return { skipped: true, reason: "not_a_git_repo" }
537
- }
538
-
539
- const result = await gitSnapshotTool.execute(
540
- { auto: true, message: "Auto snapshot before AI edit" },
541
- { cwd, sessionId }
542
- )
543
-
544
- return result
545
- }
@@ -1,86 +1,93 @@
1
- import { stdin as input, stdout as output } from "node:process"
2
- import { createInterface } from "node:readline/promises"
3
-
4
- let customPromptHandler = null
5
-
6
- export function setQuestionPromptHandler(handler) {
7
- customPromptHandler = typeof handler === "function" ? handler : null
8
- }
9
-
10
- export async function askQuestionInteractive({ questions }) {
11
- if (!Array.isArray(questions) || questions.length === 0) {
12
- return {}
13
- }
14
-
15
- // 1. TUI handler (registered by repl.mjs)
16
- if (customPromptHandler) {
17
- const answers = await customPromptHandler({ questions })
18
- if (answers && typeof answers === "object") return answers
19
- }
20
-
21
- // 2. Non-TTY: return empty answers
22
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
23
- return Object.fromEntries(questions.map((q) => [q.id, ""]))
24
- }
25
-
26
- // 3. TTY fallback: readline sequential Q&A
27
- const rl = createInterface({ input, output })
28
- const answers = {}
29
- try {
30
- for (const q of questions) {
31
- console.log("")
32
- console.log(` ${q.text}`)
33
- if (q.description) console.log(` ${q.description}`)
34
- const options = Array.isArray(q.options) ? q.options : []
35
- if (options.length) {
36
- for (let i = 0; i < options.length; i++) {
37
- const opt = options[i]
38
- console.log(` ${i + 1}. ${opt.label}`)
39
- if (opt.description) console.log(` ${opt.description}`)
40
- }
41
- if (q.allowCustom !== false) {
42
- console.log(` ${options.length + 1}. Custom...`)
43
- }
44
- }
45
- const raw = (await rl.question(" > ")).trim()
46
- if (options.length) {
47
- const idx = parseInt(raw, 10)
48
- if (idx >= 1 && idx <= options.length) {
49
- const chosen = options[idx - 1]
50
- answers[q.id] = chosen.value || chosen.label
51
- } else {
52
- answers[q.id] = raw
53
- }
54
- } else {
55
- answers[q.id] = raw
56
- }
57
- }
58
- } finally {
59
- rl.close()
60
- }
61
- return answers
62
- }
63
-
64
- export async function askPlanApproval({ plan, files = [] }) {
65
- const fileList = files.length ? `\nFiles to modify:\n${files.map(f => ` - ${f}`).join("\n")}` : ""
66
- const questions = [
67
- {
68
- id: "plan_approval",
69
- text: `Plan Review`,
70
- description: `${plan}${fileList}`,
71
- options: [
72
- { label: "Approve", value: "approve", description: "Proceed with this plan" },
73
- { label: "Reject", value: "reject", description: "Reject and provide feedback" }
74
- ],
75
- multi: false,
76
- allowCustom: true
77
- }
78
- ]
79
- const answers = await askQuestionInteractive({ questions })
80
- const answer = String(answers.plan_approval || "").trim().toLowerCase()
81
- if (answer === "approve" || answer === "1") {
82
- return { approved: true, feedback: "" }
83
- }
84
- const feedback = answer === "reject" || answer === "2" ? "" : answer
85
- return { approved: false, feedback }
86
- }
1
+ import { stdin as input, stdout as output } from "node:process"
2
+ import { createInterface } from "node:readline/promises"
3
+
4
+ let customPromptHandler = null
5
+
6
+ export function setQuestionPromptHandler(handler) {
7
+ customPromptHandler = typeof handler === "function" ? handler : null
8
+ }
9
+
10
+ export async function askQuestionInteractive({ questions }) {
11
+ if (!Array.isArray(questions) || questions.length === 0) {
12
+ return {}
13
+ }
14
+
15
+ // 1. TUI handler (registered by repl.mjs)
16
+ if (customPromptHandler) {
17
+ const answers = await customPromptHandler({ questions })
18
+ if (answers && typeof answers === "object") return answers
19
+ }
20
+
21
+ // 2. Non-TTY: return empty answers
22
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
23
+ return Object.fromEntries(questions.map((q) => [q.id, ""]))
24
+ }
25
+
26
+ // 3. TTY fallback: readline sequential Q&A
27
+ const rl = createInterface({ input, output })
28
+ const answers = {}
29
+ try {
30
+ for (const q of questions) {
31
+ console.log("")
32
+ console.log(` ${q.text}`)
33
+ if (q.description) console.log(` ${q.description}`)
34
+ const options = Array.isArray(q.options) ? q.options : []
35
+ if (options.length) {
36
+ for (let i = 0; i < options.length; i++) {
37
+ const opt = options[i]
38
+ console.log(` ${i + 1}. ${opt.label}`)
39
+ if (opt.description) console.log(` ${opt.description}`)
40
+ }
41
+ if (q.allowCustom !== false) {
42
+ console.log(` ${options.length + 1}. Custom...`)
43
+ }
44
+ }
45
+ const raw = (await rl.question(" > ")).trim()
46
+ if (options.length) {
47
+ const idx = parseInt(raw, 10)
48
+ if (idx >= 1 && idx <= options.length) {
49
+ const chosen = options[idx - 1]
50
+ answers[q.id] = chosen.value || chosen.label
51
+ } else {
52
+ answers[q.id] = raw
53
+ }
54
+ } else {
55
+ answers[q.id] = raw
56
+ }
57
+ }
58
+ } finally {
59
+ rl.close()
60
+ }
61
+ return answers
62
+ }
63
+
64
+ export async function askPlanApproval({ plan, files = [] }) {
65
+ const fileList = files.length ? `\nFiles to modify:\n${files.map(f => ` - ${f}`).join("\n")}` : ""
66
+ const questions = [
67
+ {
68
+ id: "plan_approval",
69
+ text: `Plan Review`,
70
+ description: `${plan}${fileList}`,
71
+ options: [
72
+ { label: "Approve", value: "approve", description: "Proceed with this plan" },
73
+ { label: "Request Changes", value: "changes", description: "Revise and resubmit with feedback" },
74
+ { label: "Reject", value: "reject", description: "Cancel this plan entirely" }
75
+ ],
76
+ multi: false,
77
+ allowCustom: true
78
+ }
79
+ ]
80
+ const answers = await askQuestionInteractive({ questions })
81
+ const answer = String(answers.plan_approval || "").trim().toLowerCase()
82
+ if (answer === "approve" || answer === "1") {
83
+ return { approved: true, requestChanges: false, feedback: "" }
84
+ }
85
+ if (answer === "changes" || answer === "2") {
86
+ return { approved: false, requestChanges: true, feedback: "" }
87
+ }
88
+ if (answer === "reject" || answer === "3") {
89
+ return { approved: false, requestChanges: false, feedback: "" }
90
+ }
91
+ // Custom text input: treat as "request changes" with the text as feedback
92
+ return { approved: false, requestChanges: true, feedback: answer }
93
+ }
@@ -1,7 +1,7 @@
1
1
  import path from "node:path"
2
2
  import { readdir, readFile } from "node:fs/promises"
3
3
  import { access, stat, unlink } from "node:fs/promises"
4
- import { exec as execCb } from "node:child_process"
4
+ import { exec as execCb, spawn } from "node:child_process"
5
5
  import { promisify } from "node:util"
6
6
  import { pathToFileURL } from "node:url"
7
7
  import { atomicWriteFile, replaceInFileTransactional, replaceAllInFileTransactional, diffLineCount } from "./edit-transaction.mjs"
@@ -23,7 +23,8 @@ const state = {
23
23
  loadedAt: 0,
24
24
  lastSignature: "",
25
25
  lastCwd: "",
26
- lastConfig: null
26
+ lastConfig: null,
27
+ refreshing: false
27
28
  }
28
29
 
29
30
  function schema(type, description) {
@@ -70,16 +71,37 @@ function wasFileRead(filePath) {
70
71
  return fileReadTracker.has(filePath)
71
72
  }
72
73
 
74
+ function runRg(args, cwd, timeoutMs = 30000) {
75
+ return new Promise((resolve) => {
76
+ let stdout = "", stderr = "", done = false
77
+ const child = spawn("rg", ["--no-config", ...args], {
78
+ cwd, windowsHide: true, stdio: ["ignore", "pipe", "pipe"]
79
+ })
80
+ const timer = setTimeout(() => {
81
+ if (done) return
82
+ done = true
83
+ child.kill("SIGTERM")
84
+ setTimeout(() => { try { child.kill("SIGKILL") } catch {} }, 2000).unref()
85
+ resolve({ ok: false, stdout, stderr: "search timed out" })
86
+ }, timeoutMs)
87
+ child.stdout.on("data", (b) => { stdout += b })
88
+ child.stderr.on("data", (b) => { stderr += b })
89
+ child.on("error", (e) => {
90
+ if (done) return; done = true; clearTimeout(timer)
91
+ resolve({ ok: false, stdout, stderr: e.message })
92
+ })
93
+ child.on("close", (code) => {
94
+ if (done) return; done = true; clearTimeout(timer)
95
+ resolve({ ok: code === 0 || code === 1, stdout: stdout.trim(), stderr: stderr.trim() })
96
+ })
97
+ })
98
+ }
99
+
73
100
  async function runGlob(pattern, cwd, searchPath) {
74
101
  if (!pattern) return "pattern is required"
75
- const escaped = pattern.replace(/"/g, '\\"')
76
102
  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()
103
+ const { stdout } = await runRg(["--files", "--glob", pattern, target], cwd, 15000)
104
+ const text = stdout.trim()
83
105
  if (!text) return "no files matched"
84
106
  const lines = text.split("\n").filter(Boolean)
85
107
  if (lines.length > 200) {
@@ -90,31 +112,23 @@ async function runGlob(pattern, cwd, searchPath) {
90
112
 
91
113
  async function runGrep(pattern, cwd, options = {}) {
92
114
  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
115
+ const args = []
116
+ if (options.multiline) args.push("-U", "--multiline-dotall")
117
+ if (options.outputMode === "count") args.push("-c")
118
+ else if (options.outputMode === "files") args.push("-l")
119
+ else args.push("-n")
120
+ if (options.beforeContext) args.push("-B", String(options.beforeContext))
121
+ if (options.afterContext) args.push("-A", String(options.afterContext))
122
+ if (options.context) args.push("-C", String(options.context))
123
+ if (options.type) args.push("--type", options.type)
124
+ if (options.glob) args.push("--glob", options.glob)
125
+ if (options.maxCount) args.push("-m", String(options.maxCount))
126
+ if (options.ignoreCase) args.push("-i")
127
+ args.push(pattern)
128
+ args.push(options.path ? path.resolve(cwd, options.path) : ".")
129
+ const { stdout, stderr } = await runRg(args, cwd)
130
+ let text = stdout.trim()
131
+ if (!text && stderr) text = `[search error] ${stderr}`
118
132
  if (text && (options.offset || options.headLimit)) {
119
133
  const lines = text.split("\n")
120
134
  const start = options.offset || 0
@@ -566,7 +580,8 @@ function builtinTools(config) {
566
580
  },
567
581
  async execute(args, ctx) {
568
582
  const command = String(args.command || "")
569
- const timeoutMs = Math.min(Math.max(Number(args.timeout) || BASH_TIMEOUT_MS, 1000), 600_000)
583
+ const configBashTimeout = Number(ctx.config?.tool?.bash_timeout_ms || BASH_TIMEOUT_MS)
584
+ const timeoutMs = Math.min(Math.max(Number(args.timeout) || configBashTimeout, 1000), 600_000)
570
585
 
571
586
  // 执行策略检查
572
587
  const policyCheck = checkBashAllowed(command, ctx.config)
@@ -1206,8 +1221,14 @@ function mcpTools() {
1206
1221
  description: `[mcp:${tool.server}] ${tool.description}`,
1207
1222
  inputSchema: tool.inputSchema,
1208
1223
  async execute(args, ctx) {
1209
- const result = await McpRegistry.callTool(tool.id, args || {}, ctx.signal || null)
1210
- return result.output
1224
+ try {
1225
+ const result = await McpRegistry.callTool(tool.id, args || {}, ctx.signal || null)
1226
+ return result.output
1227
+ } catch (error) {
1228
+ const reason = error.reason || "unknown"
1229
+ const server = error.server || tool.server
1230
+ return `[MCP Error: ${server} ${reason}] ${error.message}`
1231
+ }
1211
1232
  }
1212
1233
  }))
1213
1234
  }
@@ -1305,5 +1326,18 @@ export const ToolRegistry = {
1305
1326
  error: error.message
1306
1327
  }
1307
1328
  }
1329
+ },
1330
+
1331
+ refreshMcpTools() {
1332
+ if (!state.initialized || state.refreshing) return
1333
+ state.refreshing = true
1334
+ try {
1335
+ // Atomic replacement: build new list, then assign once
1336
+ const nonMcp = state.tools.filter((t) => !t.name.startsWith("mcp_"))
1337
+ const newMcpTools = mcpTools()
1338
+ state.tools = [...nonMcp, ...newMcpTools]
1339
+ } finally {
1340
+ state.refreshing = false
1341
+ }
1308
1342
  }
1309
1343
  }