mulby-cli 1.1.5
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.
- package/PLUGIN_DEVELOP_PROMPT.md +1164 -0
- package/README.md +852 -0
- package/assets/default-icon.png +0 -0
- package/dist/commands/ai-session.js +44 -0
- package/dist/commands/build.js +111 -0
- package/dist/commands/config-ai.js +291 -0
- package/dist/commands/config.js +53 -0
- package/dist/commands/create/ai-create.js +183 -0
- package/dist/commands/create/assets.js +53 -0
- package/dist/commands/create/basic.js +72 -0
- package/dist/commands/create/index.js +73 -0
- package/dist/commands/create/react.js +136 -0
- package/dist/commands/create/templates/basic.js +383 -0
- package/dist/commands/create/templates/react/backend.js +72 -0
- package/dist/commands/create/templates/react/config.js +166 -0
- package/dist/commands/create/templates/react/docs.js +78 -0
- package/dist/commands/create/templates/react/hooks.js +469 -0
- package/dist/commands/create/templates/react/index.js +41 -0
- package/dist/commands/create/templates/react/types.js +1228 -0
- package/dist/commands/create/templates/react/ui.js +528 -0
- package/dist/commands/create/templates/react.js +1888 -0
- package/dist/commands/dev.js +141 -0
- package/dist/commands/pack.js +160 -0
- package/dist/commands/resume.js +97 -0
- package/dist/commands/test-ui.js +50 -0
- package/dist/index.js +71 -0
- package/dist/services/ai/PLUGIN_API.md +1102 -0
- package/dist/services/ai/PLUGIN_DEVELOP_PROMPT.md +1164 -0
- package/dist/services/ai/context-manager.js +639 -0
- package/dist/services/ai/index.js +88 -0
- package/dist/services/ai/knowledge.js +52 -0
- package/dist/services/ai/prompts.js +114 -0
- package/dist/services/ai/providers/base.js +38 -0
- package/dist/services/ai/providers/claude.js +284 -0
- package/dist/services/ai/providers/deepseek.js +28 -0
- package/dist/services/ai/providers/gemini.js +191 -0
- package/dist/services/ai/providers/glm.js +31 -0
- package/dist/services/ai/providers/minimax.js +27 -0
- package/dist/services/ai/providers/openai.js +177 -0
- package/dist/services/ai/tools.js +204 -0
- package/dist/services/ai-generator.js +968 -0
- package/dist/services/config-manager.js +117 -0
- package/dist/services/dependency-manager.js +236 -0
- package/dist/services/file-writer.js +66 -0
- package/dist/services/plan-adapter.js +244 -0
- package/dist/services/plan-command-handler.js +172 -0
- package/dist/services/plan-manager.js +502 -0
- package/dist/services/session-manager.js +113 -0
- package/dist/services/task-analyzer.js +136 -0
- package/dist/services/tui/index.js +57 -0
- package/dist/services/tui/store.js +123 -0
- package/dist/types/ai.js +172 -0
- package/dist/types/plan.js +2 -0
- package/dist/ui/Terminal.js +56 -0
- package/dist/ui/components/InputArea.js +176 -0
- package/dist/ui/components/LogArea.js +19 -0
- package/dist/ui/components/PlanPanel.js +69 -0
- package/dist/ui/components/SelectArea.js +13 -0
- package/package.json +45 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ContextManager = void 0;
|
|
4
|
+
const js_tiktoken_1 = require("js-tiktoken");
|
|
5
|
+
class ContextManager {
|
|
6
|
+
/**
|
|
7
|
+
* Initialize the tiktoken encoder (lazy loading)
|
|
8
|
+
*/
|
|
9
|
+
static getEncoder() {
|
|
10
|
+
if (!this.encoder) {
|
|
11
|
+
try {
|
|
12
|
+
// Use cl100k_base encoding (used by GPT-4, Claude, etc.)
|
|
13
|
+
this.encoder = (0, js_tiktoken_1.encodingForModel)('gpt-4');
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
console.warn('Failed to initialize tiktoken encoder, falling back to heuristic:', error);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return this.encoder;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Estimates the token count of the conversation history using tiktoken.
|
|
23
|
+
* Falls back to character-based estimation if tiktoken fails.
|
|
24
|
+
*/
|
|
25
|
+
static estimateTokenCount(messages) {
|
|
26
|
+
const encoder = this.getEncoder();
|
|
27
|
+
if (encoder) {
|
|
28
|
+
try {
|
|
29
|
+
// Convert messages to JSON string for accurate token counting
|
|
30
|
+
const text = JSON.stringify(messages);
|
|
31
|
+
const tokens = encoder.encode(text);
|
|
32
|
+
return tokens.length;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.warn('Tiktoken encoding failed, using fallback:', error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Fallback to character-based estimation
|
|
39
|
+
let totalChars = 0;
|
|
40
|
+
for (const msg of messages) {
|
|
41
|
+
if (msg.content) {
|
|
42
|
+
if (typeof msg.content === 'string') {
|
|
43
|
+
totalChars += msg.content.length;
|
|
44
|
+
}
|
|
45
|
+
else if (Array.isArray(msg.content)) {
|
|
46
|
+
// Handle content blocks array
|
|
47
|
+
for (const block of msg.content) {
|
|
48
|
+
if (block.type === 'text' && block.text) {
|
|
49
|
+
totalChars += block.text.length;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
totalChars += JSON.stringify(block).length;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (msg.tool_calls) {
|
|
58
|
+
for (const call of msg.tool_calls) {
|
|
59
|
+
totalChars += JSON.stringify(call).length;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return Math.ceil(totalChars / this.CHARS_PER_TOKEN);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Compresses the conversation history using score-based intelligent retention.
|
|
67
|
+
* @param messages - The conversation history
|
|
68
|
+
* @param targetTokens - Target token count after compression (default: 8000)
|
|
69
|
+
* @param summarizer - Function to generate summary of compressed messages
|
|
70
|
+
* @param useScoring - Whether to use scoring mechanism (default: true)
|
|
71
|
+
*/
|
|
72
|
+
static async compressHistory(messages, targetTokens = 8000, summarizer, useScoring = true) {
|
|
73
|
+
const totalTokens = this.estimateTokenCount(messages);
|
|
74
|
+
// No compression needed
|
|
75
|
+
if (totalTokens <= targetTokens) {
|
|
76
|
+
return messages;
|
|
77
|
+
}
|
|
78
|
+
const systemMessage = messages[0].role === 'system' ? messages[0] : null;
|
|
79
|
+
const startIndex = systemMessage ? 1 : 0;
|
|
80
|
+
// Get indices of messages to keep (relative to full messages array)
|
|
81
|
+
let keptIndices;
|
|
82
|
+
if (useScoring) {
|
|
83
|
+
// Use score-based selection - returns indices relative to messagesToScore
|
|
84
|
+
const messagesToScore = messages.slice(startIndex);
|
|
85
|
+
const relativeIndices = this.selectMessageIndicesByScore(messagesToScore, targetTokens * 0.7);
|
|
86
|
+
// Convert to absolute indices
|
|
87
|
+
keptIndices = new Set([...relativeIndices].map(i => i + startIndex));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Fallback: time-based retention (old behavior)
|
|
91
|
+
const keepBudget = Math.floor(targetTokens * 0.7);
|
|
92
|
+
keptIndices = new Set();
|
|
93
|
+
let currentTokens = 0;
|
|
94
|
+
// Retain messages from the end, up to the budget
|
|
95
|
+
for (let i = messages.length - 1; i >= startIndex; i--) {
|
|
96
|
+
const msg = messages[i];
|
|
97
|
+
const msgTokens = this.estimateTokenCount([msg]);
|
|
98
|
+
if (currentTokens + msgTokens < keepBudget) {
|
|
99
|
+
keptIndices.add(i);
|
|
100
|
+
currentTokens += msgTokens;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Build kept and toCompress arrays based on indices
|
|
108
|
+
let kept = [];
|
|
109
|
+
let toCompress = [];
|
|
110
|
+
for (let i = startIndex; i < messages.length; i++) {
|
|
111
|
+
if (keptIndices.has(i)) {
|
|
112
|
+
kept.push(messages[i]);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
toCompress.push(messages[i]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Ensure we don't cut in the middle of a tool chain (applies to both strategies)
|
|
119
|
+
kept = this.ensureCompleteToolChains(kept);
|
|
120
|
+
if (toCompress.length === 0) {
|
|
121
|
+
return systemMessage ? [systemMessage, ...kept] : kept;
|
|
122
|
+
}
|
|
123
|
+
console.log(`Compressing ${toCompress.length} messages (keeping ${kept.length} with ${useScoring ? 'scoring' : 'time-based'} strategy)...`);
|
|
124
|
+
// Prune large tool outputs before summarizing
|
|
125
|
+
const prunedMessages = toCompress.map(msg => this.pruneToolOutput(msg));
|
|
126
|
+
// Convert to text for summarization
|
|
127
|
+
const textToSummarize = this.messagesToText(prunedMessages);
|
|
128
|
+
try {
|
|
129
|
+
const summary = await summarizer(textToSummarize);
|
|
130
|
+
// Use content blocks with cache_control for Prompt Caching
|
|
131
|
+
const summaryMessage = {
|
|
132
|
+
role: 'user',
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: `[Previous Context Summary]\n${summary}`,
|
|
137
|
+
cache_control: { type: 'ephemeral' }
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
};
|
|
141
|
+
const newHistory = [];
|
|
142
|
+
if (systemMessage)
|
|
143
|
+
newHistory.push(systemMessage);
|
|
144
|
+
newHistory.push(summaryMessage);
|
|
145
|
+
newHistory.push(...kept);
|
|
146
|
+
return newHistory;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
console.error('Failed to compress history:', error);
|
|
150
|
+
return messages; // Return original on failure
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Ensures that retained messages don't have broken tool chains.
|
|
155
|
+
* - Removes orphaned tool messages (no corresponding assistant)
|
|
156
|
+
* - Removes assistant messages with tool_calls that have no responses
|
|
157
|
+
* - Handles both leading/trailing and middle incomplete chains
|
|
158
|
+
* - SAFETY: Always keeps at least MIN_KEEP_MESSAGES to prevent empty arrays
|
|
159
|
+
*/
|
|
160
|
+
static ensureCompleteToolChains(messages) {
|
|
161
|
+
if (messages.length === 0)
|
|
162
|
+
return messages;
|
|
163
|
+
const MIN_KEEP_MESSAGES = 3; // Safety minimum to prevent empty results
|
|
164
|
+
// 1. Build a set of all tool_call IDs that have responses
|
|
165
|
+
const respondedToolCallIds = new Set();
|
|
166
|
+
for (const msg of messages) {
|
|
167
|
+
if (msg.role === 'tool' && msg.tool_call_id) {
|
|
168
|
+
respondedToolCallIds.add(msg.tool_call_id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// 2. Build a set of all valid tool_call IDs from assistant messages
|
|
172
|
+
// that have ALL their tool_calls responded to
|
|
173
|
+
const validToolCallIds = new Set();
|
|
174
|
+
const assistantIndicesToRemove = new Set();
|
|
175
|
+
for (let i = 0; i < messages.length; i++) {
|
|
176
|
+
const msg = messages[i];
|
|
177
|
+
if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
|
|
178
|
+
// Check if ALL tool_calls have responses
|
|
179
|
+
const allResponded = msg.tool_calls.every(call => call.id && respondedToolCallIds.has(call.id));
|
|
180
|
+
if (allResponded) {
|
|
181
|
+
// All tool_calls have responses - mark them as valid
|
|
182
|
+
for (const call of msg.tool_calls) {
|
|
183
|
+
if (call.id) {
|
|
184
|
+
validToolCallIds.add(call.id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
// Some tool_calls don't have responses - remove this assistant message
|
|
190
|
+
assistantIndicesToRemove.add(i);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// 3. Filter messages
|
|
195
|
+
const filtered = messages.filter((msg, index) => {
|
|
196
|
+
// Remove assistant messages with incomplete tool chains
|
|
197
|
+
if (assistantIndicesToRemove.has(index)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
// Remove orphaned tool messages
|
|
201
|
+
if (msg.role === 'tool') {
|
|
202
|
+
const toolCallId = msg.tool_call_id;
|
|
203
|
+
if (!toolCallId || !validToolCallIds.has(toolCallId)) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
// 4. Remove leading tool messages (in case any slipped through)
|
|
210
|
+
while (filtered.length > 0 && filtered[0].role === 'tool') {
|
|
211
|
+
filtered.shift();
|
|
212
|
+
}
|
|
213
|
+
// 5. Remove trailing assistant messages with tool calls (incomplete chains)
|
|
214
|
+
while (filtered.length > 0) {
|
|
215
|
+
const lastMsg = filtered[filtered.length - 1];
|
|
216
|
+
if (lastMsg?.role === 'assistant' && lastMsg.tool_calls?.length) {
|
|
217
|
+
filtered.pop();
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// SAFETY: If we filtered too aggressively, keep at least the last few non-tool messages
|
|
224
|
+
if (filtered.length === 0 && messages.length > 0) {
|
|
225
|
+
console.warn(`[ContextManager] ensureCompleteToolChains filtered all messages! Keeping last ${MIN_KEEP_MESSAGES} safe messages.`);
|
|
226
|
+
const safeMsgs = [];
|
|
227
|
+
for (let i = messages.length - 1; i >= 0 && safeMsgs.length < MIN_KEEP_MESSAGES; i--) {
|
|
228
|
+
const msg = messages[i];
|
|
229
|
+
// Only keep user/assistant messages without tool_calls
|
|
230
|
+
if (msg.role === 'user' || (msg.role === 'assistant' && !msg.tool_calls?.length)) {
|
|
231
|
+
safeMsgs.unshift(msg);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Last resort: if still empty, keep the very last message regardless of type
|
|
235
|
+
if (safeMsgs.length === 0) {
|
|
236
|
+
console.warn(`[ContextManager] No safe messages found, keeping last message as fallback.`);
|
|
237
|
+
safeMsgs.push(messages[messages.length - 1]);
|
|
238
|
+
}
|
|
239
|
+
return safeMsgs;
|
|
240
|
+
}
|
|
241
|
+
return filtered;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Converts messages to text format for summarization.
|
|
245
|
+
*/
|
|
246
|
+
static messagesToText(messages) {
|
|
247
|
+
return messages.map(m => {
|
|
248
|
+
let content = '';
|
|
249
|
+
if (typeof m.content === 'string') {
|
|
250
|
+
content = m.content;
|
|
251
|
+
}
|
|
252
|
+
else if (Array.isArray(m.content)) {
|
|
253
|
+
// Extract text from content blocks
|
|
254
|
+
content = m.content
|
|
255
|
+
.filter(block => block.type === 'text' && block.text)
|
|
256
|
+
.map(block => block.text)
|
|
257
|
+
.join(' ');
|
|
258
|
+
}
|
|
259
|
+
if (m.tool_calls?.length) {
|
|
260
|
+
content += ` [Tool calls: ${m.tool_calls.length}]`;
|
|
261
|
+
}
|
|
262
|
+
return `${m.role.toUpperCase()}: ${content}`;
|
|
263
|
+
}).join('\n\n');
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Intelligently prunes tool outputs based on content type.
|
|
267
|
+
* Implements smart "Read-and-Forget" strategy.
|
|
268
|
+
*/
|
|
269
|
+
static pruneToolOutput(msg) {
|
|
270
|
+
if (msg.role !== 'tool' || !msg.content) {
|
|
271
|
+
return msg;
|
|
272
|
+
}
|
|
273
|
+
// Only handle string content for now
|
|
274
|
+
if (typeof msg.content !== 'string') {
|
|
275
|
+
return msg;
|
|
276
|
+
}
|
|
277
|
+
const content = msg.content;
|
|
278
|
+
const length = content.length;
|
|
279
|
+
// Short content - keep as is
|
|
280
|
+
if (length <= 1000) {
|
|
281
|
+
return msg;
|
|
282
|
+
}
|
|
283
|
+
// Error messages - always keep complete
|
|
284
|
+
if (content.includes('Error:') || content.includes('错误') ||
|
|
285
|
+
content.includes('Exception') || content.includes('Failed')) {
|
|
286
|
+
return msg;
|
|
287
|
+
}
|
|
288
|
+
const toolName = msg.name || msg.tool_call_id || '';
|
|
289
|
+
// File read operations - keep head and tail
|
|
290
|
+
if (toolName.includes('read') || toolName.includes('Read') ||
|
|
291
|
+
content.includes('```') || /^\s*\d+\s*→/.test(content)) {
|
|
292
|
+
const head = content.slice(0, 300);
|
|
293
|
+
const tail = content.slice(-300);
|
|
294
|
+
return {
|
|
295
|
+
...msg,
|
|
296
|
+
content: `${head}\n\n[... ${length - 600} chars omitted ...]\n\n${tail}`
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// Search/grep results - keep match lines
|
|
300
|
+
if (toolName.includes('search') || toolName.includes('grep') ||
|
|
301
|
+
toolName.includes('Grep') || toolName.includes('find')) {
|
|
302
|
+
const lines = content.split('\n');
|
|
303
|
+
const matchLines = lines
|
|
304
|
+
.filter((line) => line.includes(':') || line.includes('match') || line.includes('→'))
|
|
305
|
+
.slice(0, 30);
|
|
306
|
+
if (matchLines.length > 0) {
|
|
307
|
+
return {
|
|
308
|
+
...msg,
|
|
309
|
+
content: `${matchLines.join('\n')}\n[Total: ${lines.length} lines, showing first 30 matches]`
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// List operations - keep summary
|
|
314
|
+
if (toolName.includes('list') || toolName.includes('ls') ||
|
|
315
|
+
content.match(/^[\w\-\.]+\s+[\w\-\.]+\s+\d+/m)) {
|
|
316
|
+
const lines = content.split('\n').slice(0, 20);
|
|
317
|
+
return {
|
|
318
|
+
...msg,
|
|
319
|
+
content: `${lines.join('\n')}\n[... ${content.split('\n').length - 20} more items]`
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// Default: keep first 500 chars with context
|
|
323
|
+
return {
|
|
324
|
+
...msg,
|
|
325
|
+
content: `${content.slice(0, 500)}\n\n[Tool output truncated: ${length} chars total]`
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Light compression: only prune tool outputs without summarization.
|
|
330
|
+
*/
|
|
331
|
+
static lightCompress(messages) {
|
|
332
|
+
return messages.map(msg => this.pruneToolOutput(msg));
|
|
333
|
+
}
|
|
334
|
+
// ==================== Message Scoring System ====================
|
|
335
|
+
/**
|
|
336
|
+
* Calculate semantic importance score based on content features
|
|
337
|
+
*/
|
|
338
|
+
static calculateSemanticImportance(msg, config = this.DEFAULT_SCORING_CONFIG) {
|
|
339
|
+
let score = 0;
|
|
340
|
+
// Extract text content
|
|
341
|
+
let content = '';
|
|
342
|
+
if (typeof msg.content === 'string') {
|
|
343
|
+
content = msg.content;
|
|
344
|
+
}
|
|
345
|
+
else if (Array.isArray(msg.content)) {
|
|
346
|
+
content = msg.content
|
|
347
|
+
.filter(block => block.type === 'text' && block.text)
|
|
348
|
+
.map(block => block.text)
|
|
349
|
+
.join(' ');
|
|
350
|
+
}
|
|
351
|
+
if (!content)
|
|
352
|
+
return 0;
|
|
353
|
+
const lowerContent = content.toLowerCase();
|
|
354
|
+
// Check for errors/exceptions (+15)
|
|
355
|
+
if (config.semanticKeywords.errors.some(kw => lowerContent.includes(kw.toLowerCase()))) {
|
|
356
|
+
score += 15;
|
|
357
|
+
}
|
|
358
|
+
// Check for decision keywords (+10)
|
|
359
|
+
if (config.semanticKeywords.decisions.some(kw => lowerContent.includes(kw.toLowerCase()))) {
|
|
360
|
+
score += 10;
|
|
361
|
+
}
|
|
362
|
+
// Check for file operations (+8)
|
|
363
|
+
if (config.semanticKeywords.fileOps.some(kw => lowerContent.includes(kw.toLowerCase()))) {
|
|
364
|
+
score += 8;
|
|
365
|
+
}
|
|
366
|
+
// Check for code blocks (+7)
|
|
367
|
+
if (config.semanticKeywords.codeBlocks.some(kw => content.includes(kw))) {
|
|
368
|
+
score += 7;
|
|
369
|
+
}
|
|
370
|
+
// Check for questions (+6)
|
|
371
|
+
if (config.semanticKeywords.questions.some(kw => lowerContent.includes(kw.toLowerCase()))) {
|
|
372
|
+
score += 6;
|
|
373
|
+
}
|
|
374
|
+
// Check for file paths (+5)
|
|
375
|
+
if (config.semanticKeywords.filePaths.some(ext => content.includes(ext))) {
|
|
376
|
+
score += 5;
|
|
377
|
+
}
|
|
378
|
+
// Check for tool calls (+5)
|
|
379
|
+
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
380
|
+
score += 5;
|
|
381
|
+
}
|
|
382
|
+
// Check for confirmations/summaries (+4)
|
|
383
|
+
if (config.semanticKeywords.confirmations.some(kw => lowerContent.includes(kw.toLowerCase()))) {
|
|
384
|
+
score += 4;
|
|
385
|
+
}
|
|
386
|
+
return score;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Calculate context dependency score based on message relationships
|
|
390
|
+
*/
|
|
391
|
+
static calculateContextDependency(msg, index, allMessages) {
|
|
392
|
+
let score = 0;
|
|
393
|
+
// Tool chain integrity
|
|
394
|
+
if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
|
|
395
|
+
score += 15; // Tool call initiator
|
|
396
|
+
}
|
|
397
|
+
if (msg.role === 'tool') {
|
|
398
|
+
// Check if this is part of a tool chain
|
|
399
|
+
const prevMsg = index > 0 ? allMessages[index - 1] : null;
|
|
400
|
+
if (prevMsg?.role === 'assistant' && prevMsg.tool_calls) {
|
|
401
|
+
const isPartOfChain = prevMsg.tool_calls.some(tc => tc.id === msg.tool_call_id);
|
|
402
|
+
if (isPartOfChain) {
|
|
403
|
+
score += 15; // Part of tool chain
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Reference relationships
|
|
408
|
+
let content = '';
|
|
409
|
+
if (typeof msg.content === 'string') {
|
|
410
|
+
content = msg.content;
|
|
411
|
+
}
|
|
412
|
+
else if (Array.isArray(msg.content)) {
|
|
413
|
+
content = msg.content
|
|
414
|
+
.filter(block => block.type === 'text' && block.text)
|
|
415
|
+
.map(block => block.text)
|
|
416
|
+
.join(' ');
|
|
417
|
+
}
|
|
418
|
+
const lowerContent = content.toLowerCase();
|
|
419
|
+
const referenceKeywords = ['上面', '刚才', '之前提到', 'above', 'earlier', 'previously mentioned'];
|
|
420
|
+
if (referenceKeywords.some(kw => lowerContent.includes(kw))) {
|
|
421
|
+
score += 10; // Contains references
|
|
422
|
+
}
|
|
423
|
+
// User-Assistant pairing
|
|
424
|
+
if (msg.role === 'user') {
|
|
425
|
+
const nextMsg = index < allMessages.length - 1 ? allMessages[index + 1] : null;
|
|
426
|
+
if (nextMsg?.role === 'assistant') {
|
|
427
|
+
score += 8; // Part of Q&A pair
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Error-fix chain detection
|
|
431
|
+
if (lowerContent.includes('error') || lowerContent.includes('错误')) {
|
|
432
|
+
// Check if followed by fix attempts
|
|
433
|
+
for (let i = index + 1; i < Math.min(index + 3, allMessages.length); i++) {
|
|
434
|
+
const futureMsg = allMessages[i];
|
|
435
|
+
const futureContent = typeof futureMsg.content === 'string' ? futureMsg.content : '';
|
|
436
|
+
if (futureContent.toLowerCase().includes('fix') ||
|
|
437
|
+
futureContent.toLowerCase().includes('修复') ||
|
|
438
|
+
futureContent.toLowerCase().includes('解决')) {
|
|
439
|
+
score += 12; // Part of error-fix chain
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return score;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Calculate comprehensive score for a message
|
|
448
|
+
* @param precomputedTokens - Pre-computed token count to avoid redundant calculation
|
|
449
|
+
*/
|
|
450
|
+
static scoreMessage(msg, index, allMessages, precomputedTokens, config = this.DEFAULT_SCORING_CONFIG) {
|
|
451
|
+
let score = 0;
|
|
452
|
+
// 1. Role weight
|
|
453
|
+
score += config.roleWeights[msg.role] || 0;
|
|
454
|
+
// 2. Semantic importance
|
|
455
|
+
score += this.calculateSemanticImportance(msg, config);
|
|
456
|
+
// 3. Context dependency
|
|
457
|
+
score += this.calculateContextDependency(msg, index, allMessages);
|
|
458
|
+
// 4. Length penalty (use pre-computed tokens)
|
|
459
|
+
if (config.lengthPenaltyEnabled && precomputedTokens > 0) {
|
|
460
|
+
const lengthPenalty = Math.min(0, -Math.log10(precomputedTokens / 100));
|
|
461
|
+
score += lengthPenalty;
|
|
462
|
+
}
|
|
463
|
+
// 5. Temporal decay
|
|
464
|
+
const age = allMessages.length - 1 - index;
|
|
465
|
+
const decayFactor = Math.exp(-config.decayRate * age);
|
|
466
|
+
return score * decayFactor;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Group messages into tool chains and standalone messages.
|
|
470
|
+
* Tool chains (assistant with tool_calls + corresponding tool responses) are grouped together
|
|
471
|
+
* so they can be selected or discarded as a unit.
|
|
472
|
+
*/
|
|
473
|
+
static groupMessagesWithToolChains(scored) {
|
|
474
|
+
const groups = [];
|
|
475
|
+
const usedIndices = new Set();
|
|
476
|
+
// First pass: identify tool chains
|
|
477
|
+
for (let i = 0; i < scored.length; i++) {
|
|
478
|
+
const current = scored[i];
|
|
479
|
+
if (current.message.role === 'assistant' && current.message.tool_calls?.length) {
|
|
480
|
+
const toolCallIds = new Set(current.message.tool_calls.map(tc => tc.id));
|
|
481
|
+
const chainMessages = [current];
|
|
482
|
+
// Find all corresponding tool responses
|
|
483
|
+
for (let j = i + 1; j < scored.length; j++) {
|
|
484
|
+
const next = scored[j];
|
|
485
|
+
if (next.message.role === 'tool' &&
|
|
486
|
+
next.message.tool_call_id &&
|
|
487
|
+
toolCallIds.has(next.message.tool_call_id)) {
|
|
488
|
+
chainMessages.push(next);
|
|
489
|
+
}
|
|
490
|
+
// Stop if we hit another assistant message (new potential chain)
|
|
491
|
+
if (next.message.role === 'assistant') {
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Mark all indices as used
|
|
496
|
+
for (const msg of chainMessages) {
|
|
497
|
+
usedIndices.add(msg.index);
|
|
498
|
+
}
|
|
499
|
+
// Calculate group totals
|
|
500
|
+
const totalScore = chainMessages.reduce((sum, m) => sum + m.score, 0);
|
|
501
|
+
const totalTokens = chainMessages.reduce((sum, m) => sum + m.tokens, 0);
|
|
502
|
+
groups.push({
|
|
503
|
+
messages: chainMessages,
|
|
504
|
+
totalScore,
|
|
505
|
+
totalTokens,
|
|
506
|
+
startIndex: current.index
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Second pass: add standalone messages (not part of any tool chain)
|
|
511
|
+
for (const msg of scored) {
|
|
512
|
+
if (!usedIndices.has(msg.index)) {
|
|
513
|
+
groups.push({
|
|
514
|
+
messages: [msg],
|
|
515
|
+
totalScore: msg.score,
|
|
516
|
+
totalTokens: msg.tokens,
|
|
517
|
+
startIndex: msg.index
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Sort groups by their start index to maintain order
|
|
522
|
+
groups.sort((a, b) => a.startIndex - b.startIndex);
|
|
523
|
+
return groups;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Select message indices to keep based on scores and token budget.
|
|
527
|
+
* Tool chains are treated as atomic units - they are either kept entirely or discarded entirely.
|
|
528
|
+
* @returns Set of message indices to keep
|
|
529
|
+
*/
|
|
530
|
+
static selectMessageIndicesByScore(messages, targetTokens, config = this.DEFAULT_SCORING_CONFIG) {
|
|
531
|
+
if (messages.length === 0)
|
|
532
|
+
return new Set();
|
|
533
|
+
// 1. Pre-compute tokens for all messages (done once)
|
|
534
|
+
const tokenCounts = messages.map(msg => this.estimateTokenCount([msg]));
|
|
535
|
+
// 2. Calculate scores for all messages (using pre-computed tokens)
|
|
536
|
+
const scored = messages.map((msg, idx) => ({
|
|
537
|
+
message: msg,
|
|
538
|
+
score: this.scoreMessage(msg, idx, messages, tokenCounts[idx], config),
|
|
539
|
+
tokens: tokenCounts[idx],
|
|
540
|
+
index: idx
|
|
541
|
+
}));
|
|
542
|
+
// 3. Group messages (tool chains are grouped together)
|
|
543
|
+
const groups = this.groupMessagesWithToolChains(scored);
|
|
544
|
+
// 4. Identify forced keep groups (last N messages)
|
|
545
|
+
const forcedKeepGroupIndices = new Set();
|
|
546
|
+
const lastNIndices = new Set();
|
|
547
|
+
for (let i = Math.max(0, scored.length - config.forcedKeepLastN); i < scored.length; i++) {
|
|
548
|
+
lastNIndices.add(i);
|
|
549
|
+
}
|
|
550
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
551
|
+
const group = groups[gi];
|
|
552
|
+
// If any message in this group is in lastN, keep the whole group
|
|
553
|
+
if (group.messages.some(m => lastNIndices.has(m.index))) {
|
|
554
|
+
forcedKeepGroupIndices.add(gi);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// 5. Calculate forced tokens
|
|
558
|
+
let forcedTokens = 0;
|
|
559
|
+
for (const gi of forcedKeepGroupIndices) {
|
|
560
|
+
forcedTokens += groups[gi].totalTokens;
|
|
561
|
+
}
|
|
562
|
+
// 6. Remaining budget
|
|
563
|
+
const remainingBudget = targetTokens - forcedTokens;
|
|
564
|
+
// 7. If budget exhausted, return only forced indices
|
|
565
|
+
if (remainingBudget <= 0) {
|
|
566
|
+
const result = new Set();
|
|
567
|
+
for (const gi of forcedKeepGroupIndices) {
|
|
568
|
+
for (const m of groups[gi].messages) {
|
|
569
|
+
result.add(m.index);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return result;
|
|
573
|
+
}
|
|
574
|
+
// 8. Candidate groups (not forced)
|
|
575
|
+
const candidateGroups = [];
|
|
576
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
577
|
+
if (!forcedKeepGroupIndices.has(gi)) {
|
|
578
|
+
candidateGroups.push({ group: groups[gi], index: gi });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// 9. Sort candidates by score density (score per token) for better efficiency
|
|
582
|
+
candidateGroups.sort((a, b) => {
|
|
583
|
+
const densityA = a.group.totalScore / a.group.totalTokens;
|
|
584
|
+
const densityB = b.group.totalScore / b.group.totalTokens;
|
|
585
|
+
return densityB - densityA; // Higher density first
|
|
586
|
+
});
|
|
587
|
+
// 10. Greedy selection
|
|
588
|
+
const selectedGroupIndices = new Set(forcedKeepGroupIndices);
|
|
589
|
+
let usedTokens = 0;
|
|
590
|
+
for (const { group, index } of candidateGroups) {
|
|
591
|
+
// Calculate average score per message for threshold check
|
|
592
|
+
const avgScore = group.totalScore / group.messages.length;
|
|
593
|
+
// Skip low-value groups
|
|
594
|
+
if (avgScore < config.minScoreThreshold) {
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (usedTokens + group.totalTokens <= remainingBudget) {
|
|
598
|
+
selectedGroupIndices.add(index);
|
|
599
|
+
usedTokens += group.totalTokens;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// 11. Collect all selected message indices
|
|
603
|
+
const result = new Set();
|
|
604
|
+
for (const gi of selectedGroupIndices) {
|
|
605
|
+
for (const m of groups[gi].messages) {
|
|
606
|
+
result.add(m.index);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return result;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
exports.ContextManager = ContextManager;
|
|
613
|
+
// Fallback: 4 characters per token as a rough heuristic
|
|
614
|
+
ContextManager.CHARS_PER_TOKEN = 4;
|
|
615
|
+
ContextManager.encoder = null;
|
|
616
|
+
/**
|
|
617
|
+
* Default scoring configuration
|
|
618
|
+
*/
|
|
619
|
+
ContextManager.DEFAULT_SCORING_CONFIG = {
|
|
620
|
+
roleWeights: {
|
|
621
|
+
user: 10,
|
|
622
|
+
assistant: 5,
|
|
623
|
+
tool: 3,
|
|
624
|
+
system: 15
|
|
625
|
+
},
|
|
626
|
+
semanticKeywords: {
|
|
627
|
+
errors: ['error', 'exception', 'failed', '错误', '失败', '异常'],
|
|
628
|
+
decisions: ['决定', '选择', '采用', 'decide', 'choose', 'use'],
|
|
629
|
+
fileOps: ['创建', '修改', '删除', 'create', 'modify', 'delete', 'update'],
|
|
630
|
+
questions: ['如何', '为什么', '怎么', 'how', 'why', 'what', '?', '?'],
|
|
631
|
+
confirmations: ['完成', '总结', '确认', 'done', 'complete', 'summary'],
|
|
632
|
+
codeBlocks: ['```'],
|
|
633
|
+
filePaths: ['.ts', '.js', '.tsx', '.jsx', '.py', '.java', '.go', '.rs']
|
|
634
|
+
},
|
|
635
|
+
decayRate: 0.1,
|
|
636
|
+
lengthPenaltyEnabled: true,
|
|
637
|
+
minScoreThreshold: 15,
|
|
638
|
+
forcedKeepLastN: 5
|
|
639
|
+
};
|