@kkelly-offical/kkcode 0.1.7 → 0.2.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 (163) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +452 -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/config/defaults.mjs +280 -260
  29. package/src/config/import-config.mjs +1 -1
  30. package/src/config/load-config.mjs +61 -4
  31. package/src/config/schema.mjs +591 -574
  32. package/src/context.mjs +4 -1
  33. package/src/core/constants.mjs +97 -91
  34. package/src/core/types.mjs +1 -1
  35. package/src/github/api.mjs +78 -78
  36. package/src/github/auth.mjs +294 -286
  37. package/src/github/flow.mjs +298 -298
  38. package/src/github/workspace.mjs +225 -212
  39. package/src/index.mjs +84 -82
  40. package/src/knowledge/frontend-aesthetics.txt +38 -38
  41. package/src/mcp/client-http.mjs +139 -141
  42. package/src/mcp/client-sse.mjs +297 -288
  43. package/src/mcp/client-stdio.mjs +534 -533
  44. package/src/mcp/constants.mjs +2 -2
  45. package/src/mcp/registry.mjs +498 -479
  46. package/src/mcp/stdio-framing.mjs +135 -133
  47. package/src/mcp/tool-result.mjs +24 -24
  48. package/src/observability/edit-diagnostics.mjs +449 -0
  49. package/src/observability/index.mjs +42 -42
  50. package/src/observability/metrics.mjs +165 -137
  51. package/src/observability/tracer.mjs +137 -137
  52. package/src/onboarding.mjs +209 -0
  53. package/src/orchestration/background-manager.mjs +567 -372
  54. package/src/orchestration/background-worker.mjs +419 -305
  55. package/src/orchestration/interruption-reason.mjs +21 -0
  56. package/src/orchestration/longagent-manager.mjs +197 -171
  57. package/src/orchestration/stage-scheduler.mjs +733 -728
  58. package/src/orchestration/subagent-router.mjs +7 -1
  59. package/src/orchestration/task-scheduler.mjs +219 -7
  60. package/src/permission/engine.mjs +1 -1
  61. package/src/permission/exec-policy.mjs +370 -370
  62. package/src/permission/file-edit-policy.mjs +108 -0
  63. package/src/permission/prompt.mjs +1 -1
  64. package/src/permission/rules.mjs +116 -7
  65. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  66. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  67. package/src/plugin/hook-bus.mjs +19 -5
  68. package/src/plugin/manifest-loader.mjs +222 -0
  69. package/src/provider/anthropic.mjs +396 -390
  70. package/src/provider/ollama.mjs +7 -1
  71. package/src/provider/openai.mjs +382 -340
  72. package/src/provider/retry-policy.mjs +74 -68
  73. package/src/provider/router.mjs +242 -241
  74. package/src/provider/sse.mjs +104 -104
  75. package/src/provider/wizard.mjs +556 -0
  76. package/src/repl/capability-facade.mjs +30 -0
  77. package/src/repl/command-surface.mjs +23 -0
  78. package/src/repl/controller-entry.mjs +40 -0
  79. package/src/repl/core-shell.mjs +208 -0
  80. package/src/repl/dialog-router.mjs +87 -0
  81. package/src/repl/input-engine.mjs +76 -0
  82. package/src/repl/keymap.mjs +7 -0
  83. package/src/repl/operator-surface.mjs +15 -0
  84. package/src/repl/permission-flow.mjs +49 -0
  85. package/src/repl/runtime-facade.mjs +36 -0
  86. package/src/repl/slash-router.mjs +62 -0
  87. package/src/repl/state-store.mjs +29 -0
  88. package/src/repl/turn-controller.mjs +58 -0
  89. package/src/repl/verification.mjs +23 -0
  90. package/src/repl.mjs +3368 -2981
  91. package/src/rules/load-rules.mjs +3 -3
  92. package/src/runtime.mjs +1 -1
  93. package/src/session/agent-transaction.mjs +86 -0
  94. package/src/session/checkpoint.mjs +302 -302
  95. package/src/session/compaction.mjs +298 -298
  96. package/src/session/engine.mjs +417 -232
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1097
  99. package/src/session/longagent-plan.mjs +376 -365
  100. package/src/session/longagent-project-memory.mjs +53 -53
  101. package/src/session/longagent-scaffold.mjs +291 -291
  102. package/src/session/longagent-task-bus.mjs +138 -54
  103. package/src/session/longagent-utils.mjs +828 -472
  104. package/src/session/longagent.mjs +911 -900
  105. package/src/session/loop.mjs +1005 -930
  106. package/src/session/prompt/agent.txt +25 -25
  107. package/src/session/prompt/anthropic.txt +150 -150
  108. package/src/session/prompt/beast.txt +1 -1
  109. package/src/session/prompt/plan.txt +31 -31
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +196 -195
  113. package/src/session/routing-observability.mjs +72 -0
  114. package/src/session/runtime-state.mjs +47 -0
  115. package/src/session/store.mjs +523 -519
  116. package/src/session/system-prompt.mjs +308 -273
  117. package/src/session/task-validator.mjs +267 -267
  118. package/src/session/usability-gates.mjs +2 -2
  119. package/src/skill/builtin/commit.mjs +64 -64
  120. package/src/skill/builtin/design.mjs +76 -76
  121. package/src/skill/generator.mjs +18 -2
  122. package/src/skill/registry.mjs +642 -390
  123. package/src/storage/audit-store.mjs +18 -11
  124. package/src/storage/event-log.mjs +7 -1
  125. package/src/storage/ghost-commit-store.mjs +243 -245
  126. package/src/storage/paths.mjs +13 -0
  127. package/src/theme/default-theme.mjs +1 -1
  128. package/src/theme/markdown.mjs +4 -0
  129. package/src/theme/schema.mjs +1 -1
  130. package/src/theme/status-bar.mjs +162 -158
  131. package/src/tool/audit-wrapper.mjs +18 -2
  132. package/src/tool/edit-transaction.mjs +23 -0
  133. package/src/tool/executor.mjs +26 -1
  134. package/src/tool/file-read-state.mjs +65 -0
  135. package/src/tool/git-auto.mjs +526 -526
  136. package/src/tool/git-full-auto.mjs +487 -478
  137. package/src/tool/mutation-guard.mjs +54 -0
  138. package/src/tool/prompt/edit.txt +3 -3
  139. package/src/tool/prompt/multiedit.txt +1 -0
  140. package/src/tool/prompt/notebookedit.txt +2 -1
  141. package/src/tool/prompt/patch.txt +25 -24
  142. package/src/tool/prompt/read.txt +3 -3
  143. package/src/tool/prompt/sysinfo.txt +29 -0
  144. package/src/tool/prompt/task.txt +66 -4
  145. package/src/tool/prompt/write.txt +2 -2
  146. package/src/tool/question-prompt.mjs +99 -93
  147. package/src/tool/registry.mjs +1701 -1343
  148. package/src/tool/task-tool.mjs +14 -6
  149. package/src/ui/activity-renderer.mjs +667 -664
  150. package/src/ui/repl-background-panel.mjs +7 -0
  151. package/src/ui/repl-capability-panel.mjs +9 -0
  152. package/src/ui/repl-dashboard.mjs +54 -4
  153. package/src/ui/repl-help.mjs +110 -0
  154. package/src/ui/repl-operator-panel.mjs +12 -0
  155. package/src/ui/repl-route-feedback.mjs +35 -0
  156. package/src/ui/repl-status-view.mjs +76 -0
  157. package/src/ui/repl-task-panel.mjs +5 -0
  158. package/src/ui/repl-transcript-panel.mjs +56 -0
  159. package/src/ui/repl-turn-summary.mjs +135 -0
  160. package/src/usage/pricing.mjs +122 -121
  161. package/src/usage/usage-meter.mjs +1 -0
  162. package/src/util/git.mjs +562 -519
  163. package/src/util/template.mjs +6 -1
@@ -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
+ }