@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,905 @@
1
+ import { newId } from "../core/types.mjs"
2
+ import { EventBus } from "../core/events.mjs"
3
+ import { EVENT_TYPES } from "../core/constants.mjs"
4
+ import { requestProviderStream, countTokensProvider } from "../provider/router.mjs"
5
+ import { ToolRegistry } from "../tool/registry.mjs"
6
+ import { executeTool } from "../tool/executor.mjs"
7
+ import { PermissionEngine } from "../permission/engine.mjs"
8
+ import { createTaskDelegate } from "../orchestration/task-scheduler.mjs"
9
+ import { loadInstructions } from "./instruction-loader.mjs"
10
+ import { buildSystemPromptBlocks } from "./system-prompt.mjs"
11
+ import { detectProjectContext } from "./project-context.mjs"
12
+ import { renderRulesPrompt } from "../rules/load-rules.mjs"
13
+ import { SkillRegistry } from "../skill/registry.mjs"
14
+ import {
15
+ touchSession,
16
+ appendMessage,
17
+ appendPart,
18
+ getConversationHistory,
19
+ markSessionStatus,
20
+ updateSession
21
+ } from "./store.mjs"
22
+ import { pendingRejections, markRejectionsConsumed } from "../review/rejection-queue.mjs"
23
+ import { isRecoveryEnabled, markTurnFinished, markTurnInProgress } from "./recovery.mjs"
24
+ import { HookBus, initHookBus } from "../plugin/hook-bus.mjs"
25
+ import { shouldCompact, compactSession, estimateTokenCount, modelContextLimit, contextUtilization, supportsNativeCompaction } from "./compaction.mjs"
26
+ import { createStreamRenderer } from "../theme/markdown.mjs"
27
+ import { paint } from "../theme/color.mjs"
28
+ import { saveCheckpoint } from "./checkpoint.mjs"
29
+ import { askPlanApproval } from "../tool/question-prompt.mjs"
30
+ import { createValidator } from "./task-validator.mjs"
31
+
32
+ const READ_ONLY_TOOLS = new Set([
33
+ "read", "glob", "grep", "list", "webfetch", "websearch", "codesearch", "background_output", "todowrite", "enter_plan", "exit_plan"
34
+ ])
35
+
36
+ function addUsage(target, delta) {
37
+ target.input += delta.input || 0
38
+ target.output += delta.output || 0
39
+ target.cacheRead += delta.cacheRead || 0
40
+ target.cacheWrite += delta.cacheWrite || 0
41
+ }
42
+
43
+
44
+ async function buildSystemPrompt({ mode, model, cwd, agent = null, tools = [], skills = [], language = "en" }) {
45
+ // Assemble user instructions + rules (Layer 6)
46
+ const instructions = await loadInstructions(cwd)
47
+ const rules = await renderRulesPrompt(cwd)
48
+ const userInstructions = [...instructions, rules].filter(Boolean).join("\n\n")
49
+
50
+ // Detect project context (framework, language, build tool, etc.)
51
+ const projectContext = await detectProjectContext(cwd)
52
+
53
+ // Build structured blocks for provider-level cache optimization
54
+ const result = await buildSystemPromptBlocks({ mode, model, cwd, agent, tools, skills, userInstructions, projectContext, language })
55
+ return result
56
+ }
57
+
58
+ function toolPatternFromArgs(args) {
59
+ if (!args || typeof args !== "object") return "*"
60
+ return String(args.path || args.command || args.pattern || args.task_id || "*")
61
+ }
62
+
63
+ function normalizeMessageForCache(msg) {
64
+ const content = msg?.content
65
+ // For array content (image blocks, tool_use, tool_result), serialize to a stable string
66
+ if (Array.isArray(content)) {
67
+ const textParts = content
68
+ .filter((b) => b.type === "text")
69
+ .map((b) => b.text || "")
70
+ .join("\n")
71
+ const imageParts = content
72
+ .filter((b) => b.type === "image")
73
+ .map((b) => `[image:${b.path || "inline"}]`)
74
+ .join(" ")
75
+ const toolUseParts = content
76
+ .filter((b) => b.type === "tool_use")
77
+ .map((b) => `[tool_use:${b.name}:${b.id}]`)
78
+ .join(" ")
79
+ const toolResultParts = content
80
+ .filter((b) => b.type === "tool_result")
81
+ .map((b) => `[tool_result:${b.tool_use_id}:${String(b.content || "").slice(0, 100)}]`)
82
+ .join(" ")
83
+ const extras = [imageParts, toolUseParts, toolResultParts].filter(Boolean).join("\n")
84
+ return {
85
+ role: String(msg?.role || ""),
86
+ content: `${textParts}${extras ? "\n" + extras : ""}`
87
+ }
88
+ }
89
+ return {
90
+ role: String(msg?.role || ""),
91
+ content: String(content || "")
92
+ }
93
+ }
94
+
95
+ function isPrefixMessages(prefix, full) {
96
+ if (!Array.isArray(prefix) || !Array.isArray(full)) return false
97
+ if (prefix.length > full.length) return false
98
+ for (let i = 0; i < prefix.length; i++) {
99
+ if (prefix[i].role !== full[i].role || prefix[i].content !== full[i].content) return false
100
+ }
101
+ return true
102
+ }
103
+
104
+ export async function processTurnLoop({
105
+ prompt,
106
+ contentBlocks = null,
107
+ mode,
108
+ model,
109
+ providerType,
110
+ sessionId,
111
+ configState,
112
+ baseUrl = null,
113
+ apiKeyEnv = null,
114
+ depth = 0,
115
+ signal = null,
116
+ output = null,
117
+ subagent = null,
118
+ agent = null,
119
+ allowQuestion = true,
120
+ toolContext = {}
121
+ }) {
122
+ await initHookBus()
123
+
124
+ if (depth > 8) {
125
+ return {
126
+ sessionId,
127
+ turnId: newId("turn"),
128
+ reply: "task delegation depth exceeded",
129
+ emittedText: false,
130
+ context: null,
131
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
132
+ toolEvents: []
133
+ }
134
+ }
135
+
136
+ const cwd = process.cwd()
137
+ const turnId = newId("turn")
138
+ const configMaxSteps = Math.max(1, Number(configState.config.agent.max_steps || 128))
139
+ const maxSteps = (subagent?.maxTurns > 0) ? Math.min(configMaxSteps, subagent.maxTurns) : configMaxSteps
140
+ const verifyCompletion = configState.config.agent?.verify_completion !== false
141
+ const recoveryEnabled = isRecoveryEnabled(configState.config)
142
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
143
+ const toolEvents = []
144
+ const doomTracker = [] // recent tool call signatures for doom loop detection
145
+ let emittedAnyText = false
146
+ let lastContextMeter = null
147
+ let contextCachePoint = null
148
+ const thresholdRatio = Number(configState.config.session?.compaction_threshold_ratio ?? 0.7)
149
+ const thresholdMessages = Number(configState.config.session?.compaction_threshold_messages ?? 50)
150
+ const cachePointsEnabled = configState.config.session?.context_cache_points !== false
151
+ const useNativeCompaction = supportsNativeCompaction(providerType, model)
152
+ const nativeCompactionTrigger = useNativeCompaction ? Math.floor(modelContextLimit(model, configState) * thresholdRatio) : 0
153
+
154
+ await touchSession({
155
+ sessionId,
156
+ mode,
157
+ model,
158
+ providerType,
159
+ cwd,
160
+ status: "active",
161
+ title: subagent ? `${subagent.name}: ${prompt.slice(0, 60)}` : null
162
+ })
163
+
164
+ await EventBus.emit({
165
+ type: EVENT_TYPES.TURN_START,
166
+ sessionId,
167
+ turnId,
168
+ payload: { mode, model, providerType, prompt }
169
+ })
170
+
171
+ const queue = await pendingRejections(cwd)
172
+ const rejectionText = queue.length
173
+ ? [
174
+ "<review-rejections>",
175
+ ...queue.map((entry, index) => `${index + 1}. file=${entry.file} reason=${entry.reason} risk=${entry.riskScore ?? "unknown"}`),
176
+ "</review-rejections>",
177
+ "Address these rejected changes before introducing new risky edits."
178
+ ].join("\n")
179
+ : ""
180
+ const effectivePrompt = rejectionText ? `${prompt}\n\n${rejectionText}` : prompt
181
+
182
+ // If contentBlocks provided (e.g. images), build array content for the message.
183
+ // Prepend rejection text as a text block if needed.
184
+ let messageContent
185
+ if (contentBlocks && Array.isArray(contentBlocks)) {
186
+ const blocks = [...contentBlocks]
187
+ if (rejectionText) {
188
+ // Find the first text block and prepend rejection text
189
+ const textIdx = blocks.findIndex((b) => b.type === "text")
190
+ if (textIdx >= 0) {
191
+ blocks[textIdx] = { type: "text", text: `${blocks[textIdx].text}\n\n${rejectionText}` }
192
+ } else {
193
+ blocks.unshift({ type: "text", text: rejectionText })
194
+ }
195
+ }
196
+ messageContent = blocks
197
+ } else {
198
+ messageContent = effectivePrompt
199
+ }
200
+
201
+ const userMessage = await appendMessage(sessionId, "user", messageContent, {
202
+ mode,
203
+ model,
204
+ providerType,
205
+ turnId
206
+ })
207
+
208
+ await appendPart(sessionId, {
209
+ type: "turn-start",
210
+ messageId: userMessage.id,
211
+ turnId,
212
+ mode,
213
+ model,
214
+ providerType
215
+ })
216
+
217
+ let systemTools = await ToolRegistry.list({ mode, config: configState.config, cwd })
218
+ if (agent?.tools) {
219
+ systemTools = systemTools.filter((t) => agent.tools.includes(t.name))
220
+ }
221
+ const skills = SkillRegistry.isReady() ? SkillRegistry.listForSystemPrompt() : []
222
+ const language = configState.config.language || "en"
223
+ const systemPrompt = await buildSystemPrompt({ mode, model, cwd, agent, tools: systemTools, skills, language })
224
+ // systemPrompt = { text, blocks } — providers use blocks for cache optimization
225
+ const delegateTask = createTaskDelegate({
226
+ config: configState.config,
227
+ parentSessionId: sessionId,
228
+ model,
229
+ providerType,
230
+ runSubtask: async ({
231
+ prompt: subPrompt,
232
+ sessionId: subSessionId,
233
+ model: subModel,
234
+ providerType: subProvider,
235
+ subagent: resolvedSubagent,
236
+ allowQuestion: subAllowQuestion = false
237
+ }) => {
238
+ return processTurnLoop({
239
+ prompt: subPrompt,
240
+ mode: "agent",
241
+ model: subModel,
242
+ providerType: subProvider,
243
+ sessionId: subSessionId,
244
+ configState,
245
+ baseUrl,
246
+ apiKeyEnv,
247
+ depth: depth + 1,
248
+ signal,
249
+ subagent: resolvedSubagent,
250
+ allowQuestion: subAllowQuestion,
251
+ toolContext
252
+ })
253
+ }
254
+ })
255
+
256
+ const MAX_CONTINUES = 8
257
+ let continueCount = 0
258
+ let nudgeCount = 0
259
+ let finalReply = ""
260
+ const sinkWrite = typeof output?.write === "function"
261
+ ? output.write
262
+ : () => {}
263
+ try {
264
+ for (let step = 1; step <= maxSteps; step++) {
265
+ await markTurnInProgress(sessionId, turnId, step, recoveryEnabled)
266
+ await EventBus.emit({
267
+ type: EVENT_TYPES.TURN_STEP_START,
268
+ sessionId,
269
+ turnId,
270
+ payload: { step }
271
+ })
272
+
273
+ let tools = await ToolRegistry.list({ mode, config: configState.config, cwd })
274
+ if (agent?.tools) {
275
+ tools = tools.filter((t) => agent.tools.includes(t.name))
276
+ }
277
+ let history = await getConversationHistory(sessionId, Number(configState.config.session.max_history || 30))
278
+
279
+ const normalizedHistory = history.map(normalizeMessageForCache)
280
+ let contextTokens = estimateTokenCount(normalizedHistory)
281
+ let contextFromCache = false
282
+
283
+ // Use real token counting API when available (includes system + tools + messages)
284
+ const realCount = await countTokensProvider({
285
+ configState, providerType, model,
286
+ system: systemPrompt, messages: history, tools,
287
+ baseUrl, apiKeyEnv
288
+ })
289
+ if (realCount != null) {
290
+ contextTokens = realCount
291
+ } else if (contextCachePoint && isPrefixMessages(contextCachePoint.messages, normalizedHistory)) {
292
+ const delta = normalizedHistory.slice(contextCachePoint.messages.length)
293
+ contextTokens = contextCachePoint.tokens + estimateTokenCount(delta)
294
+ contextFromCache = true
295
+ } else if (contextCachePoint) {
296
+ contextCachePoint = null
297
+ }
298
+ const contextLimit = modelContextLimit(model, configState)
299
+ const contextRatio = contextLimit > 0 ? Math.min(1, contextTokens / contextLimit) : 0
300
+ lastContextMeter = {
301
+ tokens: contextTokens,
302
+ limit: contextLimit,
303
+ ratio: contextRatio,
304
+ percent: Math.round(contextRatio * 100),
305
+ fromCache: contextFromCache
306
+ }
307
+
308
+ if (cachePointsEnabled && (step === 1 || contextRatio >= thresholdRatio)) {
309
+ contextCachePoint = {
310
+ messages: normalizedHistory,
311
+ tokens: contextTokens
312
+ }
313
+ await appendPart(sessionId, {
314
+ type: "context-cache-point",
315
+ turnId,
316
+ step,
317
+ tokenEstimate: contextTokens,
318
+ contextLimit,
319
+ contextRatio
320
+ })
321
+ await saveCheckpoint(sessionId, {
322
+ kind: "context-cache-point",
323
+ iteration: step,
324
+ turnId,
325
+ step,
326
+ tokenEstimate: contextTokens,
327
+ contextLimit,
328
+ contextRatio,
329
+ messageCount: normalizedHistory.length,
330
+ fromCache: contextFromCache
331
+ })
332
+ }
333
+
334
+ if (!useNativeCompaction && shouldCompact({
335
+ messages: normalizedHistory,
336
+ model,
337
+ thresholdMessages,
338
+ thresholdRatio,
339
+ configState,
340
+ realTokenCount: realCount != null ? contextTokens : null
341
+ })) {
342
+ const compactResult = await compactSession({
343
+ sessionId, model, providerType, configState, baseUrl, apiKeyEnv
344
+ })
345
+ if (compactResult.compacted) {
346
+ await EventBus.emit({ type: EVENT_TYPES.SESSION_COMPACTED, sessionId, turnId, payload: compactResult })
347
+ history = await getConversationHistory(sessionId, Number(configState.config.session.max_history || 30))
348
+ const compactedMeter = contextUtilization(history.map(normalizeMessageForCache), model, configState)
349
+ lastContextMeter = { ...compactedMeter, fromCache: false }
350
+ contextCachePoint = {
351
+ messages: history.map(normalizeMessageForCache),
352
+ tokens: compactedMeter.tokens
353
+ }
354
+ }
355
+ }
356
+
357
+ const messages = await HookBus.messagesTransform([...history])
358
+
359
+ let response
360
+ try {
361
+ const chunks = requestProviderStream({
362
+ configState,
363
+ providerType,
364
+ model,
365
+ system: systemPrompt,
366
+ messages,
367
+ tools,
368
+ baseUrl,
369
+ apiKeyEnv,
370
+ signal,
371
+ compaction: useNativeCompaction ? { trigger: nativeCompactionTrigger } : null
372
+ })
373
+ const textParts = []
374
+ const streamToolCalls = []
375
+ let streamUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
376
+ let streamStopReason = "end_turn"
377
+ const mdEnabled = configState.config.ui?.markdown_render !== false
378
+ const streamRenderer = mdEnabled ? createStreamRenderer() : null
379
+ let inThinking = false
380
+
381
+ for await (const chunk of chunks) {
382
+ if (chunk.type === "thinking") {
383
+ const text = chunk.content || ""
384
+ if (!inThinking) {
385
+ sinkWrite(paint("●", "#666666") + " " + paint("Thinking", null, { dim: true }) + " " + paint("∨", null, { dim: true }) + "\n")
386
+ inThinking = true
387
+ await EventBus.emit({ type: EVENT_TYPES.STREAM_THINKING_START, sessionId, turnId, payload: { step } })
388
+ }
389
+ sinkWrite(paint(" " + text, null, { dim: true }))
390
+ } else if (chunk.type === "text") {
391
+ if (inThinking) {
392
+ sinkWrite("\n")
393
+ inThinking = false
394
+ }
395
+ if (textParts.length === 0) {
396
+ await EventBus.emit({ type: EVENT_TYPES.STREAM_TEXT_START, sessionId, turnId, payload: { step } })
397
+ }
398
+ if (streamRenderer) {
399
+ const rendered = streamRenderer.push(chunk.content)
400
+ if (rendered) sinkWrite(rendered)
401
+ } else {
402
+ sinkWrite(chunk.content)
403
+ }
404
+ textParts.push(chunk.content)
405
+ } else if (chunk.type === "tool_call") {
406
+ if (inThinking) {
407
+ sinkWrite("\n")
408
+ inThinking = false
409
+ }
410
+ streamToolCalls.push(chunk.call)
411
+ } else if (chunk.type === "usage") {
412
+ streamUsage = chunk.usage
413
+ } else if (chunk.type === "compaction") {
414
+ sinkWrite(paint("\n ↻ context compacted by provider\n", "cyan", { dim: true }))
415
+ } else if (chunk.type === "stop") {
416
+ streamStopReason = chunk.reason || "end_turn"
417
+ }
418
+ }
419
+ if (inThinking) {
420
+ sinkWrite("\n")
421
+ }
422
+ if (streamRenderer) {
423
+ const tail = streamRenderer.flush()
424
+ if (tail) sinkWrite(tail)
425
+ }
426
+ if (textParts.length) {
427
+ sinkWrite("\n")
428
+ emittedAnyText = true
429
+ }
430
+
431
+ response = {
432
+ text: textParts.join(""),
433
+ toolCalls: streamToolCalls,
434
+ usage: streamUsage,
435
+ stopReason: streamStopReason
436
+ }
437
+ } catch (error) {
438
+ if (error.needsCompaction) {
439
+ const compactResult = await compactSession({
440
+ sessionId, model, providerType, configState, baseUrl, apiKeyEnv
441
+ })
442
+ if (compactResult.compacted) {
443
+ await EventBus.emit({ type: EVENT_TYPES.SESSION_COMPACTED, sessionId, turnId, payload: compactResult })
444
+ continue
445
+ }
446
+ }
447
+ await appendPart(sessionId, {
448
+ type: "provider-error",
449
+ messageId: userMessage.id,
450
+ step,
451
+ turnId,
452
+ error: error.message,
453
+ errorClass: error.errorClass || "unknown",
454
+ needsCompaction: Boolean(error.needsCompaction)
455
+ })
456
+ throw error
457
+ }
458
+
459
+ addUsage(usage, response.usage || {})
460
+
461
+ // Update context meter with real API total input tokens
462
+ // Anthropic: input_tokens is only non-cached portion; total = input + cacheRead + cacheWrite
463
+ // OpenAI: prompt_tokens is already the total
464
+ const u = response.usage || {}
465
+ const totalInput = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0)
466
+ if (totalInput > 0) {
467
+ const contextLimit = modelContextLimit(model, configState)
468
+ const contextRatio = contextLimit > 0 ? Math.min(1, totalInput / contextLimit) : 0
469
+ lastContextMeter = {
470
+ tokens: totalInput,
471
+ limit: contextLimit,
472
+ ratio: contextRatio,
473
+ percent: Math.round(contextRatio * 100),
474
+ fromCache: false,
475
+ cacheRead: u.cacheRead || 0,
476
+ cacheWrite: u.cacheWrite || 0,
477
+ inputUncached: u.input || 0
478
+ }
479
+ }
480
+
481
+ // Emit cumulative usage so status bar can update in real-time
482
+ await EventBus.emit({
483
+ type: EVENT_TYPES.TURN_USAGE_UPDATE,
484
+ sessionId,
485
+ turnId,
486
+ payload: { usage: { ...usage }, step, model, context: lastContextMeter }
487
+ })
488
+
489
+ // --- Auto-continue on output truncation (max_tokens) ---
490
+ if (response.stopReason === "max_tokens" && continueCount < MAX_CONTINUES) {
491
+ continueCount++
492
+ sinkWrite(paint(`\n ↳ output truncated, auto-continuing (${continueCount}/${MAX_CONTINUES})...\n`, "yellow", { dim: true }))
493
+
494
+ // Drop any tool calls with parse errors (truncated JSON from cutoff)
495
+ const validToolCalls = (response.toolCalls || []).filter(tc => !tc.args?.__parse_error)
496
+
497
+ // Save partial output as assistant message
498
+ const partialContent = []
499
+ if (response.text) {
500
+ partialContent.push({ type: "text", text: response.text })
501
+ }
502
+ for (const call of validToolCalls) {
503
+ partialContent.push({ type: "tool_use", id: call.id, name: call.name, input: call.args || {} })
504
+ }
505
+ if (partialContent.length) {
506
+ await appendMessage(sessionId, "assistant", partialContent.length === 1 && partialContent[0].type === "text"
507
+ ? partialContent[0].text
508
+ : partialContent, {
509
+ mode, model, providerType, step, turnId, truncated: true
510
+ })
511
+ }
512
+
513
+ // If there were valid tool calls, execute them and add results before continuing
514
+ if (validToolCalls.length) {
515
+ const resultContent = []
516
+ for (const call of validToolCalls) {
517
+ resultContent.push({
518
+ type: "tool_result",
519
+ tool_use_id: call.id,
520
+ content: "[truncated response — tool call acknowledged but output was cut off]",
521
+ is_error: true
522
+ })
523
+ }
524
+ await appendMessage(sessionId, "user", resultContent, {
525
+ mode, model, providerType, step, turnId, synthetic: true
526
+ })
527
+ }
528
+
529
+ // Inject continue prompt (localized) — include info about what was truncated
530
+ const hadTruncatedToolCalls = (response.toolCalls || []).some(tc => tc.args?.__parse_error)
531
+ const truncatedToolNames = (response.toolCalls || []).filter(tc => tc.args?.__parse_error).map(tc => tc.name).join(", ")
532
+ const toolHint = hadTruncatedToolCalls
533
+ ? (language === "zh"
534
+ ? `\n被截断的工具调用: ${truncatedToolNames}。请完整重新发起这些工具调用。如果是创建大文件,使用 write(mode="append") 分段追加;如果是修改已有文件的局部内容,使用 patch 按行号范围替换。`
535
+ : `\nTruncated tool calls: ${truncatedToolNames}. Re-issue these tool calls completely. For large file creation, use write(mode="append") to append in chunks. For modifying sections of existing files, use patch to replace by line range.`)
536
+ : ""
537
+ const continuePrompt = language === "zh"
538
+ ? `[输出被截断 ${continueCount}/${MAX_CONTINUES}] 你的上一条回复在输出 token 上限处被截断。请从你停止的地方精确继续,不要重复已经写过的内容。如果你正在执行工具调用,请完整重新发起。${toolHint}`
539
+ : `[OUTPUT TRUNCATED ${continueCount}/${MAX_CONTINUES}] Your previous response was cut off at the output token limit. Continue EXACTLY from where you stopped. Do not repeat any content you already wrote. If you were in the middle of a tool call, re-issue it completely.${toolHint}`
540
+ await appendMessage(sessionId, "user", continuePrompt,
541
+ { mode, model, providerType, step, turnId, synthetic: true }
542
+ )
543
+
544
+ // Don't consume a step for auto-continue
545
+ step--
546
+ continue
547
+ }
548
+ // Reset continue count on successful non-truncated response
549
+ continueCount = 0
550
+
551
+ if (!response.toolCalls?.length) {
552
+ // Enhanced task completion verification
553
+ if (verifyCompletion && nudgeCount < 2) {
554
+ try {
555
+ const validator = await createValidator({ cwd, configState })
556
+ const validationResult = await validator.validate({
557
+ todoState: toolContext._todoState
558
+ })
559
+
560
+ if (!validationResult.passed) {
561
+ nudgeCount++
562
+ const validationPrompt = language === "zh"
563
+ ? `[任务验证失败] 您报告任务已完成,但以下验证失败:\n\n${validationResult.message}\n\n请修复问题后再报告完成。`
564
+ : `[TASK VERIFICATION FAILED] You indicated completion, but verification failed:\n\n${validationResult.message}\n\nPlease fix the issues before declaring completion.`
565
+
566
+ await appendMessage(sessionId, "user", validationPrompt,
567
+ { mode, model, providerType, step, turnId, synthetic: true }
568
+ )
569
+ continue
570
+ }
571
+ } catch (validationError) {
572
+ sinkWrite(paint(`\n ⚠ Task validation skipped: ${validationError.message}\n`, "yellow", { dim: true }))
573
+ }
574
+ }
575
+
576
+ finalReply = (response.text || "").trim() || "No content returned from provider."
577
+ const assistant = await appendMessage(sessionId, "assistant", finalReply, {
578
+ mode,
579
+ model,
580
+ providerType,
581
+ step,
582
+ turnId
583
+ })
584
+ await appendPart(sessionId, {
585
+ type: "assistant-response",
586
+ messageId: assistant.id,
587
+ step,
588
+ turnId,
589
+ hasText: Boolean(finalReply)
590
+ })
591
+ await markSessionStatus(sessionId, "active")
592
+ if (queue.length) {
593
+ await markRejectionsConsumed(
594
+ queue.map((entry) => entry.id),
595
+ sessionId,
596
+ cwd
597
+ )
598
+ }
599
+ await markTurnFinished(sessionId, recoveryEnabled)
600
+ await EventBus.emit({
601
+ type: EVENT_TYPES.TURN_FINISH,
602
+ sessionId,
603
+ turnId,
604
+ payload: { step, reply: finalReply }
605
+ })
606
+ return {
607
+ sessionId,
608
+ turnId,
609
+ reply: finalReply,
610
+ emittedText: emittedAnyText,
611
+ context: lastContextMeter,
612
+ usage,
613
+ toolEvents
614
+ }
615
+ }
616
+
617
+ // --- Execute tool calls (read-only in parallel, write tools serially) ---
618
+ async function executeOneCall(call) {
619
+ const runningPart = await appendPart(sessionId, {
620
+ type: "tool-call",
621
+ messageId: userMessage.id,
622
+ step,
623
+ turnId,
624
+ tool: call.name,
625
+ args: call.args,
626
+ status: "running",
627
+ output: ""
628
+ })
629
+
630
+ const pattern = toolPatternFromArgs(call.args)
631
+ const command = call.name === "bash" ? String(call.args?.command || "") : ""
632
+ const risk = ["bash", "write", "edit", "task"].includes(call.name) ? 9 : 1
633
+ let result
634
+ try {
635
+ const hookTransformed = await HookBus.toolBefore({ tool: call.name, args: call.args, sessionId, step })
636
+ if (hookTransformed?.args) call.args = hookTransformed.args
637
+
638
+ if (call.name === "question" && !allowQuestion) {
639
+ call.args = {
640
+ ...(call.args || {}),
641
+ _allowQuestion: false
642
+ }
643
+ }
644
+
645
+ await PermissionEngine.check({
646
+ config: configState.config,
647
+ sessionId,
648
+ tool: call.name,
649
+ mode,
650
+ pattern,
651
+ command,
652
+ risk,
653
+ reason: `tool call from model at step ${step}`
654
+ })
655
+
656
+ const tool = await ToolRegistry.get(call.name)
657
+ result = !tool
658
+ ? {
659
+ name: call.name,
660
+ status: "error",
661
+ output: `unknown tool: ${call.name}`,
662
+ error: `unknown tool: ${call.name}`
663
+ }
664
+ : await executeTool({
665
+ tool,
666
+ args: call.args,
667
+ sessionId,
668
+ turnId,
669
+ context: {
670
+ cwd,
671
+ mode,
672
+ delegateTask,
673
+ signal,
674
+ sessionId,
675
+ turnId,
676
+ config: configState.config,
677
+ ...toolContext
678
+ },
679
+ signal
680
+ })
681
+ } catch (error) {
682
+ result = {
683
+ name: call.name,
684
+ status: "error",
685
+ output: error.message,
686
+ error: error.message
687
+ }
688
+ }
689
+
690
+ const hookAfterResult = await HookBus.toolAfter({ tool: call.name, args: call.args, result, sessionId, step })
691
+ if (hookAfterResult?.result) result = hookAfterResult.result
692
+
693
+ // Plan approval interception: if the tool returned planApproval metadata,
694
+ // pause and ask the user to approve/reject the plan
695
+ if (result.metadata?.planApproval) {
696
+ const approval = await askPlanApproval({
697
+ plan: result.metadata.plan || "",
698
+ files: result.metadata.files || []
699
+ })
700
+ result = {
701
+ ...result,
702
+ output: approval.approved
703
+ ? "User APPROVED the plan. Proceed with implementation."
704
+ : `User REJECTED the plan. Feedback: ${approval.feedback || "no feedback provided"}`,
705
+ metadata: { ...result.metadata, planApprovalResult: approval }
706
+ }
707
+ }
708
+
709
+ await appendPart(sessionId, {
710
+ type: "tool-call",
711
+ messageId: userMessage.id,
712
+ step,
713
+ turnId,
714
+ runPartId: runningPart.id,
715
+ tool: call.name,
716
+ args: call.args,
717
+ status: result.status,
718
+ output: result.output
719
+ })
720
+
721
+ return { call, result }
722
+ }
723
+
724
+ // Split into read-only (parallelizable) and write (serial) groups
725
+ const readOnlyCalls = []
726
+ const writeCalls = []
727
+ for (const call of response.toolCalls) {
728
+ if (READ_ONLY_TOOLS.has(call.name)) {
729
+ readOnlyCalls.push(call)
730
+ } else {
731
+ writeCalls.push(call)
732
+ }
733
+ }
734
+
735
+ // Execute read-only tools in parallel
736
+ const callResults = new Map() // call.id → { call, result }
737
+ if (readOnlyCalls.length > 0) {
738
+ const settled = await Promise.allSettled(readOnlyCalls.map(executeOneCall))
739
+ for (let si = 0; si < settled.length; si++) {
740
+ const outcome = settled[si]
741
+ if (outcome.status === "fulfilled") {
742
+ callResults.set(outcome.value.call.id, outcome.value)
743
+ } else {
744
+ const failedCall = readOnlyCalls[si]
745
+ callResults.set(failedCall.id, {
746
+ call: failedCall,
747
+ result: {
748
+ name: failedCall.name,
749
+ status: "error",
750
+ output: `Tool execution failed: ${outcome.reason?.message || "unknown error"}`,
751
+ error: outcome.reason?.message || "unknown error"
752
+ }
753
+ })
754
+ }
755
+ }
756
+ }
757
+
758
+ // Execute write tools serially
759
+ for (const call of writeCalls) {
760
+ const outcome = await executeOneCall(call)
761
+ callResults.set(outcome.call.id, outcome)
762
+ }
763
+
764
+ // Collect results in original order
765
+ for (const call of response.toolCalls) {
766
+ const entry = callResults.get(call.id)
767
+ if (entry) {
768
+ toolEvents.push({
769
+ step,
770
+ name: entry.call.name,
771
+ args: entry.call.args,
772
+ ...entry.result
773
+ })
774
+ }
775
+ }
776
+
777
+ // --- Build native tool_use / tool_result messages ---
778
+ // Assistant message: text + tool_use blocks
779
+ const assistantContent = []
780
+ if (response.text) {
781
+ assistantContent.push({ type: "text", text: response.text })
782
+ }
783
+ for (const call of response.toolCalls) {
784
+ assistantContent.push({
785
+ type: "tool_use",
786
+ id: call.id,
787
+ name: call.name,
788
+ input: call.args || {}
789
+ })
790
+ }
791
+ await appendMessage(sessionId, "assistant", assistantContent, {
792
+ mode,
793
+ model,
794
+ providerType,
795
+ step,
796
+ turnId,
797
+ toolCallPhase: true
798
+ })
799
+
800
+ // User message: tool_result blocks (one per tool call, in order)
801
+ const resultContent = []
802
+ for (const call of response.toolCalls) {
803
+ const entry = callResults.get(call.id)
804
+ const output = entry?.result?.output || ""
805
+ const isError = entry?.result?.status === "error"
806
+ resultContent.push({
807
+ type: "tool_result",
808
+ tool_use_id: call.id,
809
+ content: output,
810
+ is_error: isError
811
+ })
812
+ }
813
+ await appendMessage(sessionId, "user", resultContent, {
814
+ mode,
815
+ model,
816
+ providerType,
817
+ step,
818
+ turnId,
819
+ synthetic: true
820
+ })
821
+
822
+ // --- Doom loop detection: 3x identical tool call → inject warning ---
823
+ for (const call of response.toolCalls) {
824
+ doomTracker.push(`${call.name}::${JSON.stringify(call.args || {})}`)
825
+ }
826
+ if (doomTracker.length > 6) doomTracker.splice(0, doomTracker.length - 6)
827
+ if (doomTracker.length >= 3) {
828
+ const last3 = doomTracker.slice(-3)
829
+ if (last3[0] === last3[1] && last3[1] === last3[2]) {
830
+ await appendMessage(sessionId, "user", "[DOOM LOOP DETECTED] You called the same tool with identical arguments 3 times consecutively. STOP repeating this approach — it will not work. Try a completely different strategy, re-read the relevant files, or ask the user for guidance.", {
831
+ mode, model, providerType, step, turnId, synthetic: true
832
+ })
833
+ doomTracker.length = 0
834
+ }
835
+ }
836
+
837
+ // --- Soft step warning: alert model when nearing the limit ---
838
+ if (step === maxSteps - 2) {
839
+ await appendMessage(sessionId, "user", `[STEP LIMIT WARNING] You have used ${step} of ${maxSteps} steps. You are running low — wrap up your current work, summarize progress, and list any remaining tasks.`, {
840
+ mode, model, providerType, step, turnId, synthetic: true
841
+ })
842
+ }
843
+
844
+ await EventBus.emit({
845
+ type: EVENT_TYPES.TURN_STEP_FINISH,
846
+ sessionId,
847
+ turnId,
848
+ payload: { step, toolCalls: response.toolCalls.length }
849
+ })
850
+ }
851
+
852
+ finalReply = "Reached max steps. Review tool outputs and continue in a new turn."
853
+ await appendMessage(sessionId, "assistant", finalReply, {
854
+ mode,
855
+ model,
856
+ providerType,
857
+ turnId,
858
+ maxSteps: true
859
+ })
860
+ await markTurnFinished(sessionId)
861
+ await EventBus.emit({
862
+ type: EVENT_TYPES.TURN_FINISH,
863
+ sessionId,
864
+ turnId,
865
+ payload: { maxSteps: true, reply: finalReply }
866
+ })
867
+ return {
868
+ sessionId,
869
+ turnId,
870
+ reply: finalReply,
871
+ emittedText: emittedAnyText,
872
+ context: lastContextMeter,
873
+ usage,
874
+ toolEvents
875
+ }
876
+ } catch (error) {
877
+ await markSessionStatus(sessionId, "error")
878
+ await markTurnFinished(sessionId, recoveryEnabled)
879
+ if (recoveryEnabled) {
880
+ await updateSession(sessionId, {
881
+ retryMeta: {
882
+ inProgress: false,
883
+ turnId,
884
+ failedAt: Date.now(),
885
+ error: error.message
886
+ }
887
+ })
888
+ }
889
+ await EventBus.emit({
890
+ type: EVENT_TYPES.TURN_ERROR,
891
+ sessionId,
892
+ turnId,
893
+ payload: { error: error.message }
894
+ })
895
+ return {
896
+ sessionId,
897
+ turnId,
898
+ reply: `provider error: ${error.message}`,
899
+ emittedText: emittedAnyText,
900
+ context: lastContextMeter,
901
+ usage,
902
+ toolEvents
903
+ }
904
+ }
905
+ }