@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,298 +1,298 @@
1
- import { requestProvider } from "../provider/router.mjs"
2
- import { getConversationHistory, replaceMessages } from "./store.mjs"
3
- import { HookBus } from "../plugin/hook-bus.mjs"
4
- import { saveCheckpoint } from "./checkpoint.mjs"
5
- import { recordTurn } from "../usage/usage-meter.mjs"
6
- import { loadPricing, calculateCost } from "../usage/pricing.mjs"
7
-
8
- const COMPACTION_SYSTEM = `You are a conversation summarizer. Create a structured summary preserving all critical information for continued work.
9
-
10
- ## Output Format
11
-
12
- <summary>
13
- <goal>The user's overall goal or current task</goal>
14
- <completed>
15
- - Completed task with specific details (file paths, function names, line numbers)
16
- </completed>
17
- <in_progress>Current work being done, if any</in_progress>
18
- <files_modified>
19
- - path/to/file: specific change description
20
- </files_modified>
21
- <key_decisions>
22
- - Decision and reasoning
23
- - User preferences or constraints
24
- </key_decisions>
25
- <errors_resolved>
26
- - Error description → fix applied
27
- </errors_resolved>
28
- <next_steps>
29
- - Specific next action items
30
- </next_steps>
31
- </summary>
32
-
33
- Rules:
34
- - Use the SAME LANGUAGE as the conversation
35
- - Preserve ALL file paths, function names, variable names, and technical identifiers exactly
36
- - Include specific code changes, not just "modified file X"
37
- - Omit tool call metadata and message formatting details
38
- - Be concise but never drop actionable information`
39
-
40
- const DEFAULT_THRESHOLD_MESSAGES = 50
41
- const DEFAULT_THRESHOLD_RATIO = 0.7
42
- const DEFAULT_KEEP_RECENT = 6
43
- const DEFAULT_KEEP_RECENT_TURNS = 3
44
- const TOOL_RESULT_PREVIEW_LIMIT = 200
45
-
46
- // Estimate tokens from a string, accounting for CJK characters (~1.5 chars/token vs ~4 for Latin)
47
- export function estimateStringTokens(str) {
48
- if (!str) return 0
49
- let cjk = 0
50
- for (let i = 0; i < str.length; i++) {
51
- const code = str.charCodeAt(i)
52
- if ((code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x30FF) ||
53
- (code >= 0xAC00 && code <= 0xD7AF)) cjk++
54
- }
55
- const latin = str.length - cjk
56
- return Math.ceil(latin / 4 + cjk / 1.5)
57
- }
58
-
59
- const MSG_OVERHEAD = 4 // ~4 tokens per message for role/metadata
60
-
61
- export function estimateTokenCount(messages) {
62
- let tokens = 0
63
- for (const msg of messages) {
64
- tokens += MSG_OVERHEAD
65
- const content = msg.content
66
- if (Array.isArray(content)) {
67
- for (const block of content) {
68
- if (block.type === "image") {
69
- tokens += 1600 // conservative estimate for a typical image
70
- } else if (block.type === "tool_use") {
71
- tokens += estimateStringTokens(block.name || "")
72
- tokens += estimateStringTokens(JSON.stringify(block.input || {}))
73
- } else if (block.type === "tool_result") {
74
- tokens += estimateStringTokens(String(block.content || ""))
75
- } else {
76
- tokens += estimateStringTokens(block.text || block.content || "")
77
- }
78
- }
79
- } else {
80
- tokens += estimateStringTokens(content || "")
81
- }
82
- }
83
- return tokens
84
- }
85
-
86
- /**
87
- * Pre-prune messages before LLM summarization.
88
- * - Strip synthetic scaffolding messages (continuation noise)
89
- * - Truncate large tool_result content with aging: older steps get shorter previews
90
- * - Keep tool_use blocks intact (they show model intent)
91
- * - Truncate very long plain-text assistant/user messages
92
- */
93
- export function pruneForSummary(messages, previewLimit = TOOL_RESULT_PREVIEW_LIMIT) {
94
- // Strip synthetic scaffolding messages (continuation prompts, fake tool_result errors)
95
- const real = messages.filter(msg => !msg.synthetic)
96
-
97
- // #2 工具结果老化: find max step to compute relative age per message
98
- const maxStep = real.reduce((m, msg) => Math.max(m, msg.step || 0), 0)
99
-
100
- return real.map((msg) => {
101
- // Aging: older tool_results get more aggressive truncation
102
- const age = maxStep - (msg.step || 0)
103
- const effectiveLimit = Math.max(50, previewLimit - age * 15)
104
-
105
- const content = msg.content
106
- if (Array.isArray(content)) {
107
- const pruned = content.map((block) => {
108
- if (block.type === "tool_result") {
109
- const raw = String(block.content || "")
110
- if (raw.length > effectiveLimit) {
111
- return {
112
- ...block,
113
- content: `${raw.slice(0, effectiveLimit)}... [truncated ${raw.length} chars, age=${age}]`
114
- }
115
- }
116
- }
117
- return block
118
- })
119
- return { ...msg, content: pruned }
120
- }
121
- // Truncate very long plain-text messages (e.g. large tool output pasted as text)
122
- if (typeof content === "string" && content.length > 2000) {
123
- return { ...msg, content: `${content.slice(0, 2000)}... [truncated ${content.length} chars]` }
124
- }
125
- return msg
126
- })
127
- }
128
-
129
- const BUILTIN_CONTEXT = {
130
- "gpt-5": 272000, "o3": 200000, "o1": 200000,
131
- "claude-opus-4": 200000, "claude-3-5": 200000, "claude-3.5": 200000, "claude": 200000,
132
- "gemini-2": 1048576, "gemini-1.5": 1048576, "gemini": 128000,
133
- "gpt-4o": 128000, "gpt-4": 128000, "gpt-3.5": 16000,
134
- "deepseek": 64000, "qwen": 128000
135
- }
136
-
137
- export function modelContextLimit(model, configState = null) {
138
- const m = String(model || "").toLowerCase()
139
- // 1) Check provider-level context_limit for the active provider
140
- const providerCfg = configState?.config?.provider
141
- if (providerCfg) {
142
- // Per-model override from provider.model_context map
143
- const mc = providerCfg.model_context
144
- if (mc) {
145
- if (mc[model]) return mc[model]
146
- for (const key of Object.keys(mc)) {
147
- if (m.startsWith(key.toLowerCase())) return mc[key]
148
- }
149
- }
150
- // Provider-level context_limit
151
- const active = providerCfg[providerCfg.default]
152
- if (active?.context_limit > 0) return active.context_limit
153
- }
154
- // 2) Builtin prefix match
155
- for (const [prefix, limit] of Object.entries(BUILTIN_CONTEXT)) {
156
- if (m.includes(prefix)) return limit
157
- }
158
- return 128000
159
- }
160
-
161
- export function contextUtilization(messages, model, configState = null) {
162
- const tokens = estimateTokenCount(messages)
163
- const limit = modelContextLimit(model, configState)
164
- const ratio = limit > 0 ? Math.min(1, tokens / limit) : 0
165
- return {
166
- tokens,
167
- limit,
168
- ratio,
169
- percent: Math.round(ratio * 100)
170
- }
171
- }
172
-
173
- export function supportsNativeCompaction(providerType, model) {
174
- if (providerType !== "anthropic") return false
175
- const m = String(model || "").toLowerCase()
176
- return m.includes("claude") && (m.includes("opus") || m.includes("sonnet"))
177
- }
178
-
179
- export function shouldCompact({ messages, model, thresholdMessages = DEFAULT_THRESHOLD_MESSAGES, thresholdRatio = DEFAULT_THRESHOLD_RATIO, configState = null, realTokenCount = null }) {
180
- if (messages.length >= thresholdMessages) return true
181
- const limit = modelContextLimit(model, configState)
182
- const tokens = realTokenCount != null ? realTokenCount : estimateTokenCount(messages)
183
- return tokens >= limit * thresholdRatio
184
- }
185
-
186
- export async function compactSession({
187
- sessionId,
188
- model,
189
- providerType,
190
- configState,
191
- keepRecent = DEFAULT_KEEP_RECENT,
192
- keepRecentTurns = DEFAULT_KEEP_RECENT_TURNS,
193
- baseUrl = null,
194
- apiKeyEnv = null
195
- }) {
196
- const history = await getConversationHistory(sessionId, 9999)
197
- if (history.length <= keepRecent + 2) return { compacted: false, reason: "too few messages" }
198
-
199
- // Turn-based split: keep last keepRecentTurns complete turns
200
- // A "turn" = one user interaction cycle (user msg + model response + all tool calls)
201
- // Falls back to message-count if no turnId metadata is present
202
- let splitIdx
203
- const turnIds = []
204
- const seenTurns = new Set()
205
- for (const msg of history) {
206
- if (msg.turnId && !seenTurns.has(msg.turnId)) {
207
- seenTurns.add(msg.turnId)
208
- turnIds.push(msg.turnId)
209
- }
210
- }
211
- if (turnIds.length > keepRecentTurns) {
212
- const keepFromTurnId = turnIds[turnIds.length - keepRecentTurns]
213
- splitIdx = history.findIndex(msg => msg.turnId === keepFromTurnId)
214
- if (splitIdx < 0) splitIdx = history.length - keepRecent
215
- } else {
216
- // Fallback: not enough turns, use message count
217
- splitIdx = history.length - keepRecent
218
- }
219
- const toSummarize = history.slice(0, splitIdx)
220
- const kept = history.slice(splitIdx)
221
-
222
- // Layer 1: prune large tool outputs before sending to LLM
223
- const pruned = pruneForSummary(toSummarize)
224
- const summaryPrompt = pruned.map((m) => {
225
- const content = m.content
226
- if (Array.isArray(content)) {
227
- return `[${m.role}]: ${content.map((b) => {
228
- if (b.type === "text") return b.text || ""
229
- if (b.type === "tool_use") return `[tool_use:${b.name}(${JSON.stringify(b.input || {}).slice(0, 120)})]`
230
- if (b.type === "tool_result") return `[tool_result:${b.is_error ? "ERROR " : ""}${b.content || ""}]`
231
- return ""
232
- }).filter(Boolean).join("\n")}`
233
- }
234
- return `[${m.role}]: ${content}`
235
- }).join("\n\n")
236
-
237
- const hookPayload = await HookBus.sessionCompacting({
238
- sessionId,
239
- messageCount: history.length,
240
- summarizeCount: toSummarize.length,
241
- keepCount: kept.length
242
- })
243
- if (hookPayload?.skip) return { compacted: false, reason: "skipped by hook" }
244
-
245
- let summaryText
246
- let compactionUsage = null
247
- try {
248
- const response = await requestProvider({
249
- configState,
250
- providerType,
251
- model,
252
- system: COMPACTION_SYSTEM,
253
- messages: [{ role: "user", content: summaryPrompt }],
254
- tools: [],
255
- baseUrl,
256
- apiKeyEnv
257
- })
258
- summaryText = (response.text || "").trim()
259
- compactionUsage = response.usage || null
260
- } catch (error) {
261
- return { compacted: false, reason: `compaction LLM call failed: ${error.message}` }
262
- }
263
-
264
- if (!summaryText) return { compacted: false, reason: "empty summary from LLM" }
265
-
266
- // Replace all messages with: [summary] + [kept recent messages]
267
- const summaryMessage = {
268
- role: "user",
269
- content: `<compaction-summary>\n${summaryText}\n</compaction-summary>`
270
- }
271
- await replaceMessages(sessionId, [summaryMessage, ...kept])
272
-
273
- // Record compaction LLM usage so it's not "invisible"
274
- if (compactionUsage) {
275
- try {
276
- const { pricing } = await loadPricing(configState)
277
- const { amount } = calculateCost(pricing, model, compactionUsage)
278
- await recordTurn({ sessionId, usage: compactionUsage, cost: amount })
279
- } catch { /* best-effort */ }
280
- }
281
-
282
- await saveCheckpoint(sessionId, {
283
- kind: "compaction",
284
- iteration: 0,
285
- compactedAt: Date.now(),
286
- summarizeCount: toSummarize.length,
287
- keepCount: kept.length,
288
- summaryVersion: 1,
289
- summaryLength: summaryText.length
290
- })
291
-
292
- return {
293
- compacted: true,
294
- summarizedCount: toSummarize.length,
295
- keptCount: kept.length,
296
- summaryLength: summaryText.length
297
- }
298
- }
1
+ import { requestProvider } from "../provider/router.mjs"
2
+ import { getConversationHistory, replaceMessages } from "./store.mjs"
3
+ import { HookBus } from "../plugin/hook-bus.mjs"
4
+ import { saveCheckpoint } from "./checkpoint.mjs"
5
+ import { recordTurn } from "../usage/usage-meter.mjs"
6
+ import { loadPricing, calculateCost } from "../usage/pricing.mjs"
7
+
8
+ const COMPACTION_SYSTEM = `You are a conversation summarizer. Create a structured summary preserving all critical information for continued work.
9
+
10
+ ## Output Format
11
+
12
+ <summary>
13
+ <goal>The user's overall goal or current task</goal>
14
+ <completed>
15
+ - Completed task with specific details (file paths, function names, line numbers)
16
+ </completed>
17
+ <in_progress>Current work being done, if any</in_progress>
18
+ <files_modified>
19
+ - path/to/file: specific change description
20
+ </files_modified>
21
+ <key_decisions>
22
+ - Decision and reasoning
23
+ - User preferences or constraints
24
+ </key_decisions>
25
+ <errors_resolved>
26
+ - Error description → fix applied
27
+ </errors_resolved>
28
+ <next_steps>
29
+ - Specific next action items
30
+ </next_steps>
31
+ </summary>
32
+
33
+ Rules:
34
+ - Use the SAME LANGUAGE as the conversation
35
+ - Preserve ALL file paths, function names, variable names, and technical identifiers exactly
36
+ - Include specific code changes, not just "modified file X"
37
+ - Omit tool call metadata and message formatting details
38
+ - Be concise but never drop actionable information`
39
+
40
+ const DEFAULT_THRESHOLD_MESSAGES = 50
41
+ const DEFAULT_THRESHOLD_RATIO = 0.7
42
+ const DEFAULT_KEEP_RECENT = 6
43
+ const DEFAULT_KEEP_RECENT_TURNS = 3
44
+ const TOOL_RESULT_PREVIEW_LIMIT = 200
45
+
46
+ // Estimate tokens from a string, accounting for CJK characters (~1.5 chars/token vs ~4 for Latin)
47
+ export function estimateStringTokens(str) {
48
+ if (!str) return 0
49
+ let cjk = 0
50
+ for (let i = 0; i < str.length; i++) {
51
+ const code = str.charCodeAt(i)
52
+ if ((code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x30FF) ||
53
+ (code >= 0xAC00 && code <= 0xD7AF)) cjk++
54
+ }
55
+ const latin = str.length - cjk
56
+ return Math.ceil(latin / 4 + cjk / 1.5)
57
+ }
58
+
59
+ const MSG_OVERHEAD = 4 // ~4 tokens per message for role/metadata
60
+
61
+ export function estimateTokenCount(messages) {
62
+ let tokens = 0
63
+ for (const msg of messages) {
64
+ tokens += MSG_OVERHEAD
65
+ const content = msg.content
66
+ if (Array.isArray(content)) {
67
+ for (const block of content) {
68
+ if (block.type === "image") {
69
+ tokens += 1600 // conservative estimate for a typical image
70
+ } else if (block.type === "tool_use") {
71
+ tokens += estimateStringTokens(block.name || "")
72
+ tokens += estimateStringTokens(JSON.stringify(block.input || {}))
73
+ } else if (block.type === "tool_result") {
74
+ tokens += estimateStringTokens(String(block.content || ""))
75
+ } else {
76
+ tokens += estimateStringTokens(block.text || block.content || "")
77
+ }
78
+ }
79
+ } else {
80
+ tokens += estimateStringTokens(content || "")
81
+ }
82
+ }
83
+ return tokens
84
+ }
85
+
86
+ /**
87
+ * Pre-prune messages before LLM summarization.
88
+ * - Strip synthetic scaffolding messages (continuation noise)
89
+ * - Truncate large tool_result content with aging: older steps get shorter previews
90
+ * - Keep tool_use blocks intact (they show model intent)
91
+ * - Truncate very long plain-text assistant/user messages
92
+ */
93
+ export function pruneForSummary(messages, previewLimit = TOOL_RESULT_PREVIEW_LIMIT) {
94
+ // Strip synthetic scaffolding messages (continuation prompts, fake tool_result errors)
95
+ const real = messages.filter(msg => !msg.synthetic)
96
+
97
+ // #2 工具结果老化: find max step to compute relative age per message
98
+ const maxStep = real.reduce((m, msg) => Math.max(m, msg.step || 0), 0)
99
+
100
+ return real.map((msg) => {
101
+ // Aging: older tool_results get more aggressive truncation
102
+ const age = maxStep - (msg.step || 0)
103
+ const effectiveLimit = Math.max(50, previewLimit - age * 15)
104
+
105
+ const content = msg.content
106
+ if (Array.isArray(content)) {
107
+ const pruned = content.map((block) => {
108
+ if (block.type === "tool_result") {
109
+ const raw = String(block.content || "")
110
+ if (raw.length > effectiveLimit) {
111
+ return {
112
+ ...block,
113
+ content: `${raw.slice(0, effectiveLimit)}... [truncated ${raw.length} chars, age=${age}]`
114
+ }
115
+ }
116
+ }
117
+ return block
118
+ })
119
+ return { ...msg, content: pruned }
120
+ }
121
+ // Truncate very long plain-text messages (e.g. large tool output pasted as text)
122
+ if (typeof content === "string" && content.length > 2000) {
123
+ return { ...msg, content: `${content.slice(0, 2000)}... [truncated ${content.length} chars]` }
124
+ }
125
+ return msg
126
+ })
127
+ }
128
+
129
+ const BUILTIN_CONTEXT = {
130
+ "gpt-5": 272000, "o3": 200000, "o1": 200000,
131
+ "claude-opus-4": 200000, "claude-3-5": 200000, "claude-3.5": 200000, "claude": 200000,
132
+ "gemini-2": 1048576, "gemini-1.5": 1048576, "gemini": 128000,
133
+ "gpt-4o": 128000, "gpt-4": 128000, "gpt-3.5": 16000,
134
+ "deepseek": 64000, "qwen": 128000
135
+ }
136
+
137
+ export function modelContextLimit(model, configState = null) {
138
+ const m = String(model || "").toLowerCase()
139
+ // 1) Check provider-level context_limit for the active provider
140
+ const providerCfg = configState?.config?.provider
141
+ if (providerCfg) {
142
+ // Per-model override from provider.model_context map
143
+ const mc = providerCfg.model_context
144
+ if (mc) {
145
+ if (mc[model]) return mc[model]
146
+ for (const key of Object.keys(mc)) {
147
+ if (m.startsWith(key.toLowerCase())) return mc[key]
148
+ }
149
+ }
150
+ // Provider-level context_limit
151
+ const active = providerCfg[providerCfg.default]
152
+ if (active?.context_limit > 0) return active.context_limit
153
+ }
154
+ // 2) Builtin prefix match
155
+ for (const [prefix, limit] of Object.entries(BUILTIN_CONTEXT)) {
156
+ if (m.includes(prefix)) return limit
157
+ }
158
+ return 128000
159
+ }
160
+
161
+ export function contextUtilization(messages, model, configState = null) {
162
+ const tokens = estimateTokenCount(messages)
163
+ const limit = modelContextLimit(model, configState)
164
+ const ratio = limit > 0 ? Math.min(1, tokens / limit) : 0
165
+ return {
166
+ tokens,
167
+ limit,
168
+ ratio,
169
+ percent: Math.round(ratio * 100)
170
+ }
171
+ }
172
+
173
+ export function supportsNativeCompaction(providerType, model) {
174
+ if (providerType !== "anthropic") return false
175
+ const m = String(model || "").toLowerCase()
176
+ return m.includes("claude") && (m.includes("opus") || m.includes("sonnet"))
177
+ }
178
+
179
+ export function shouldCompact({ messages, model, thresholdMessages = DEFAULT_THRESHOLD_MESSAGES, thresholdRatio = DEFAULT_THRESHOLD_RATIO, configState = null, realTokenCount = null }) {
180
+ if (messages.length >= thresholdMessages) return true
181
+ const limit = modelContextLimit(model, configState)
182
+ const tokens = realTokenCount != null ? realTokenCount : estimateTokenCount(messages)
183
+ return tokens >= limit * thresholdRatio
184
+ }
185
+
186
+ export async function compactSession({
187
+ sessionId,
188
+ model,
189
+ providerType,
190
+ configState,
191
+ keepRecent = DEFAULT_KEEP_RECENT,
192
+ keepRecentTurns = DEFAULT_KEEP_RECENT_TURNS,
193
+ baseUrl = null,
194
+ apiKeyEnv = null
195
+ }) {
196
+ const history = await getConversationHistory(sessionId, 9999)
197
+ if (history.length <= keepRecent + 2) return { compacted: false, reason: "too few messages" }
198
+
199
+ // Turn-based split: keep last keepRecentTurns complete turns
200
+ // A "turn" = one user interaction cycle (user msg + model response + all tool calls)
201
+ // Falls back to message-count if no turnId metadata is present
202
+ let splitIdx
203
+ const turnIds = []
204
+ const seenTurns = new Set()
205
+ for (const msg of history) {
206
+ if (msg.turnId && !seenTurns.has(msg.turnId)) {
207
+ seenTurns.add(msg.turnId)
208
+ turnIds.push(msg.turnId)
209
+ }
210
+ }
211
+ if (turnIds.length > keepRecentTurns) {
212
+ const keepFromTurnId = turnIds[turnIds.length - keepRecentTurns]
213
+ splitIdx = history.findIndex(msg => msg.turnId === keepFromTurnId)
214
+ if (splitIdx < 0) splitIdx = history.length - keepRecent
215
+ } else {
216
+ // Fallback: not enough turns, use message count
217
+ splitIdx = history.length - keepRecent
218
+ }
219
+ const toSummarize = history.slice(0, splitIdx)
220
+ const kept = history.slice(splitIdx)
221
+
222
+ // Layer 1: prune large tool outputs before sending to LLM
223
+ const pruned = pruneForSummary(toSummarize)
224
+ const summaryPrompt = pruned.map((m) => {
225
+ const content = m.content
226
+ if (Array.isArray(content)) {
227
+ return `[${m.role}]: ${content.map((b) => {
228
+ if (b.type === "text") return b.text || ""
229
+ if (b.type === "tool_use") return `[tool_use:${b.name}(${JSON.stringify(b.input || {}).slice(0, 120)})]`
230
+ if (b.type === "tool_result") return `[tool_result:${b.is_error ? "ERROR " : ""}${b.content || ""}]`
231
+ return ""
232
+ }).filter(Boolean).join("\n")}`
233
+ }
234
+ return `[${m.role}]: ${content}`
235
+ }).join("\n\n")
236
+
237
+ const hookPayload = await HookBus.sessionCompacting({
238
+ sessionId,
239
+ messageCount: history.length,
240
+ summarizeCount: toSummarize.length,
241
+ keepCount: kept.length
242
+ })
243
+ if (hookPayload?.skip) return { compacted: false, reason: "skipped by hook" }
244
+
245
+ let summaryText
246
+ let compactionUsage = null
247
+ try {
248
+ const response = await requestProvider({
249
+ configState,
250
+ providerType,
251
+ model,
252
+ system: COMPACTION_SYSTEM,
253
+ messages: [{ role: "user", content: summaryPrompt }],
254
+ tools: [],
255
+ baseUrl,
256
+ apiKeyEnv
257
+ })
258
+ summaryText = (response.text || "").trim()
259
+ compactionUsage = response.usage || null
260
+ } catch (error) {
261
+ return { compacted: false, reason: `compaction LLM call failed: ${error.message}` }
262
+ }
263
+
264
+ if (!summaryText) return { compacted: false, reason: "empty summary from LLM" }
265
+
266
+ // Replace all messages with: [summary] + [kept recent messages]
267
+ const summaryMessage = {
268
+ role: "user",
269
+ content: `<compaction-summary>\n${summaryText}\n</compaction-summary>`
270
+ }
271
+ await replaceMessages(sessionId, [summaryMessage, ...kept])
272
+
273
+ // Record compaction LLM usage so it's not "invisible"
274
+ if (compactionUsage) {
275
+ try {
276
+ const { pricing } = await loadPricing(configState)
277
+ const { amount } = calculateCost(pricing, model, compactionUsage)
278
+ await recordTurn({ sessionId, usage: compactionUsage, cost: amount })
279
+ } catch { /* best-effort */ }
280
+ }
281
+
282
+ await saveCheckpoint(sessionId, {
283
+ kind: "compaction",
284
+ iteration: Date.now(),
285
+ compactedAt: Date.now(),
286
+ summarizeCount: toSummarize.length,
287
+ keepCount: kept.length,
288
+ summaryVersion: 1,
289
+ summaryLength: summaryText.length
290
+ })
291
+
292
+ return {
293
+ compacted: true,
294
+ summarizedCount: toSummarize.length,
295
+ keptCount: kept.length,
296
+ summaryLength: summaryText.length
297
+ }
298
+ }