@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.1

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 (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -1,390 +1,396 @@
1
- import { ProviderError } from "../core/errors.mjs"
2
- import { requestWithRetry } from "./retry-policy.mjs"
3
- import { parseSSE } from "./sse.mjs"
4
-
5
- function sleep(ms) {
6
- return new Promise((resolve) => setTimeout(resolve, ms))
7
- }
8
-
9
- function mapTools(tools) {
10
- if (!tools || !tools.length) return []
11
- const mapped = tools.map((tool) => ({
12
- name: tool.name,
13
- description: tool.description,
14
- input_schema: tool.inputSchema
15
- }))
16
- // Cache the tool definitions (they rarely change within a session)
17
- if (mapped.length > 0) {
18
- mapped[mapped.length - 1].cache_control = { type: "ephemeral" }
19
- }
20
- return mapped
21
- }
22
-
23
- function systemWithCacheControl(system) {
24
- if (!system) return undefined
25
-
26
- // Structured format from buildSystemPromptBlocks: { text, blocks }
27
- // Strategy: merge all stable content into ONE block with cache_control,
28
- // keeping dynamic content separate. Combined with the tool breakpoint in
29
- // mapTools, this gives us 2 breakpoints total — well within the 4-max limit
30
- // and ensures the cumulative prefix easily exceeds the minimum cacheable
31
- // threshold (4096 tokens for Opus, 1024 for Sonnet).
32
- if (system.blocks && Array.isArray(system.blocks)) {
33
- const stableParts = []
34
- const dynamicParts = []
35
- for (const block of system.blocks) {
36
- if (block.cacheable === false) {
37
- dynamicParts.push(block.text)
38
- } else {
39
- stableParts.push(block.text)
40
- }
41
- }
42
-
43
- const contentBlocks = []
44
- if (stableParts.length) {
45
- contentBlocks.push({
46
- type: "text",
47
- text: stableParts.join("\n\n"),
48
- cache_control: { type: "ephemeral" }
49
- })
50
- }
51
- if (dynamicParts.length) {
52
- contentBlocks.push({ type: "text", text: dynamicParts.join("\n\n") })
53
- }
54
- return contentBlocks.length ? contentBlocks : undefined
55
- }
56
-
57
- // Legacy: plain string
58
- if (typeof system === "string") {
59
- return [{ type: "text", text: system, cache_control: { type: "ephemeral" } }]
60
- }
61
- // Legacy: array of strings/blocks
62
- if (Array.isArray(system)) {
63
- const blocks = system.map((b) => (typeof b === "string" ? { type: "text", text: b } : { ...b }))
64
- if (blocks.length > 0) {
65
- blocks[blocks.length - 1].cache_control = { type: "ephemeral" }
66
- }
67
- return blocks
68
- }
69
- return system
70
- }
71
-
72
- function mapContentBlock(block) {
73
- if (block.type === "image" && block.data) {
74
- return {
75
- type: "image",
76
- source: {
77
- type: "base64",
78
- media_type: block.mediaType || "image/png",
79
- data: block.data
80
- }
81
- }
82
- }
83
- // Native Anthropic tool_use block — pass through
84
- if (block.type === "tool_use") {
85
- return { type: "tool_use", id: block.id, name: block.name, input: block.input || {} }
86
- }
87
- // Native Anthropic tool_result block — pass through
88
- if (block.type === "tool_result") {
89
- return {
90
- type: "tool_result",
91
- tool_use_id: block.tool_use_id,
92
- content: String(block.content || ""),
93
- ...(block.is_error ? { is_error: true } : {})
94
- }
95
- }
96
- return { type: "text", text: String(block.text || block.content || "") }
97
- }
98
-
99
- function mapMessages(messages) {
100
- const mapped = messages.map((message) => {
101
- const role = message.role === "assistant" ? "assistant" : "user"
102
- const content = message.content
103
- if (Array.isArray(content)) {
104
- return { role, content: content.map(mapContentBlock) }
105
- }
106
- return { role, content: String(content || "") }
107
- })
108
- // Add cache_control to last user message for multi-turn caching
109
- for (let i = mapped.length - 1; i >= 0; i--) {
110
- if (mapped[i].role === "user") {
111
- const c = mapped[i].content
112
- if (Array.isArray(c) && c.length) {
113
- c[c.length - 1].cache_control = { type: "ephemeral" }
114
- } else if (typeof c === "string") {
115
- mapped[i].content = [{ type: "text", text: c, cache_control: { type: "ephemeral" } }]
116
- }
117
- break
118
- }
119
- }
120
- return mapped
121
- }
122
-
123
- function parseContentBlocks(content) {
124
- const blocks = Array.isArray(content) ? content : []
125
- const text = blocks.filter((block) => block.type === "text").map((block) => block.text || "").join("\n")
126
- const toolCalls = blocks
127
- .filter((block) => block.type === "tool_use" && block.name)
128
- .map((block) => ({
129
- id: block.id || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
130
- name: block.name,
131
- args: block.input || {}
132
- }))
133
- return { text, toolCalls }
134
- }
135
-
136
- function timeoutSignal(ms, parentSignal = null) {
137
- const own = AbortSignal.timeout(ms)
138
- if (!parentSignal) return own
139
- return AbortSignal.any([parentSignal, own])
140
- }
141
-
142
- export async function requestAnthropic(input) {
143
- const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 120000, maxTokens = 16384, retry = {}, signal = null } = input
144
- if (!apiKey) {
145
- throw new ProviderError(`missing API key for anthropic provider (env: ${input.apiKeyEnv || "unknown"})`, {
146
- provider: "anthropic"
147
- })
148
- }
149
-
150
- const endpoint = `${baseUrl.replace(/\/$/, "")}/messages`
151
- const mappedTools = mapTools(tools)
152
- const payload = {
153
- model,
154
- max_tokens: maxTokens,
155
- metadata: { user_id: "kkcode" },
156
- system: systemWithCacheControl(system),
157
- messages: mapMessages(messages),
158
- tools: mappedTools.length ? mappedTools : undefined
159
- }
160
- if (input.thinking?.type) {
161
- payload.thinking = { type: input.thinking.type, budget_tokens: input.thinking.budget_tokens || 10000 }
162
- }
163
-
164
- return requestWithRetry({
165
- attempts: Number(retry.attempts ?? 3),
166
- baseDelayMs: Number(retry.baseDelayMs ?? 800),
167
- signal,
168
- execute: async () => {
169
- const response = await fetch(endpoint, {
170
- method: "POST",
171
- headers: {
172
- "content-type": "application/json",
173
- "x-api-key": apiKey,
174
- "anthropic-version": "2023-06-01",
175
- "anthropic-beta": "prompt-caching-2024-07-31"
176
- },
177
- body: JSON.stringify(payload),
178
- signal: timeoutSignal(timeoutMs, signal)
179
- })
180
- if (!response.ok) {
181
- const text = await response.text().catch(() => "")
182
- const error = new ProviderError(`anthropic request failed: ${response.status} ${text}`, {
183
- provider: "anthropic",
184
- model,
185
- endpoint
186
- })
187
- error.httpStatus = response.status
188
- throw error
189
- }
190
- const json = await response.json()
191
- const parsed = parseContentBlocks(json?.content)
192
- const usage = {
193
- input: json?.usage?.input_tokens ?? 0,
194
- output: json?.usage?.output_tokens ?? 0,
195
- cacheRead: json?.usage?.cache_read_input_tokens ?? 0,
196
- cacheWrite: json?.usage?.cache_creation_input_tokens ?? 0
197
- }
198
- return { text: parsed.text, usage, toolCalls: parsed.toolCalls }
199
- }
200
- })
201
- }
202
-
203
- export async function countTokensAnthropic(input) {
204
- const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 10000 } = input
205
- if (!apiKey) return null
206
- const endpoint = `${baseUrl.replace(/\/$/, "")}/messages/count_tokens`
207
- const mappedTools = mapTools(tools)
208
- const payload = {
209
- model,
210
- system: systemWithCacheControl(system),
211
- messages: mapMessages(messages),
212
- tools: mappedTools.length ? mappedTools : undefined
213
- }
214
- try {
215
- const res = await fetch(endpoint, {
216
- method: "POST",
217
- headers: {
218
- "content-type": "application/json",
219
- "x-api-key": apiKey,
220
- "anthropic-version": "2023-06-01"
221
- },
222
- body: JSON.stringify(payload),
223
- signal: AbortSignal.timeout(timeoutMs)
224
- })
225
- if (!res.ok) return null
226
- const json = await res.json()
227
- return json?.input_tokens ?? null
228
- } catch {
229
- return null
230
- }
231
- }
232
-
233
- export async function* requestAnthropicStream(input) {
234
- const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 120000, streamIdleTimeoutMs = 120000, maxTokens = 16384, retry = {}, signal = null, compaction = null } = input
235
- if (!apiKey) {
236
- throw new ProviderError(`missing API key for anthropic provider (env: ${input.apiKeyEnv || "unknown"})`, {
237
- provider: "anthropic"
238
- })
239
- }
240
-
241
- const endpoint = `${baseUrl.replace(/\/$/, "")}/messages`
242
- const mappedTools = mapTools(tools)
243
- const payload = {
244
- model,
245
- max_tokens: maxTokens,
246
- metadata: { user_id: "kkcode" },
247
- system: systemWithCacheControl(system),
248
- messages: mapMessages(messages),
249
- tools: mappedTools.length ? mappedTools : undefined,
250
- stream: true,
251
- ...(compaction ? { context_management: { edits: [{ type: "compact_20260112", trigger: { tokens: compaction.trigger || 150000 } }] } } : {})
252
- }
253
- if (input.thinking?.type) {
254
- payload.thinking = { type: input.thinking.type, budget_tokens: input.thinking.budget_tokens || 10000 }
255
- }
256
- const attempts = Number(retry.attempts ?? 3)
257
- const baseDelayMs = Number(retry.baseDelayMs ?? 800)
258
-
259
- let response
260
- for (let attempt = 1; attempt <= attempts; attempt++) {
261
- try {
262
- // Use a connection-only timeout for the initial fetch.
263
- // Once headers arrive, clear it — the SSE idle timeout handles the streaming phase.
264
- const connController = new AbortController()
265
- const connTimer = setTimeout(() => connController.abort(), timeoutMs)
266
- const fetchSignal = signal
267
- ? AbortSignal.any([signal, connController.signal])
268
- : connController.signal
269
-
270
- response = await fetch(endpoint, {
271
- method: "POST",
272
- headers: {
273
- "content-type": "application/json",
274
- "x-api-key": apiKey,
275
- "anthropic-version": "2023-06-01",
276
- "anthropic-beta": compaction ? "prompt-caching-2024-07-31,compact-2026-01-12" : "prompt-caching-2024-07-31"
277
- },
278
- body: JSON.stringify(payload),
279
- signal: fetchSignal
280
- })
281
- clearTimeout(connTimer)
282
-
283
- if (!response.ok) {
284
- const text = await response.text().catch(() => "")
285
- const error = new ProviderError(`anthropic stream failed: ${response.status} ${text}`, {
286
- provider: "anthropic", model, endpoint
287
- })
288
- error.httpStatus = response.status
289
- throw error
290
- }
291
- break
292
- } catch (err) {
293
- clearTimeout(connTimer)
294
- if (signal?.aborted) throw err
295
- const isNetwork = err?.code === "ETIMEDOUT" || err?.code === "ECONNRESET" || err?.name === "AbortError"
296
- if (!isNetwork || attempt >= attempts) throw err
297
- await sleep(baseDelayMs * Math.pow(2, attempt - 1))
298
- }
299
- }
300
-
301
- let currentBlock = null
302
- let inputUsage = { input: 0, cacheRead: 0, cacheWrite: 0 }
303
- let outputTokens = 0
304
- let stopReason = null
305
-
306
- for await (const { event, data } of parseSSE(response.body, signal, { idleTimeoutMs: streamIdleTimeoutMs })) {
307
- let parsed
308
- try { parsed = JSON.parse(data) } catch { continue }
309
-
310
- if (event === "message_start") {
311
- const u = parsed.message?.usage
312
- inputUsage.input = u?.input_tokens ?? 0
313
- inputUsage.cacheRead = u?.cache_read_input_tokens ?? 0
314
- inputUsage.cacheWrite = u?.cache_creation_input_tokens ?? 0
315
- }
316
-
317
- if (event === "content_block_start") {
318
- const block = parsed.content_block
319
- currentBlock = {
320
- type: block?.type,
321
- id: block?.id || null,
322
- name: block?.name || null,
323
- jsonParts: []
324
- }
325
- }
326
-
327
- if (event === "content_block_delta") {
328
- if (parsed.delta?.type === "text_delta") {
329
- const text = parsed.delta.text || ""
330
- if (text) yield { type: "text", content: text }
331
- }
332
- if (parsed.delta?.type === "thinking_delta" && currentBlock?.type !== "redacted_thinking") {
333
- const thinking = parsed.delta.thinking || ""
334
- if (thinking) yield { type: "thinking", content: thinking }
335
- }
336
- if (parsed.delta?.type === "input_json_delta") {
337
- if (currentBlock) currentBlock.jsonParts.push(parsed.delta.partial_json || "")
338
- }
339
- if (parsed.delta?.type === "compaction_delta") {
340
- if (currentBlock) currentBlock.compactionContent = parsed.delta.content || ""
341
- }
342
- }
343
-
344
- if (event === "content_block_stop" && currentBlock) {
345
- if (currentBlock.type === "tool_use") {
346
- const raw = currentBlock.jsonParts.join("") || "{}"
347
- let args = {}
348
- try {
349
- args = JSON.parse(raw)
350
- } catch (parseErr) {
351
- console.error(`[anthropic] tool_call JSON parse failed for "${currentBlock.name}": ${parseErr.message} (${raw.length} chars, first 200: ${raw.slice(0, 200)})`)
352
- args = { __parse_error: true, __raw_length: raw.length, __error: parseErr.message }
353
- }
354
- yield {
355
- type: "tool_call",
356
- call: {
357
- id: currentBlock.id || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
358
- name: currentBlock.name,
359
- args
360
- }
361
- }
362
- }
363
- if (currentBlock.type === "compaction") {
364
- yield { type: "compaction", content: currentBlock.compactionContent || "" }
365
- }
366
- currentBlock = null
367
- }
368
-
369
- if (event === "message_delta") {
370
- outputTokens = parsed.usage?.output_tokens ?? outputTokens
371
- if (parsed.delta?.stop_reason) {
372
- stopReason = parsed.delta.stop_reason
373
- }
374
- }
375
-
376
- if (event === "message_stop") {
377
- yield {
378
- type: "usage",
379
- usage: {
380
- input: inputUsage.input,
381
- output: outputTokens,
382
- cacheRead: inputUsage.cacheRead,
383
- cacheWrite: inputUsage.cacheWrite
384
- }
385
- }
386
- // Normalize: "end_turn" → "end_turn", "max_tokens" → "max_tokens", "tool_use" → "tool_use"
387
- yield { type: "stop", reason: stopReason || "end_turn" }
388
- }
389
- }
390
- }
1
+ import { ProviderError } from "../core/errors.mjs"
2
+ import { requestWithRetry } from "./retry-policy.mjs"
3
+ import { parseSSE } from "./sse.mjs"
4
+
5
+ function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms))
7
+ }
8
+
9
+ function mapTools(tools) {
10
+ if (!tools || !tools.length) return []
11
+ const mapped = tools.map((tool) => ({
12
+ name: tool.name,
13
+ description: tool.description,
14
+ input_schema: tool.inputSchema
15
+ }))
16
+ // Cache the tool definitions (they rarely change within a session)
17
+ if (mapped.length > 0) {
18
+ mapped[mapped.length - 1].cache_control = { type: "ephemeral" }
19
+ }
20
+ return mapped
21
+ }
22
+
23
+ function systemWithCacheControl(system) {
24
+ if (!system) return undefined
25
+
26
+ // Structured format from buildSystemPromptBlocks: { text, blocks }
27
+ // Strategy: merge all stable content into ONE block with cache_control,
28
+ // keeping dynamic content separate. Combined with the tool breakpoint in
29
+ // mapTools, this gives us 2 breakpoints total — well within the 4-max limit
30
+ // and ensures the cumulative prefix easily exceeds the minimum cacheable
31
+ // threshold (4096 tokens for Opus, 1024 for Sonnet).
32
+ if (system.blocks && Array.isArray(system.blocks)) {
33
+ const stableParts = []
34
+ const dynamicParts = []
35
+ for (const block of system.blocks) {
36
+ if (block.cacheable === false) {
37
+ dynamicParts.push(block.text)
38
+ } else {
39
+ stableParts.push(block.text)
40
+ }
41
+ }
42
+
43
+ const contentBlocks = []
44
+ if (stableParts.length) {
45
+ contentBlocks.push({
46
+ type: "text",
47
+ text: stableParts.join("\n\n"),
48
+ cache_control: { type: "ephemeral" }
49
+ })
50
+ }
51
+ if (dynamicParts.length) {
52
+ contentBlocks.push({ type: "text", text: dynamicParts.join("\n\n") })
53
+ }
54
+ return contentBlocks.length ? contentBlocks : undefined
55
+ }
56
+
57
+ // Legacy: plain string
58
+ if (typeof system === "string") {
59
+ return [{ type: "text", text: system, cache_control: { type: "ephemeral" } }]
60
+ }
61
+ // Legacy: array of strings/blocks
62
+ if (Array.isArray(system)) {
63
+ const blocks = system.map((b) => (typeof b === "string" ? { type: "text", text: b } : { ...b }))
64
+ if (blocks.length > 0) {
65
+ blocks[blocks.length - 1].cache_control = { type: "ephemeral" }
66
+ }
67
+ return blocks
68
+ }
69
+ return system
70
+ }
71
+
72
+ function mapContentBlock(block) {
73
+ if (block.type === "image" && block.data) {
74
+ return {
75
+ type: "image",
76
+ source: {
77
+ type: "base64",
78
+ media_type: block.mediaType || "image/png",
79
+ data: block.data
80
+ }
81
+ }
82
+ }
83
+ // Native Anthropic tool_use block — pass through
84
+ if (block.type === "tool_use") {
85
+ return { type: "tool_use", id: block.id, name: block.name, input: block.input || {} }
86
+ }
87
+ // Native Anthropic tool_result block — pass through
88
+ if (block.type === "tool_result") {
89
+ return {
90
+ type: "tool_result",
91
+ tool_use_id: block.tool_use_id,
92
+ content: String(block.content || ""),
93
+ ...(block.is_error ? { is_error: true } : {})
94
+ }
95
+ }
96
+ return { type: "text", text: String(block.text || block.content || "") }
97
+ }
98
+
99
+ function mapMessages(messages) {
100
+ const mapped = messages.map((message) => {
101
+ const role = message.role === "assistant" ? "assistant" : "user"
102
+ const content = message.content
103
+ if (Array.isArray(content)) {
104
+ return { role, content: content.map(mapContentBlock) }
105
+ }
106
+ return { role, content: String(content || "") }
107
+ })
108
+ // Add cache_control to last user message for multi-turn caching
109
+ for (let i = mapped.length - 1; i >= 0; i--) {
110
+ if (mapped[i].role === "user") {
111
+ const c = mapped[i].content
112
+ if (Array.isArray(c) && c.length) {
113
+ c[c.length - 1].cache_control = { type: "ephemeral" }
114
+ } else if (typeof c === "string") {
115
+ mapped[i].content = [{ type: "text", text: c, cache_control: { type: "ephemeral" } }]
116
+ }
117
+ break
118
+ }
119
+ }
120
+ return mapped
121
+ }
122
+
123
+ function parseContentBlocks(content) {
124
+ const blocks = Array.isArray(content) ? content : []
125
+ const text = blocks.filter((block) => block.type === "text").map((block) => block.text || "").join("\n")
126
+ const toolCalls = blocks
127
+ .filter((block) => block.type === "tool_use" && block.name)
128
+ .map((block) => ({
129
+ id: block.id || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
130
+ name: block.name,
131
+ args: block.input || {}
132
+ }))
133
+ return { text, toolCalls }
134
+ }
135
+
136
+ function timeoutSignal(ms, parentSignal = null) {
137
+ const own = AbortSignal.timeout(ms)
138
+ if (!parentSignal) return own
139
+ return AbortSignal.any([parentSignal, own])
140
+ }
141
+
142
+ export async function requestAnthropic(input) {
143
+ const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 120000, maxTokens = 16384, retry = {}, signal = null } = input
144
+ if (!apiKey) {
145
+ throw new ProviderError(`missing API key for anthropic provider (env: ${input.apiKeyEnv || "unknown"})`, {
146
+ provider: "anthropic"
147
+ })
148
+ }
149
+
150
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/messages`
151
+ const mappedTools = mapTools(tools)
152
+ const payload = {
153
+ model,
154
+ max_tokens: maxTokens,
155
+ metadata: { user_id: "kkcode" },
156
+ system: systemWithCacheControl(system),
157
+ messages: mapMessages(messages),
158
+ tools: mappedTools.length ? mappedTools : undefined
159
+ }
160
+ if (input.thinking?.type) {
161
+ payload.thinking = { type: input.thinking.type, budget_tokens: input.thinking.budget_tokens || 10000 }
162
+ }
163
+
164
+ return requestWithRetry({
165
+ attempts: Number(retry.attempts ?? 3),
166
+ baseDelayMs: Number(retry.baseDelayMs ?? 800),
167
+ signal,
168
+ execute: async () => {
169
+ const response = await fetch(endpoint, {
170
+ method: "POST",
171
+ headers: {
172
+ "content-type": "application/json",
173
+ "x-api-key": apiKey,
174
+ "anthropic-version": "2023-06-01",
175
+ "anthropic-beta": "prompt-caching-2024-07-31"
176
+ },
177
+ body: JSON.stringify(payload),
178
+ signal: timeoutSignal(timeoutMs, signal)
179
+ })
180
+ if (!response.ok) {
181
+ const text = await response.text().catch(() => "")
182
+ const error = new ProviderError(`anthropic request failed: ${response.status} ${text}`, {
183
+ provider: "anthropic",
184
+ model,
185
+ endpoint
186
+ })
187
+ error.httpStatus = response.status
188
+ throw error
189
+ }
190
+ let json
191
+ try {
192
+ json = await response.json()
193
+ } catch (parseErr) {
194
+ throw new ProviderError(`anthropic response JSON parse failed: ${parseErr.message}`, { provider: "anthropic", model, endpoint })
195
+ }
196
+ const parsed = parseContentBlocks(json?.content)
197
+ const usage = {
198
+ input: json?.usage?.input_tokens ?? 0,
199
+ output: json?.usage?.output_tokens ?? 0,
200
+ cacheRead: json?.usage?.cache_read_input_tokens ?? 0,
201
+ cacheWrite: json?.usage?.cache_creation_input_tokens ?? 0
202
+ }
203
+ return { text: parsed.text, usage, toolCalls: parsed.toolCalls }
204
+ }
205
+ })
206
+ }
207
+
208
+ export async function countTokensAnthropic(input) {
209
+ const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 10000 } = input
210
+ if (!apiKey) return null
211
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/messages/count_tokens`
212
+ const mappedTools = mapTools(tools)
213
+ const payload = {
214
+ model,
215
+ system: systemWithCacheControl(system),
216
+ messages: mapMessages(messages),
217
+ tools: mappedTools.length ? mappedTools : undefined
218
+ }
219
+ try {
220
+ const res = await fetch(endpoint, {
221
+ method: "POST",
222
+ headers: {
223
+ "content-type": "application/json",
224
+ "x-api-key": apiKey,
225
+ "anthropic-version": "2023-06-01"
226
+ },
227
+ body: JSON.stringify(payload),
228
+ signal: AbortSignal.timeout(timeoutMs)
229
+ })
230
+ if (!res.ok) return null
231
+ const json = await res.json()
232
+ return json?.input_tokens ?? null
233
+ } catch {
234
+ return null
235
+ }
236
+ }
237
+
238
+ export async function* requestAnthropicStream(input) {
239
+ const { apiKey, baseUrl, model, system, messages, tools, timeoutMs = 120000, streamIdleTimeoutMs = 120000, maxTokens = 16384, retry = {}, signal = null, compaction = null } = input
240
+ if (!apiKey) {
241
+ throw new ProviderError(`missing API key for anthropic provider (env: ${input.apiKeyEnv || "unknown"})`, {
242
+ provider: "anthropic"
243
+ })
244
+ }
245
+
246
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/messages`
247
+ const mappedTools = mapTools(tools)
248
+ const payload = {
249
+ model,
250
+ max_tokens: maxTokens,
251
+ metadata: { user_id: "kkcode" },
252
+ system: systemWithCacheControl(system),
253
+ messages: mapMessages(messages),
254
+ tools: mappedTools.length ? mappedTools : undefined,
255
+ stream: true,
256
+ ...(compaction ? { context_management: { edits: [{ type: "compact_20260112", trigger: { tokens: compaction.trigger || 150000 } }] } } : {})
257
+ }
258
+ if (input.thinking?.type) {
259
+ payload.thinking = { type: input.thinking.type, budget_tokens: input.thinking.budget_tokens || 10000 }
260
+ }
261
+ const attempts = Number(retry.attempts ?? 3)
262
+ const baseDelayMs = Number(retry.baseDelayMs ?? 800)
263
+
264
+ let response
265
+ for (let attempt = 1; attempt <= attempts; attempt++) {
266
+ let connTimer = null
267
+ const connController = new AbortController()
268
+ try {
269
+ // Use a connection-only timeout for the initial fetch.
270
+ // Once headers arrive, clear it — the SSE idle timeout handles the streaming phase.
271
+ connTimer = setTimeout(() => connController.abort(), timeoutMs)
272
+ const fetchSignal = signal
273
+ ? AbortSignal.any([signal, connController.signal])
274
+ : connController.signal
275
+
276
+ response = await fetch(endpoint, {
277
+ method: "POST",
278
+ headers: {
279
+ "content-type": "application/json",
280
+ "x-api-key": apiKey,
281
+ "anthropic-version": "2023-06-01",
282
+ "anthropic-beta": compaction ? "prompt-caching-2024-07-31,compact-2026-01-12" : "prompt-caching-2024-07-31"
283
+ },
284
+ body: JSON.stringify(payload),
285
+ signal: fetchSignal
286
+ })
287
+ clearTimeout(connTimer)
288
+
289
+ if (!response.ok) {
290
+ const text = await response.text().catch(() => "")
291
+ const error = new ProviderError(`anthropic stream failed: ${response.status} ${text}`, {
292
+ provider: "anthropic", model, endpoint
293
+ })
294
+ error.httpStatus = response.status
295
+ throw error
296
+ }
297
+ break
298
+ } catch (err) {
299
+ clearTimeout(connTimer)
300
+ if (signal?.aborted) throw err
301
+ const isNetwork = err?.code === "ETIMEDOUT" || err?.code === "ECONNRESET" || err?.name === "AbortError"
302
+ if (!isNetwork || attempt >= attempts) throw err
303
+ await sleep(baseDelayMs * Math.pow(2, attempt - 1))
304
+ }
305
+ }
306
+
307
+ let currentBlock = null
308
+ let inputUsage = { input: 0, cacheRead: 0, cacheWrite: 0 }
309
+ let outputTokens = 0
310
+ let stopReason = null
311
+
312
+ for await (const { event, data } of parseSSE(response.body, signal, { idleTimeoutMs: streamIdleTimeoutMs })) {
313
+ let parsed
314
+ try { parsed = JSON.parse(data) } catch { continue }
315
+
316
+ if (event === "message_start") {
317
+ const u = parsed.message?.usage
318
+ inputUsage.input = u?.input_tokens ?? 0
319
+ inputUsage.cacheRead = u?.cache_read_input_tokens ?? 0
320
+ inputUsage.cacheWrite = u?.cache_creation_input_tokens ?? 0
321
+ }
322
+
323
+ if (event === "content_block_start") {
324
+ const block = parsed.content_block
325
+ currentBlock = {
326
+ type: block?.type,
327
+ id: block?.id || null,
328
+ name: block?.name || null,
329
+ jsonParts: []
330
+ }
331
+ }
332
+
333
+ if (event === "content_block_delta") {
334
+ if (parsed.delta?.type === "text_delta") {
335
+ const text = parsed.delta.text || ""
336
+ if (text) yield { type: "text", content: text }
337
+ }
338
+ if (parsed.delta?.type === "thinking_delta" && currentBlock?.type !== "redacted_thinking") {
339
+ const thinking = parsed.delta.thinking || ""
340
+ if (thinking) yield { type: "thinking", content: thinking }
341
+ }
342
+ if (parsed.delta?.type === "input_json_delta") {
343
+ if (currentBlock) currentBlock.jsonParts.push(parsed.delta.partial_json || "")
344
+ }
345
+ if (parsed.delta?.type === "compaction_delta") {
346
+ if (currentBlock) currentBlock.compactionContent = parsed.delta.content || ""
347
+ }
348
+ }
349
+
350
+ if (event === "content_block_stop" && currentBlock) {
351
+ if (currentBlock.type === "tool_use") {
352
+ const raw = currentBlock.jsonParts.join("") || "{}"
353
+ let args = {}
354
+ try {
355
+ args = JSON.parse(raw)
356
+ } catch (parseErr) {
357
+ console.error(`[anthropic] tool_call JSON parse failed for "${currentBlock.name}": ${parseErr.message} (${raw.length} chars, first 200: ${raw.slice(0, 200)})`)
358
+ args = { __parse_error: true, __raw_length: raw.length, __error: parseErr.message }
359
+ }
360
+ yield {
361
+ type: "tool_call",
362
+ call: {
363
+ id: currentBlock.id || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
364
+ name: currentBlock.name,
365
+ args
366
+ }
367
+ }
368
+ }
369
+ if (currentBlock.type === "compaction") {
370
+ yield { type: "compaction", content: currentBlock.compactionContent || "" }
371
+ }
372
+ currentBlock = null
373
+ }
374
+
375
+ if (event === "message_delta") {
376
+ outputTokens = parsed.usage?.output_tokens ?? outputTokens
377
+ if (parsed.delta?.stop_reason) {
378
+ stopReason = parsed.delta.stop_reason
379
+ }
380
+ }
381
+
382
+ if (event === "message_stop") {
383
+ yield {
384
+ type: "usage",
385
+ usage: {
386
+ input: inputUsage.input,
387
+ output: outputTokens,
388
+ cacheRead: inputUsage.cacheRead,
389
+ cacheWrite: inputUsage.cacheWrite
390
+ }
391
+ }
392
+ // Normalize: "end_turn" → "end_turn", "max_tokens" → "max_tokens", "tool_use" → "tool_use"
393
+ yield { type: "stop", reason: stopReason || "end_turn" }
394
+ }
395
+ }
396
+ }