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.
Files changed (59) hide show
  1. package/PLUGIN_DEVELOP_PROMPT.md +1164 -0
  2. package/README.md +852 -0
  3. package/assets/default-icon.png +0 -0
  4. package/dist/commands/ai-session.js +44 -0
  5. package/dist/commands/build.js +111 -0
  6. package/dist/commands/config-ai.js +291 -0
  7. package/dist/commands/config.js +53 -0
  8. package/dist/commands/create/ai-create.js +183 -0
  9. package/dist/commands/create/assets.js +53 -0
  10. package/dist/commands/create/basic.js +72 -0
  11. package/dist/commands/create/index.js +73 -0
  12. package/dist/commands/create/react.js +136 -0
  13. package/dist/commands/create/templates/basic.js +383 -0
  14. package/dist/commands/create/templates/react/backend.js +72 -0
  15. package/dist/commands/create/templates/react/config.js +166 -0
  16. package/dist/commands/create/templates/react/docs.js +78 -0
  17. package/dist/commands/create/templates/react/hooks.js +469 -0
  18. package/dist/commands/create/templates/react/index.js +41 -0
  19. package/dist/commands/create/templates/react/types.js +1228 -0
  20. package/dist/commands/create/templates/react/ui.js +528 -0
  21. package/dist/commands/create/templates/react.js +1888 -0
  22. package/dist/commands/dev.js +141 -0
  23. package/dist/commands/pack.js +160 -0
  24. package/dist/commands/resume.js +97 -0
  25. package/dist/commands/test-ui.js +50 -0
  26. package/dist/index.js +71 -0
  27. package/dist/services/ai/PLUGIN_API.md +1102 -0
  28. package/dist/services/ai/PLUGIN_DEVELOP_PROMPT.md +1164 -0
  29. package/dist/services/ai/context-manager.js +639 -0
  30. package/dist/services/ai/index.js +88 -0
  31. package/dist/services/ai/knowledge.js +52 -0
  32. package/dist/services/ai/prompts.js +114 -0
  33. package/dist/services/ai/providers/base.js +38 -0
  34. package/dist/services/ai/providers/claude.js +284 -0
  35. package/dist/services/ai/providers/deepseek.js +28 -0
  36. package/dist/services/ai/providers/gemini.js +191 -0
  37. package/dist/services/ai/providers/glm.js +31 -0
  38. package/dist/services/ai/providers/minimax.js +27 -0
  39. package/dist/services/ai/providers/openai.js +177 -0
  40. package/dist/services/ai/tools.js +204 -0
  41. package/dist/services/ai-generator.js +968 -0
  42. package/dist/services/config-manager.js +117 -0
  43. package/dist/services/dependency-manager.js +236 -0
  44. package/dist/services/file-writer.js +66 -0
  45. package/dist/services/plan-adapter.js +244 -0
  46. package/dist/services/plan-command-handler.js +172 -0
  47. package/dist/services/plan-manager.js +502 -0
  48. package/dist/services/session-manager.js +113 -0
  49. package/dist/services/task-analyzer.js +136 -0
  50. package/dist/services/tui/index.js +57 -0
  51. package/dist/services/tui/store.js +123 -0
  52. package/dist/types/ai.js +172 -0
  53. package/dist/types/plan.js +2 -0
  54. package/dist/ui/Terminal.js +56 -0
  55. package/dist/ui/components/InputArea.js +176 -0
  56. package/dist/ui/components/LogArea.js +19 -0
  57. package/dist/ui/components/PlanPanel.js +69 -0
  58. package/dist/ui/components/SelectArea.js +13 -0
  59. 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
+ };