lonny-agent 0.2.4 → 0.2.6

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 (102) hide show
  1. package/README.md +326 -326
  2. package/dist/agent/llm.d.ts +4 -0
  3. package/dist/agent/llm.d.ts.map +1 -1
  4. package/dist/agent/prompt-builder.d.ts.map +1 -1
  5. package/dist/agent/prompt-builder.js +13 -9
  6. package/dist/agent/prompt-builder.js.map +1 -1
  7. package/dist/agent/providers/google.d.ts.map +1 -1
  8. package/dist/agent/providers/google.js.map +1 -1
  9. package/dist/agent/providers/openai.d.ts.map +1 -1
  10. package/dist/agent/providers/openai.js +43 -3
  11. package/dist/agent/providers/openai.js.map +1 -1
  12. package/dist/agent/session.d.ts +4 -0
  13. package/dist/agent/session.d.ts.map +1 -1
  14. package/dist/agent/session.js +45 -7
  15. package/dist/agent/session.js.map +1 -1
  16. package/dist/config/index.d.ts.map +1 -1
  17. package/dist/config/index.js +7 -1
  18. package/dist/config/index.js.map +1 -1
  19. package/dist/tools/__tests__/bash.test.js +1 -1
  20. package/dist/tools/__tests__/bash.test.js.map +1 -1
  21. package/dist/tools/__tests__/edit.test.js +875 -371
  22. package/dist/tools/__tests__/edit.test.js.map +1 -1
  23. package/dist/tools/__tests__/grep.test.js +32 -0
  24. package/dist/tools/__tests__/grep.test.js.map +1 -1
  25. package/dist/tools/__tests__/sed.test.d.ts +2 -0
  26. package/dist/tools/__tests__/sed.test.d.ts.map +1 -0
  27. package/dist/tools/__tests__/sed.test.js +228 -0
  28. package/dist/tools/__tests__/sed.test.js.map +1 -0
  29. package/dist/tools/bash.d.ts.map +1 -1
  30. package/dist/tools/bash.js +83 -10
  31. package/dist/tools/bash.js.map +1 -1
  32. package/dist/tools/edit.d.ts +50 -9
  33. package/dist/tools/edit.d.ts.map +1 -1
  34. package/dist/tools/edit.js +328 -158
  35. package/dist/tools/edit.js.map +1 -1
  36. package/dist/tools/grep.d.ts.map +1 -1
  37. package/dist/tools/grep.js +54 -30
  38. package/dist/tools/grep.js.map +1 -1
  39. package/dist/tools/registry.d.ts +0 -6
  40. package/dist/tools/registry.d.ts.map +1 -1
  41. package/dist/tools/registry.js +2 -29
  42. package/dist/tools/registry.js.map +1 -1
  43. package/dist/tools/sed.d.ts +4 -0
  44. package/dist/tools/sed.d.ts.map +1 -0
  45. package/dist/tools/sed.js +121 -0
  46. package/dist/tools/sed.js.map +1 -0
  47. package/dist/web/index.d.ts.map +1 -1
  48. package/dist/web/index.js +26 -3
  49. package/dist/web/index.js.map +1 -1
  50. package/dist/web/public/app.js +5 -855
  51. package/dist/web/public/confirm.js +70 -0
  52. package/dist/web/public/index.html +121 -120
  53. package/dist/web/public/input.js +107 -0
  54. package/dist/web/public/messages.js +395 -0
  55. package/dist/web/public/sidebar.js +82 -0
  56. package/dist/web/public/state.js +72 -0
  57. package/dist/web/public/style.css +996 -949
  58. package/dist/web/public/utils.js +37 -0
  59. package/dist/web/public/websocket.js +267 -0
  60. package/dist/web/public/ws.js +19 -0
  61. package/dist/web/session-bridge.d.ts.map +1 -1
  62. package/dist/web/session-bridge.js +3 -7
  63. package/dist/web/session-bridge.js.map +1 -1
  64. package/package.json +55 -54
  65. package/scripts/copy-native.mjs +24 -0
  66. package/scripts/copy-web.mjs +15 -0
  67. package/src/agent/index.ts +25 -25
  68. package/src/agent/llm.ts +4 -0
  69. package/src/agent/prompt-builder.ts +13 -9
  70. package/src/agent/providers/anthropic.ts +179 -179
  71. package/src/agent/providers/google.ts +210 -211
  72. package/src/agent/providers/ollama.ts +186 -186
  73. package/src/agent/providers/openai.ts +61 -5
  74. package/src/agent/session.ts +59 -3
  75. package/src/config/index.ts +342 -335
  76. package/src/tools/__tests__/bash.test.ts +1 -1
  77. package/src/tools/__tests__/edit.test.ts +981 -385
  78. package/src/tools/__tests__/grep.test.ts +35 -0
  79. package/src/tools/bash.ts +97 -11
  80. package/src/tools/edit.ts +362 -169
  81. package/src/tools/git.ts +76 -76
  82. package/src/tools/grep.ts +57 -30
  83. package/src/tools/registry.ts +2 -31
  84. package/src/tui/index.ts +786 -786
  85. package/src/web/index.ts +25 -3
  86. package/src/web/public/app.js +5 -855
  87. package/src/web/public/confirm.js +70 -0
  88. package/src/web/public/index.html +121 -120
  89. package/src/web/public/input.js +107 -0
  90. package/src/web/public/messages.js +395 -0
  91. package/src/web/public/sidebar.js +82 -0
  92. package/src/web/public/state.js +72 -0
  93. package/src/web/public/style.css +996 -949
  94. package/src/web/public/utils.js +37 -0
  95. package/src/web/public/websocket.js +267 -0
  96. package/src/web/public/ws.js +19 -0
  97. package/src/web/session-bridge.ts +193 -194
  98. package/.claude/settings.local.json +0 -14
  99. package/.kilo/plans/1780064105789-mighty-pixel.md +0 -171
  100. package/.kilo/plans/1780293725888-quick-wizard.md +0 -62
  101. package/.lonny/plan-web-cwd-status.md +0 -38
  102. package/src/tools/exec.ts +0 -348
@@ -1,186 +1,186 @@
1
- import { ToolCall, type ToolDefinition } from '../../tools/types.js'
2
- import type { LLMChunk, LLMMessage, LLMProvider } from '../llm.js'
3
-
4
- interface OllamaTool {
5
- type: 'function'
6
- function: {
7
- name: string
8
- description: string
9
- parameters: Record<string, unknown>
10
- }
11
- }
12
-
13
- interface OllamaMessage {
14
- role: 'system' | 'user' | 'assistant' | 'tool'
15
- content: string
16
- tool_calls?: {
17
- function: {
18
- name: string
19
- arguments: string
20
- }
21
- }[]
22
- }
23
-
24
- interface OllamaResponse {
25
- model: string
26
- created_at: string
27
- message: OllamaMessage
28
- done: boolean
29
- done_reason?: string
30
- prompt_eval_count?: number
31
- eval_count?: number
32
- }
33
-
34
- export class OllamaProvider implements LLMProvider {
35
- private baseUrl: string
36
- private model: string
37
-
38
- constructor(_apiKey: string, baseURL?: string, model?: string) {
39
- this.baseUrl = baseURL || 'http://localhost:11434'
40
- this.model = model || 'llama3.2'
41
- }
42
-
43
- async *chat(
44
- messages: LLMMessage[],
45
- tools: ToolDefinition[],
46
- signal?: AbortSignal,
47
- ): AsyncGenerator<LLMChunk> {
48
- // Build Ollama-format messages
49
- const ollamaMessages: OllamaMessage[] = messages.map(m => {
50
- if (m.role === 'system') {
51
- return { role: 'system', content: m.content || '' }
52
- }
53
- if (m.role === 'user') {
54
- return { role: 'user', content: m.content || '' }
55
- }
56
- if (m.role === 'assistant') {
57
- if (m.tool_calls && m.tool_calls.length > 0) {
58
- return {
59
- role: 'assistant',
60
- content: m.content || '',
61
- tool_calls: m.tool_calls.map(tc => ({
62
- function: {
63
- name: tc.name,
64
- arguments: JSON.stringify(tc.input),
65
- },
66
- })),
67
- }
68
- }
69
- return { role: 'assistant', content: m.content || '' }
70
- }
71
- if (m.role === 'tool') {
72
- return { role: 'tool', content: m.content || '' }
73
- }
74
- return { role: 'user', content: '' }
75
- })
76
-
77
- // Build tools
78
- const ollamaTools: OllamaTool[] | undefined =
79
- tools.length > 0
80
- ? tools.map(t => {
81
- const properties: Record<string, unknown> = {}
82
- for (const [key, param] of Object.entries(t.parameters)) {
83
- const { required: _, ...rest } = param
84
- properties[key] = rest
85
- }
86
- return {
87
- type: 'function' as const,
88
- function: {
89
- name: t.name,
90
- description: t.description,
91
- parameters: {
92
- type: 'object',
93
- properties,
94
- required: Object.entries(t.parameters)
95
- .filter(([, v]) => v.required)
96
- .map(([k]) => k),
97
- },
98
- },
99
- }
100
- })
101
- : undefined
102
-
103
- const body: Record<string, unknown> = {
104
- model: this.model,
105
- messages: ollamaMessages,
106
- stream: true,
107
- }
108
- if (ollamaTools && ollamaTools.length > 0) {
109
- body.tools = ollamaTools
110
- }
111
-
112
- const response = await fetch(`${this.baseUrl}/api/chat`, {
113
- method: 'POST',
114
- headers: { 'Content-Type': 'application/json' },
115
- signal,
116
- body: JSON.stringify(body),
117
- })
118
-
119
- if (!response.ok) {
120
- const errText = await response.text()
121
- throw new Error(`Ollama API error (${response.status}): ${errText}`)
122
- }
123
-
124
- const reader = response.body?.getReader()
125
- if (!reader) throw new Error('No response body')
126
-
127
- const decoder = new TextDecoder()
128
- let buffer = ''
129
-
130
- while (true) {
131
- const { done, value } = await reader.read()
132
- if (done) break
133
-
134
- buffer += decoder.decode(value, { stream: true })
135
- const lines = buffer.split('\n')
136
- buffer = lines.pop() || ''
137
-
138
- for (const line of lines) {
139
- if (!line.trim()) continue
140
- try {
141
- const chunk = JSON.parse(line) as OllamaResponse
142
-
143
- if (chunk.message?.content) {
144
- yield { type: 'text', text: chunk.message.content }
145
- }
146
-
147
- if (chunk.message?.tool_calls) {
148
- for (const tc of chunk.message.tool_calls) {
149
- let input: Record<string, unknown> = {}
150
- try {
151
- input = JSON.parse(tc.function.arguments || '{}')
152
- } catch {
153
- console.error(
154
- '[ollama] Failed to parse tool_call arguments:',
155
- tc.function.arguments,
156
- )
157
- input = {}
158
- }
159
- yield {
160
- type: 'tool_use',
161
- tool_call: {
162
- id: `ollama-${tc.function.name}-${Date.now()}`,
163
- name: tc.function.name,
164
- input,
165
- },
166
- }
167
- }
168
- }
169
-
170
- if (chunk.done) {
171
- yield {
172
- type: 'complete',
173
- finish_reason: chunk.done_reason || 'stop',
174
- usage: {
175
- input_tokens: chunk.prompt_eval_count ?? 0,
176
- output_tokens: chunk.eval_count ?? 0,
177
- },
178
- }
179
- }
180
- } catch {
181
- console.error('[ollama] Failed to parse stream line:', line)
182
- }
183
- }
184
- }
185
- }
186
- }
1
+ import { ToolCall, type ToolDefinition } from '../../tools/types.js'
2
+ import type { LLMChunk, LLMMessage, LLMProvider } from '../llm.js'
3
+
4
+ interface OllamaTool {
5
+ type: 'function'
6
+ function: {
7
+ name: string
8
+ description: string
9
+ parameters: Record<string, unknown>
10
+ }
11
+ }
12
+
13
+ interface OllamaMessage {
14
+ role: 'system' | 'user' | 'assistant' | 'tool'
15
+ content: string
16
+ tool_calls?: {
17
+ function: {
18
+ name: string
19
+ arguments: string
20
+ }
21
+ }[]
22
+ }
23
+
24
+ interface OllamaResponse {
25
+ model: string
26
+ created_at: string
27
+ message: OllamaMessage
28
+ done: boolean
29
+ done_reason?: string
30
+ prompt_eval_count?: number
31
+ eval_count?: number
32
+ }
33
+
34
+ export class OllamaProvider implements LLMProvider {
35
+ private baseUrl: string
36
+ private model: string
37
+
38
+ constructor(_apiKey: string, baseURL?: string, model?: string) {
39
+ this.baseUrl = baseURL || 'http://localhost:11434'
40
+ this.model = model || 'llama3.2'
41
+ }
42
+
43
+ async *chat(
44
+ messages: LLMMessage[],
45
+ tools: ToolDefinition[],
46
+ signal?: AbortSignal,
47
+ ): AsyncGenerator<LLMChunk> {
48
+ // Build Ollama-format messages
49
+ const ollamaMessages: OllamaMessage[] = messages.map(m => {
50
+ if (m.role === 'system') {
51
+ return { role: 'system', content: m.content || '' }
52
+ }
53
+ if (m.role === 'user') {
54
+ return { role: 'user', content: m.content || '' }
55
+ }
56
+ if (m.role === 'assistant') {
57
+ if (m.tool_calls && m.tool_calls.length > 0) {
58
+ return {
59
+ role: 'assistant',
60
+ content: m.content || '',
61
+ tool_calls: m.tool_calls.map(tc => ({
62
+ function: {
63
+ name: tc.name,
64
+ arguments: JSON.stringify(tc.input),
65
+ },
66
+ })),
67
+ }
68
+ }
69
+ return { role: 'assistant', content: m.content || '' }
70
+ }
71
+ if (m.role === 'tool') {
72
+ return { role: 'tool', content: m.content || '' }
73
+ }
74
+ return { role: 'user', content: '' }
75
+ })
76
+
77
+ // Build tools
78
+ const ollamaTools: OllamaTool[] | undefined =
79
+ tools.length > 0
80
+ ? tools.map(t => {
81
+ const properties: Record<string, unknown> = {}
82
+ for (const [key, param] of Object.entries(t.parameters)) {
83
+ const { required: _, ...rest } = param
84
+ properties[key] = rest
85
+ }
86
+ return {
87
+ type: 'function' as const,
88
+ function: {
89
+ name: t.name,
90
+ description: t.description,
91
+ parameters: {
92
+ type: 'object',
93
+ properties,
94
+ required: Object.entries(t.parameters)
95
+ .filter(([, v]) => v.required)
96
+ .map(([k]) => k),
97
+ },
98
+ },
99
+ }
100
+ })
101
+ : undefined
102
+
103
+ const body: Record<string, unknown> = {
104
+ model: this.model,
105
+ messages: ollamaMessages,
106
+ stream: true,
107
+ }
108
+ if (ollamaTools && ollamaTools.length > 0) {
109
+ body.tools = ollamaTools
110
+ }
111
+
112
+ const response = await fetch(`${this.baseUrl}/api/chat`, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ signal,
116
+ body: JSON.stringify(body),
117
+ })
118
+
119
+ if (!response.ok) {
120
+ const errText = await response.text()
121
+ throw new Error(`Ollama API error (${response.status}): ${errText}`)
122
+ }
123
+
124
+ const reader = response.body?.getReader()
125
+ if (!reader) throw new Error('No response body')
126
+
127
+ const decoder = new TextDecoder()
128
+ let buffer = ''
129
+
130
+ while (true) {
131
+ const { done, value } = await reader.read()
132
+ if (done) break
133
+
134
+ buffer += decoder.decode(value, { stream: true })
135
+ const lines = buffer.split('\n')
136
+ buffer = lines.pop() || ''
137
+
138
+ for (const line of lines) {
139
+ if (!line.trim()) continue
140
+ try {
141
+ const chunk = JSON.parse(line) as OllamaResponse
142
+
143
+ if (chunk.message?.content) {
144
+ yield { type: 'text', text: chunk.message.content }
145
+ }
146
+
147
+ if (chunk.message?.tool_calls) {
148
+ for (const tc of chunk.message.tool_calls) {
149
+ let input: Record<string, unknown> = {}
150
+ try {
151
+ input = JSON.parse(tc.function.arguments || '{}')
152
+ } catch {
153
+ console.error(
154
+ '[ollama] Failed to parse tool_call arguments:',
155
+ tc.function.arguments,
156
+ )
157
+ input = {}
158
+ }
159
+ yield {
160
+ type: 'tool_use',
161
+ tool_call: {
162
+ id: `ollama-${tc.function.name}-${Date.now()}`,
163
+ name: tc.function.name,
164
+ input,
165
+ },
166
+ }
167
+ }
168
+ }
169
+
170
+ if (chunk.done) {
171
+ yield {
172
+ type: 'complete',
173
+ finish_reason: chunk.done_reason || 'stop',
174
+ usage: {
175
+ input_tokens: chunk.prompt_eval_count ?? 0,
176
+ output_tokens: chunk.eval_count ?? 0,
177
+ },
178
+ }
179
+ }
180
+ } catch {
181
+ console.error('[ollama] Failed to parse stream line:', line)
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
@@ -21,6 +21,38 @@ interface ExtendedCreateParams {
21
21
  enable_cache?: boolean
22
22
  }
23
23
 
24
+ /** Try to parse JSON, and attempt to repair common issues like unescaped quotes */
25
+ function tryParseJSON(input: string): Record<string, unknown> | null {
26
+ try {
27
+ return JSON.parse(input)
28
+ } catch {
29
+ // Try to repair common JSON issues from LLM output
30
+ let repaired = input
31
+
32
+ // Fix unescaped quotes in string values:
33
+ // Pattern: key="unquoted text" -> key="escaped text"
34
+ // This regex finds ="<not properly escaped>" and adds escaping
35
+ // It handles: ="<content with spaces and 中文">
36
+ repaired = repaired.replace(/= "([^"\\]*(?:\\.[^"\\]*)*)"/g, (match, content) => {
37
+ // Escape any unescaped quotes within the content
38
+ const escaped = content.replace(/(?<!\\)"/g, '\\"')
39
+ return `= "${escaped}"`
40
+ })
41
+
42
+ // Also try escaping quotes after @ (for @click="...")
43
+ repaired = repaired.replace(/@(\w+)="([^"\\]*(?:\\.[^"\\]*)*)"/g, (match, attr, content) => {
44
+ const escaped = content.replace(/(?<!\\)"/g, '\\"')
45
+ return `@${attr}="${escaped}"`
46
+ })
47
+
48
+ try {
49
+ return JSON.parse(repaired)
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+ }
55
+
24
56
  export class OpenAIProvider implements LLMProvider {
25
57
  private client: OpenAI
26
58
  private model: string
@@ -146,7 +178,14 @@ export class OpenAIProvider implements LLMProvider {
146
178
  } | null = null
147
179
  let fullText = ''
148
180
  let reasoningContent: string | undefined
149
- let lastUsage: { prompt_tokens?: number; completion_tokens?: number } | undefined
181
+ let lastUsage:
182
+ | {
183
+ prompt_tokens?: number
184
+ completion_tokens?: number
185
+ prompt_cache_hit_tokens?: number
186
+ prompt_cache_miss_tokens?: number
187
+ }
188
+ | undefined
150
189
 
151
190
  // Track a pending "complete" yield — OpenAI sends finish_reason in a content
152
191
  // chunk but sends usage in a *separate final chunk* with no choices/delta.
@@ -159,7 +198,15 @@ export class OpenAIProvider implements LLMProvider {
159
198
  for await (const chunk of stream) {
160
199
  // Capture usage info if present (may come in a chunk without choices)
161
200
  // OpenAI uses prompt_tokens/completion_tokens; map to our input_tokens/output_tokens
162
- const rawChunk = chunk as { usage?: { prompt_tokens?: number; completion_tokens?: number } }
201
+ // DeepSeek also returns prompt_cache_hit_tokens & prompt_cache_miss_tokens
202
+ const rawChunk = chunk as {
203
+ usage?: {
204
+ prompt_tokens?: number
205
+ completion_tokens?: number
206
+ prompt_cache_hit_tokens?: number
207
+ prompt_cache_miss_tokens?: number
208
+ }
209
+ }
163
210
  if (rawChunk.usage) {
164
211
  lastUsage = rawChunk.usage
165
212
  // If we have a pending complete, yield it now with usage
@@ -171,6 +218,8 @@ export class OpenAIProvider implements LLMProvider {
171
218
  usage: {
172
219
  input_tokens: rawChunk.usage.prompt_tokens ?? 0,
173
220
  output_tokens: rawChunk.usage.completion_tokens ?? 0,
221
+ prompt_cache_hit_tokens: rawChunk.usage.prompt_cache_hit_tokens,
222
+ prompt_cache_miss_tokens: rawChunk.usage.prompt_cache_miss_tokens,
174
223
  },
175
224
  }
176
225
  pendingComplete = null
@@ -203,7 +252,7 @@ export class OpenAIProvider implements LLMProvider {
203
252
  let input: Record<string, unknown>
204
253
  const rawArgs = currentToolCall.arguments || ''
205
254
  try {
206
- input = JSON.parse(rawArgs || '{}')
255
+ input = tryParseJSON(rawArgs || '{}') || {}
207
256
  } catch {
208
257
  console.error(
209
258
  '[openai] Failed to parse tool_call arguments (flush on new tool):',
@@ -237,12 +286,13 @@ export class OpenAIProvider implements LLMProvider {
237
286
  if (currentToolCall) {
238
287
  const finalArgs = currentToolCall.arguments || ''
239
288
  try {
289
+ const parsed = tryParseJSON(finalArgs || '{}')
240
290
  yield {
241
291
  type: 'tool_use',
242
292
  tool_call: {
243
293
  id: currentToolCall.id,
244
294
  name: currentToolCall.name,
245
- input: JSON.parse(finalArgs || '{}'),
295
+ input: parsed || {},
246
296
  },
247
297
  reasoning_content: reasoningContent,
248
298
  }
@@ -274,6 +324,8 @@ export class OpenAIProvider implements LLMProvider {
274
324
  usage: {
275
325
  input_tokens: lastUsage.prompt_tokens ?? 0,
276
326
  output_tokens: lastUsage.completion_tokens ?? 0,
327
+ prompt_cache_hit_tokens: lastUsage.prompt_cache_hit_tokens,
328
+ prompt_cache_miss_tokens: lastUsage.prompt_cache_miss_tokens,
277
329
  },
278
330
  }
279
331
  reasoningContent = undefined
@@ -294,6 +346,8 @@ export class OpenAIProvider implements LLMProvider {
294
346
  ? {
295
347
  input_tokens: lastUsage.prompt_tokens ?? 0,
296
348
  output_tokens: lastUsage.completion_tokens ?? 0,
349
+ prompt_cache_hit_tokens: lastUsage.prompt_cache_hit_tokens,
350
+ prompt_cache_miss_tokens: lastUsage.prompt_cache_miss_tokens,
297
351
  }
298
352
  : undefined
299
353
  yield {
@@ -310,12 +364,14 @@ export class OpenAIProvider implements LLMProvider {
310
364
  ? {
311
365
  input_tokens: lastUsage.prompt_tokens ?? 0,
312
366
  output_tokens: lastUsage.completion_tokens ?? 0,
367
+ prompt_cache_hit_tokens: lastUsage.prompt_cache_hit_tokens,
368
+ prompt_cache_miss_tokens: lastUsage.prompt_cache_miss_tokens,
313
369
  }
314
370
  : undefined
315
371
  let input: Record<string, unknown>
316
372
  const rawFinalArgs = currentToolCall.arguments || ''
317
373
  try {
318
- input = JSON.parse(rawFinalArgs || '{}')
374
+ input = tryParseJSON(rawFinalArgs || '{}') || {}
319
375
  } catch {
320
376
  console.error('[openai] Failed to parse tool_call arguments (final flush):', rawFinalArgs)
321
377
  input = {}
@@ -25,6 +25,8 @@ interface SessionData {
25
25
  totalInputTokens: number
26
26
  totalOutputTokens: number
27
27
  totalApiCalls: number
28
+ totalCacheHitTokens?: number
29
+ totalCacheMissTokens?: number
28
30
  mode: 'code' | 'plan' | 'ask'
29
31
  model: string
30
32
  provider: string
@@ -222,13 +224,36 @@ function printTokenStats(
222
224
  turnApi: number,
223
225
  totalApi: number,
224
226
  output?: SessionOutput,
227
+ turnCacheHit?: number,
228
+ turnCacheMiss?: number,
229
+ totalCacheHit?: number,
230
+ totalCacheMiss?: number,
225
231
  ): void {
226
232
  const bus = getGlobalEventBus()
227
- bus.emit(EventChannels.TOKEN_STATS, { turnIn, turnOut, totalIn, totalOut, turnApi, totalApi })
233
+ bus.emit(EventChannels.TOKEN_STATS, {
234
+ turnIn,
235
+ turnOut,
236
+ totalIn,
237
+ totalOut,
238
+ turnApi,
239
+ totalApi,
240
+ turnCacheHit,
241
+ turnCacheMiss,
242
+ totalCacheHit,
243
+ totalCacheMiss,
244
+ })
228
245
  // Skip terminal output in Web UI mode
229
246
  if (output?.suppressToolOutput) return
230
247
  const total = totalIn + totalOut
231
- const msg = ` ${GY}┃${RS} ${GY}${BLD}▴${RS}${GY}${turnIn}${RS} ${GY}${BLD}▾${RS}${GY}${turnOut}${RS} ${GY}total${RS} ${total} ${GY}calls${RS} ${turnApi}(${totalApi})`
248
+ let msg = ` ${GY}┃${RS} ${GY}${BLD}▴${RS}${GY}${turnIn}${RS} ${GY}${BLD}▾${RS}${GY}${turnOut}${RS} ${GY}total${RS} ${total} ${GY}calls${RS} ${turnApi}(${totalApi})`
249
+ // Show cache hit rate if available
250
+ const cacheHit = totalCacheHit ?? 0
251
+ const cacheMiss = totalCacheMiss ?? 0
252
+ const cacheTotal = cacheHit + cacheMiss
253
+ if (cacheTotal > 0) {
254
+ const pct = Math.round((cacheHit / cacheTotal) * 100)
255
+ msg += ` ${GY}cached${RS} ${pct}%`
256
+ }
232
257
  writeOut(`\n${msg}\n`, output)
233
258
  }
234
259
 
@@ -259,6 +284,10 @@ export class Session {
259
284
  turnOutputTokens: number = 0
260
285
  turnApiCalls: number = 0
261
286
  totalApiCalls: number = 0
287
+ turnCacheHitTokens: number = 0
288
+ turnCacheMissTokens: number = 0
289
+ totalCacheHitTokens: number = 0
290
+ totalCacheMissTokens: number = 0
262
291
  private stopped: boolean = false
263
292
  private abortController: AbortController | null = null
264
293
 
@@ -316,6 +345,8 @@ export class Session {
316
345
  totalInputTokens: this.totalInputTokens,
317
346
  totalOutputTokens: this.totalOutputTokens,
318
347
  totalApiCalls: this.totalApiCalls,
348
+ totalCacheHitTokens: this.totalCacheHitTokens || undefined,
349
+ totalCacheMissTokens: this.totalCacheMissTokens || undefined,
319
350
  mode: this.config.mode,
320
351
  model: this.config.model,
321
352
  provider: this.config.provider,
@@ -358,6 +389,8 @@ export class Session {
358
389
  session.totalInputTokens = data.totalInputTokens
359
390
  session.totalOutputTokens = data.totalOutputTokens
360
391
  session.totalApiCalls = data.totalApiCalls
392
+ session.totalCacheHitTokens = data.totalCacheHitTokens ?? 0
393
+ session.totalCacheMissTokens = data.totalCacheMissTokens ?? 0
361
394
  return session
362
395
  }
363
396
 
@@ -410,6 +443,8 @@ export class Session {
410
443
  this.turnInputTokens = 0
411
444
  this.turnOutputTokens = 0
412
445
  this.turnApiCalls = 0
446
+ this.turnCacheHitTokens = 0
447
+ this.turnCacheMissTokens = 0
413
448
 
414
449
  let iterations = 0
415
450
  const maxIterations = 30
@@ -583,6 +618,15 @@ export class Session {
583
618
  this.turnOutputTokens += chunk.usage.output_tokens
584
619
  this.totalInputTokens += chunk.usage.input_tokens
585
620
  this.totalOutputTokens += chunk.usage.output_tokens
621
+ // Accumulate DeepSeek cache hit/miss tokens
622
+ if (chunk.usage.prompt_cache_hit_tokens != null) {
623
+ this.turnCacheHitTokens += chunk.usage.prompt_cache_hit_tokens
624
+ this.totalCacheHitTokens += chunk.usage.prompt_cache_hit_tokens
625
+ }
626
+ if (chunk.usage.prompt_cache_miss_tokens != null) {
627
+ this.turnCacheMissTokens += chunk.usage.prompt_cache_miss_tokens
628
+ this.totalCacheMissTokens += chunk.usage.prompt_cache_miss_tokens
629
+ }
586
630
  }
587
631
  if (chunk.finish_reason === 'stop' || chunk.finish_reason === 'end_turn') {
588
632
  if (toolCalls.length === 0) {
@@ -600,6 +644,10 @@ export class Session {
600
644
  this.turnApiCalls,
601
645
  this.totalApiCalls,
602
646
  out,
647
+ this.turnCacheHitTokens,
648
+ this.turnCacheMissTokens,
649
+ this.totalCacheHitTokens,
650
+ this.totalCacheMissTokens,
603
651
  )
604
652
  writeOut('\n\n', out)
605
653
  saveTokenUsage(
@@ -687,6 +735,10 @@ export class Session {
687
735
  this.turnApiCalls,
688
736
  this.totalApiCalls,
689
737
  out,
738
+ this.turnCacheHitTokens,
739
+ this.turnCacheMissTokens,
740
+ this.totalCacheHitTokens,
741
+ this.totalCacheMissTokens,
690
742
  )
691
743
  writeOut('\n\n', out)
692
744
  }
@@ -712,7 +764,7 @@ export class Session {
712
764
 
713
765
  // ── User confirmation for write-type tool calls ──
714
766
  if (!this.config.autoApprove && this.output?.confirmTool && toolCalls.length > 0) {
715
- const writeTools = ['edit', 'bash', 'write_plan', 'exec', 'install_skill']
767
+ const writeTools = ['edit', 'bash', 'write_plan', 'install_skill']
716
768
  const needsConfirm = toolCalls.filter(tc => writeTools.includes(tc.name))
717
769
  if (needsConfirm.length > 0) {
718
770
  const approved = await this.output.confirmTool(needsConfirm)
@@ -802,6 +854,10 @@ export class Session {
802
854
  this.turnApiCalls,
803
855
  this.totalApiCalls,
804
856
  out,
857
+ this.turnCacheHitTokens,
858
+ this.turnCacheMissTokens,
859
+ this.totalCacheHitTokens,
860
+ this.totalCacheMissTokens,
805
861
  )
806
862
  writeOut('\nAgent reached maximum iterations. Stopping.\n', out)
807
863
  }