nex-code 0.3.4 → 0.3.7
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/README.md +34 -12
- package/dist/bundle.js +505 -0
- package/dist/nex-code.js +485 -0
- package/package.json +8 -6
- package/bin/nex-code.js +0 -99
- package/cli/agent.js +0 -835
- package/cli/compactor.js +0 -85
- package/cli/context-engine.js +0 -507
- package/cli/context.js +0 -98
- package/cli/costs.js +0 -290
- package/cli/diff.js +0 -366
- package/cli/file-history.js +0 -94
- package/cli/format.js +0 -211
- package/cli/fuzzy-match.js +0 -270
- package/cli/git.js +0 -211
- package/cli/hooks.js +0 -173
- package/cli/index.js +0 -1289
- package/cli/mcp.js +0 -284
- package/cli/memory.js +0 -170
- package/cli/ollama.js +0 -130
- package/cli/permissions.js +0 -124
- package/cli/picker.js +0 -201
- package/cli/planner.js +0 -282
- package/cli/providers/anthropic.js +0 -333
- package/cli/providers/base.js +0 -116
- package/cli/providers/gemini.js +0 -239
- package/cli/providers/local.js +0 -249
- package/cli/providers/ollama.js +0 -228
- package/cli/providers/openai.js +0 -237
- package/cli/providers/registry.js +0 -454
- package/cli/render.js +0 -495
- package/cli/safety.js +0 -241
- package/cli/session.js +0 -133
- package/cli/skills.js +0 -412
- package/cli/spinner.js +0 -371
- package/cli/sub-agent.js +0 -425
- package/cli/tasks.js +0 -179
- package/cli/tool-tiers.js +0 -164
- package/cli/tool-validator.js +0 -138
- package/cli/tools.js +0 -1050
- package/cli/ui.js +0 -93
package/cli/compactor.js
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cli/compactor.js — LLM-Based Conversation Compacting
|
|
3
|
-
*
|
|
4
|
-
* Replaces old messages with a semantic summary via callChat(),
|
|
5
|
-
* preserving context while freeing tokens. Silent fallback on any error.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const { callChat } = require('./providers/registry');
|
|
9
|
-
const { estimateTokens } = require('./context-engine');
|
|
10
|
-
|
|
11
|
-
const COMPACTION_ENABLED = process.env.NEX_COMPACTION !== 'false';
|
|
12
|
-
const COMPACTION_MIN_MESSAGES = 6;
|
|
13
|
-
const COMPACTION_SUMMARY_BUDGET = 500;
|
|
14
|
-
|
|
15
|
-
const COMPACT_PROMPT = `Summarize this conversation history concisely. Focus on:
|
|
16
|
-
- What files were read, created, or modified
|
|
17
|
-
- Key decisions made and their rationale
|
|
18
|
-
- Current state of the task (what's done, what's pending)
|
|
19
|
-
- Any errors encountered and how they were resolved
|
|
20
|
-
Be factual and brief. Use bullet points. Max 300 words.`;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Compact old messages into a single summary message via LLM.
|
|
24
|
-
* @param {Array} messages - Old (non-compacted) messages to summarize
|
|
25
|
-
* @returns {Promise<{ message: object, tokensRemoved: number } | null>}
|
|
26
|
-
*/
|
|
27
|
-
async function compactMessages(messages) {
|
|
28
|
-
if (!COMPACTION_ENABLED || messages.length < COMPACTION_MIN_MESSAGES) return null;
|
|
29
|
-
|
|
30
|
-
const summaryMessages = [
|
|
31
|
-
{ role: 'system', content: COMPACT_PROMPT },
|
|
32
|
-
{ role: 'user', content: formatMessagesForSummary(messages) },
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const result = await callChat(summaryMessages, [], {
|
|
37
|
-
temperature: 0,
|
|
38
|
-
maxTokens: COMPACTION_SUMMARY_BUDGET,
|
|
39
|
-
});
|
|
40
|
-
const summary = (result.content || '').trim();
|
|
41
|
-
if (!summary) return null;
|
|
42
|
-
|
|
43
|
-
const originalTokens = messages.reduce((sum, m) =>
|
|
44
|
-
sum + estimateTokens(m.content || '') + (m.tool_calls ? estimateTokens(JSON.stringify(m.tool_calls)) : 0), 0);
|
|
45
|
-
const summaryTokens = estimateTokens(summary);
|
|
46
|
-
|
|
47
|
-
if (summaryTokens >= originalTokens * 0.8) return null;
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
message: {
|
|
51
|
-
role: 'system',
|
|
52
|
-
content: `[Conversation Summary — ${messages.length} messages compacted]\n${summary}`,
|
|
53
|
-
_compacted: true,
|
|
54
|
-
_originalCount: messages.length,
|
|
55
|
-
},
|
|
56
|
-
tokensRemoved: originalTokens - summaryTokens,
|
|
57
|
-
};
|
|
58
|
-
} catch {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Format messages for the summary prompt input.
|
|
65
|
-
* Each message is truncated to 500 chars to control input budget.
|
|
66
|
-
*/
|
|
67
|
-
function formatMessagesForSummary(messages) {
|
|
68
|
-
return messages.map(m => {
|
|
69
|
-
const role = m.role === 'tool' ? 'tool_result' : m.role;
|
|
70
|
-
const content = (m.content || '').substring(0, 500);
|
|
71
|
-
if (m.tool_calls) {
|
|
72
|
-
const tools = m.tool_calls.map(tc => tc.function?.name).join(', ');
|
|
73
|
-
return `[${role}] ${content}\n tools: ${tools}`;
|
|
74
|
-
}
|
|
75
|
-
return `[${role}] ${content}`;
|
|
76
|
-
}).join('\n\n');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
module.exports = {
|
|
80
|
-
compactMessages,
|
|
81
|
-
formatMessagesForSummary,
|
|
82
|
-
COMPACTION_ENABLED,
|
|
83
|
-
COMPACTION_MIN_MESSAGES,
|
|
84
|
-
COMPACTION_SUMMARY_BUDGET,
|
|
85
|
-
};
|
package/cli/context-engine.js
DELETED
|
@@ -1,507 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cli/context-engine.js — Token Management + Context Compression
|
|
3
|
-
*
|
|
4
|
-
* Tracks token usage per model, auto-compresses conversation history
|
|
5
|
-
* when approaching context window limits, and provides smart file truncation.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const { getActiveModel } = require('./providers/registry');
|
|
9
|
-
|
|
10
|
-
// ─── Token Estimation ──────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
// Chars-per-token ratios vary by provider/model
|
|
13
|
-
const TOKEN_RATIOS = {
|
|
14
|
-
anthropic: 3.5,
|
|
15
|
-
openai: 4.0,
|
|
16
|
-
gemini: 4.0,
|
|
17
|
-
ollama: 4.0,
|
|
18
|
-
local: 4.0,
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Get chars-per-token ratio for current provider.
|
|
23
|
-
*/
|
|
24
|
-
function getTokenRatio() {
|
|
25
|
-
try {
|
|
26
|
-
const model = getActiveModel();
|
|
27
|
-
const provider = model?.provider || 'ollama';
|
|
28
|
-
return TOKEN_RATIOS[provider] || 4.0;
|
|
29
|
-
} catch {
|
|
30
|
-
return 4.0;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Estimate token count for a string.
|
|
36
|
-
* Uses provider-specific chars/token ratio for better accuracy.
|
|
37
|
-
*/
|
|
38
|
-
function estimateTokens(text) {
|
|
39
|
-
if (!text) return 0;
|
|
40
|
-
if (typeof text !== 'string') text = JSON.stringify(text);
|
|
41
|
-
return Math.ceil(text.length / getTokenRatio());
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Estimate token count for a single message (including role overhead).
|
|
46
|
-
* Each message has ~4 tokens of overhead (role, formatting).
|
|
47
|
-
*/
|
|
48
|
-
function estimateMessageTokens(msg) {
|
|
49
|
-
const MESSAGE_OVERHEAD = 4;
|
|
50
|
-
let tokens = MESSAGE_OVERHEAD;
|
|
51
|
-
|
|
52
|
-
if (msg.content) {
|
|
53
|
-
tokens += estimateTokens(msg.content);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (msg.tool_calls) {
|
|
57
|
-
for (const tc of msg.tool_calls) {
|
|
58
|
-
tokens += 4; // tool call overhead
|
|
59
|
-
tokens += estimateTokens(tc.function?.name || '');
|
|
60
|
-
const args = tc.function?.arguments;
|
|
61
|
-
if (typeof args === 'string') {
|
|
62
|
-
tokens += estimateTokens(args);
|
|
63
|
-
} else if (args) {
|
|
64
|
-
tokens += estimateTokens(JSON.stringify(args));
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return tokens;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Estimate total tokens for a message array.
|
|
74
|
-
*/
|
|
75
|
-
function estimateMessagesTokens(messages) {
|
|
76
|
-
let total = 0;
|
|
77
|
-
for (const msg of messages) {
|
|
78
|
-
total += estimateMessageTokens(msg);
|
|
79
|
-
}
|
|
80
|
-
return total;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Estimate tokens for tool definitions.
|
|
85
|
-
*/
|
|
86
|
-
function estimateToolsTokens(tools) {
|
|
87
|
-
if (!tools || tools.length === 0) return 0;
|
|
88
|
-
return estimateTokens(JSON.stringify(tools));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ─── Context Window ────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Get the context window size for the active model.
|
|
95
|
-
* Falls back to a conservative default if unknown.
|
|
96
|
-
*/
|
|
97
|
-
function getContextWindow() {
|
|
98
|
-
const model = getActiveModel();
|
|
99
|
-
return model?.contextWindow || 32768;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Get current token usage breakdown.
|
|
104
|
-
* @param {Array} messages - Current conversation messages
|
|
105
|
-
* @param {Array} tools - Tool definitions
|
|
106
|
-
* @returns {{ used: number, limit: number, percentage: number, breakdown: object }}
|
|
107
|
-
*/
|
|
108
|
-
function getUsage(messages, tools) {
|
|
109
|
-
const messageTokens = estimateMessagesTokens(messages);
|
|
110
|
-
const toolTokens = estimateToolsTokens(tools);
|
|
111
|
-
const used = messageTokens + toolTokens;
|
|
112
|
-
const limit = getContextWindow();
|
|
113
|
-
const percentage = limit > 0 ? (used / limit) * 100 : 0;
|
|
114
|
-
|
|
115
|
-
// Breakdown
|
|
116
|
-
let systemTokens = 0;
|
|
117
|
-
let conversationTokens = 0;
|
|
118
|
-
let toolResultTokens = 0;
|
|
119
|
-
|
|
120
|
-
for (const msg of messages) {
|
|
121
|
-
const t = estimateMessageTokens(msg);
|
|
122
|
-
if (msg.role === 'system') {
|
|
123
|
-
systemTokens += t;
|
|
124
|
-
} else if (msg.role === 'tool') {
|
|
125
|
-
toolResultTokens += t;
|
|
126
|
-
} else {
|
|
127
|
-
conversationTokens += t;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
used,
|
|
133
|
-
limit,
|
|
134
|
-
percentage: Math.round(percentage * 10) / 10,
|
|
135
|
-
breakdown: {
|
|
136
|
-
system: systemTokens,
|
|
137
|
-
conversation: conversationTokens,
|
|
138
|
-
toolResults: toolResultTokens,
|
|
139
|
-
toolDefinitions: toolTokens,
|
|
140
|
-
},
|
|
141
|
-
messageCount: messages.length,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ─── Auto-Compression ──────────────────────────────────────────
|
|
146
|
-
|
|
147
|
-
const COMPRESSION_THRESHOLD = 0.75; // Compress when >75% full
|
|
148
|
-
const SAFETY_MARGIN = 0.10; // 10% reserve for token estimation errors
|
|
149
|
-
const KEEP_RECENT = 10; // Always keep last N messages intact
|
|
150
|
-
const TRUNCATE_TOOL_RESULT = 200; // Truncate old tool results to N chars
|
|
151
|
-
const TRUNCATE_ASSISTANT = 500; // Truncate old assistant content to N chars
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Smart compression for tool result content.
|
|
155
|
-
* Preserves error messages, test summaries, and error traces at end of output.
|
|
156
|
-
*
|
|
157
|
-
* @param {string} content - Tool result text
|
|
158
|
-
* @param {number} maxChars - Character budget
|
|
159
|
-
* @returns {string} compressed content
|
|
160
|
-
*/
|
|
161
|
-
function compressToolResult(content, maxChars) {
|
|
162
|
-
if (!content || content.length <= maxChars) return content;
|
|
163
|
-
|
|
164
|
-
// Error/status messages get 3x budget — highest value for LLM recovery
|
|
165
|
-
const isError = /^(ERROR|EXIT|BLOCKED|CANCELLED)/i.test(content);
|
|
166
|
-
const budget = isError ? maxChars * 3 : maxChars;
|
|
167
|
-
if (content.length <= budget) return content;
|
|
168
|
-
|
|
169
|
-
const lines = content.split('\n');
|
|
170
|
-
|
|
171
|
-
// Short outputs (≤10 lines): character-based 60/40 head/tail split
|
|
172
|
-
if (lines.length <= 10) {
|
|
173
|
-
const headChars = Math.floor(budget * 0.6);
|
|
174
|
-
const tailChars = Math.floor(budget * 0.4);
|
|
175
|
-
const head = content.substring(0, headChars);
|
|
176
|
-
const tail = content.substring(content.length - tailChars);
|
|
177
|
-
return head + `\n...(${content.length} chars total)...\n` + tail;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Long outputs (>10 lines): line-based 40/40 head/tail split
|
|
181
|
-
const headCount = Math.floor(lines.length * 0.4);
|
|
182
|
-
const tailCount = Math.floor(lines.length * 0.4);
|
|
183
|
-
|
|
184
|
-
// Build head and tail within budget
|
|
185
|
-
let headLines = [];
|
|
186
|
-
let headLen = 0;
|
|
187
|
-
const headBudget = Math.floor(budget * 0.4);
|
|
188
|
-
for (let i = 0; i < headCount && headLen < headBudget; i++) {
|
|
189
|
-
headLines.push(lines[i]);
|
|
190
|
-
headLen += lines[i].length + 1;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
let tailLines = [];
|
|
194
|
-
let tailLen = 0;
|
|
195
|
-
const tailBudget = Math.floor(budget * 0.4);
|
|
196
|
-
for (let i = lines.length - 1; i >= lines.length - tailCount && tailLen < tailBudget; i--) {
|
|
197
|
-
tailLines.unshift(lines[i]);
|
|
198
|
-
tailLen += lines[i].length + 1;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const omitted = lines.length - headLines.length - tailLines.length;
|
|
202
|
-
return headLines.join('\n') + `\n...(${omitted} lines omitted, ${lines.length} total)...\n` + tailLines.join('\n');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Compress a single message to reduce token usage.
|
|
207
|
-
* @param {object} msg
|
|
208
|
-
* @param {string} level - 'light', 'medium', or 'aggressive'
|
|
209
|
-
* @returns {object} compressed message
|
|
210
|
-
*/
|
|
211
|
-
function compressMessage(msg, level = 'light') {
|
|
212
|
-
const maxContent = level === 'aggressive' ? 100 : level === 'medium' ? 200 : TRUNCATE_ASSISTANT;
|
|
213
|
-
const maxTool = level === 'aggressive' ? 50 : level === 'medium' ? 100 : TRUNCATE_TOOL_RESULT;
|
|
214
|
-
|
|
215
|
-
if (msg.role === 'tool') {
|
|
216
|
-
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
|
217
|
-
if (content.length > maxTool) {
|
|
218
|
-
return {
|
|
219
|
-
...msg,
|
|
220
|
-
content: compressToolResult(content, maxTool),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
return msg;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (msg.role === 'assistant') {
|
|
227
|
-
const compressed = { ...msg };
|
|
228
|
-
|
|
229
|
-
// Truncate long content
|
|
230
|
-
if (compressed.content && compressed.content.length > maxContent) {
|
|
231
|
-
compressed.content =
|
|
232
|
-
compressed.content.substring(0, maxContent) + `\n...(truncated)`;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Simplify tool_calls in old messages
|
|
236
|
-
if (compressed.tool_calls && level === 'aggressive') {
|
|
237
|
-
compressed.tool_calls = compressed.tool_calls.map((tc) => ({
|
|
238
|
-
...tc,
|
|
239
|
-
function: {
|
|
240
|
-
name: tc.function.name,
|
|
241
|
-
arguments: typeof tc.function.arguments === 'string'
|
|
242
|
-
? tc.function.arguments.substring(0, 50)
|
|
243
|
-
: tc.function.arguments,
|
|
244
|
-
},
|
|
245
|
-
}));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return compressed;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// User and system messages: keep as-is (they're important context)
|
|
252
|
-
return msg;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Fit messages into the context window.
|
|
257
|
-
* Compresses older messages if the conversation exceeds the threshold.
|
|
258
|
-
*
|
|
259
|
-
* Strategy:
|
|
260
|
-
* 1. Always keep system prompt (first message) intact
|
|
261
|
-
* 2. Always keep the most recent KEEP_RECENT messages intact
|
|
262
|
-
* 3. Compress middle messages (light first, then aggressive)
|
|
263
|
-
* 4. If still too large, remove oldest compressed messages
|
|
264
|
-
*
|
|
265
|
-
* @param {Array} messages - Full message array
|
|
266
|
-
* @param {Array} tools - Tool definitions
|
|
267
|
-
* @param {object} [options] - { threshold, keepRecent }
|
|
268
|
-
* @returns {{ messages: Array, compressed: boolean, tokensRemoved: number }}
|
|
269
|
-
*/
|
|
270
|
-
async function fitToContext(messages, tools, options = {}) {
|
|
271
|
-
const threshold = options.threshold ?? COMPRESSION_THRESHOLD;
|
|
272
|
-
const safetyMargin = options.safetyMargin ?? SAFETY_MARGIN;
|
|
273
|
-
const keepRecent = options.keepRecent ?? KEEP_RECENT;
|
|
274
|
-
|
|
275
|
-
const limit = getContextWindow();
|
|
276
|
-
const toolTokens = estimateToolsTokens(tools);
|
|
277
|
-
const targetMax = Math.floor(limit * (threshold - safetyMargin));
|
|
278
|
-
const available = targetMax - toolTokens;
|
|
279
|
-
|
|
280
|
-
const currentTokens = estimateMessagesTokens(messages);
|
|
281
|
-
const totalUsed = currentTokens + toolTokens;
|
|
282
|
-
|
|
283
|
-
// Under threshold → no compression needed
|
|
284
|
-
if (totalUsed <= targetMax) {
|
|
285
|
-
return { messages, compressed: false, compacted: false, tokensRemoved: 0 };
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const originalTokens = currentTokens;
|
|
289
|
-
|
|
290
|
-
// Split: system + old messages + recent messages
|
|
291
|
-
let system = null;
|
|
292
|
-
let startIdx = 0;
|
|
293
|
-
if (messages.length > 0 && messages[0].role === 'system') {
|
|
294
|
-
system = messages[0];
|
|
295
|
-
startIdx = 1;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const recentStart = Math.max(startIdx, messages.length - keepRecent);
|
|
299
|
-
let oldMessages = messages.slice(startIdx, recentStart);
|
|
300
|
-
const recentMessages = messages.slice(recentStart);
|
|
301
|
-
|
|
302
|
-
// Phase 0: LLM Compacting
|
|
303
|
-
const nonCompacted = oldMessages.filter(m => !m._compacted);
|
|
304
|
-
if (nonCompacted.length >= 6) {
|
|
305
|
-
try {
|
|
306
|
-
const { compactMessages } = require('./compactor');
|
|
307
|
-
const compactResult = await compactMessages(nonCompacted);
|
|
308
|
-
if (compactResult) {
|
|
309
|
-
const kept = oldMessages.filter(m => m._compacted);
|
|
310
|
-
const compressedOld = [...kept, compactResult.message];
|
|
311
|
-
const r = buildResult(system, compressedOld, recentMessages);
|
|
312
|
-
const t = estimateMessagesTokens(r);
|
|
313
|
-
if (t + toolTokens <= targetMax) {
|
|
314
|
-
return { messages: r, compressed: true, compacted: true,
|
|
315
|
-
tokensRemoved: originalTokens - t };
|
|
316
|
-
}
|
|
317
|
-
// Compacted but still too large → continue with compacted messages as base
|
|
318
|
-
oldMessages = compressedOld;
|
|
319
|
-
}
|
|
320
|
-
} catch { /* silent fallback */ }
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Determine compression level based on how far over target we are
|
|
324
|
-
const overageRatio = (totalUsed - targetMax) / targetMax;
|
|
325
|
-
|
|
326
|
-
// Phase 1: Light compression (≤15% over target)
|
|
327
|
-
let compressed = oldMessages.map((msg) => compressMessage(msg, 'light'));
|
|
328
|
-
let result = buildResult(system, compressed, recentMessages);
|
|
329
|
-
let tokens = estimateMessagesTokens(result);
|
|
330
|
-
|
|
331
|
-
if (tokens + toolTokens <= targetMax) {
|
|
332
|
-
return {
|
|
333
|
-
messages: result,
|
|
334
|
-
compressed: true,
|
|
335
|
-
compacted: false,
|
|
336
|
-
tokensRemoved: originalTokens - tokens,
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Phase 2: Medium compression (≤30% over target)
|
|
341
|
-
compressed = oldMessages.map((msg) => compressMessage(msg, 'medium'));
|
|
342
|
-
result = buildResult(system, compressed, recentMessages);
|
|
343
|
-
tokens = estimateMessagesTokens(result);
|
|
344
|
-
|
|
345
|
-
if (tokens + toolTokens <= targetMax) {
|
|
346
|
-
return {
|
|
347
|
-
messages: result,
|
|
348
|
-
compressed: true,
|
|
349
|
-
compacted: false,
|
|
350
|
-
tokensRemoved: originalTokens - tokens,
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Phase 3: Aggressive compression (>30% over target)
|
|
355
|
-
compressed = oldMessages.map((msg) => compressMessage(msg, 'aggressive'));
|
|
356
|
-
result = buildResult(system, compressed, recentMessages);
|
|
357
|
-
tokens = estimateMessagesTokens(result);
|
|
358
|
-
|
|
359
|
-
if (tokens + toolTokens <= targetMax) {
|
|
360
|
-
return {
|
|
361
|
-
messages: result,
|
|
362
|
-
compressed: true,
|
|
363
|
-
compacted: false,
|
|
364
|
-
tokensRemoved: originalTokens - tokens,
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Phase 4: Remove oldest messages until we fit
|
|
369
|
-
while (compressed.length > 0 && tokens + toolTokens > available) {
|
|
370
|
-
const removed = compressed.shift();
|
|
371
|
-
tokens -= estimateMessageTokens(removed);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
result = buildResult(system, compressed, recentMessages);
|
|
375
|
-
tokens = estimateMessagesTokens(result);
|
|
376
|
-
|
|
377
|
-
return {
|
|
378
|
-
messages: result,
|
|
379
|
-
compressed: true,
|
|
380
|
-
compacted: false,
|
|
381
|
-
tokensRemoved: originalTokens - tokens,
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function buildResult(system, oldMessages, recentMessages) {
|
|
386
|
-
const result = [];
|
|
387
|
-
if (system) result.push(system);
|
|
388
|
-
result.push(...oldMessages, ...recentMessages);
|
|
389
|
-
return result;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// ─── Smart File Truncation ─────────────────────────────────────
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Truncate file content to fit within a token budget.
|
|
396
|
-
* Keeps the beginning and end of the file (most useful parts).
|
|
397
|
-
*
|
|
398
|
-
* @param {string} content - File content
|
|
399
|
-
* @param {number} maxTokens - Maximum tokens to use
|
|
400
|
-
* @returns {string} truncated content
|
|
401
|
-
*/
|
|
402
|
-
function truncateFileContent(content, maxTokens) {
|
|
403
|
-
if (!content) return '';
|
|
404
|
-
|
|
405
|
-
const currentTokens = estimateTokens(content);
|
|
406
|
-
if (currentTokens <= maxTokens) return content;
|
|
407
|
-
|
|
408
|
-
const maxChars = maxTokens * 4; // Reverse the estimation
|
|
409
|
-
const lines = content.split('\n');
|
|
410
|
-
|
|
411
|
-
// Keep first 60% and last 40%
|
|
412
|
-
const headChars = Math.floor(maxChars * 0.6);
|
|
413
|
-
const tailChars = Math.floor(maxChars * 0.4);
|
|
414
|
-
|
|
415
|
-
let headContent = '';
|
|
416
|
-
let headLines = 0;
|
|
417
|
-
for (const line of lines) {
|
|
418
|
-
if (headContent.length + line.length + 1 > headChars) break;
|
|
419
|
-
headContent += (headContent ? '\n' : '') + line;
|
|
420
|
-
headLines++;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
let tailContent = '';
|
|
424
|
-
let tailLines = 0;
|
|
425
|
-
for (let i = lines.length - 1; i >= headLines; i--) {
|
|
426
|
-
const candidate = lines[i] + (tailContent ? '\n' : '') + tailContent;
|
|
427
|
-
if (candidate.length > tailChars) break;
|
|
428
|
-
tailContent = candidate;
|
|
429
|
-
tailLines++;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const skipped = lines.length - headLines - tailLines;
|
|
433
|
-
const separator = `\n\n... (${skipped} lines omitted, ${lines.length} total) ...\n\n`;
|
|
434
|
-
|
|
435
|
-
return headContent + separator + tailContent;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// ─── Force Compression (Context-Too-Long Recovery) ─────────────
|
|
439
|
-
|
|
440
|
-
const FORCE_COMPRESS_KEEP_RECENT = 6;
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Emergency compression when the API rejects with "context too long".
|
|
444
|
-
* More aggressive than fitToContext: targets 50% of context window
|
|
445
|
-
* and keeps only 6 recent messages.
|
|
446
|
-
*
|
|
447
|
-
* @param {Array} messages - Full message array (including system prompt)
|
|
448
|
-
* @param {Array} tools - Tool definitions
|
|
449
|
-
* @returns {{ messages: Array, tokensRemoved: number }}
|
|
450
|
-
*/
|
|
451
|
-
function forceCompress(messages, tools) {
|
|
452
|
-
const limit = getContextWindow();
|
|
453
|
-
const toolTokens = estimateToolsTokens(tools);
|
|
454
|
-
const targetMax = Math.floor(limit * 0.5) - toolTokens; // 50% of context window
|
|
455
|
-
const originalTokens = estimateMessagesTokens(messages);
|
|
456
|
-
|
|
457
|
-
// Split: system + old + recent
|
|
458
|
-
let system = null;
|
|
459
|
-
let startIdx = 0;
|
|
460
|
-
if (messages.length > 0 && messages[0].role === 'system') {
|
|
461
|
-
system = messages[0];
|
|
462
|
-
startIdx = 1;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const recentStart = Math.max(startIdx, messages.length - FORCE_COMPRESS_KEEP_RECENT);
|
|
466
|
-
let oldMessages = messages.slice(startIdx, recentStart);
|
|
467
|
-
const recentMessages = messages.slice(recentStart);
|
|
468
|
-
|
|
469
|
-
// Aggressive compression on all old messages
|
|
470
|
-
let compressed = oldMessages.map((msg) => compressMessage(msg, 'aggressive'));
|
|
471
|
-
|
|
472
|
-
// Remove oldest messages until we fit
|
|
473
|
-
let result = buildResult(system, compressed, recentMessages);
|
|
474
|
-
let tokens = estimateMessagesTokens(result);
|
|
475
|
-
|
|
476
|
-
while (compressed.length > 0 && tokens > targetMax) {
|
|
477
|
-
const removed = compressed.shift();
|
|
478
|
-
tokens -= estimateMessageTokens(removed);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
result = buildResult(system, compressed, recentMessages);
|
|
482
|
-
tokens = estimateMessagesTokens(result);
|
|
483
|
-
|
|
484
|
-
return {
|
|
485
|
-
messages: result,
|
|
486
|
-
tokensRemoved: originalTokens - tokens,
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// ─── Exports ───────────────────────────────────────────────────
|
|
491
|
-
|
|
492
|
-
module.exports = {
|
|
493
|
-
estimateTokens,
|
|
494
|
-
estimateMessageTokens,
|
|
495
|
-
estimateMessagesTokens,
|
|
496
|
-
estimateToolsTokens,
|
|
497
|
-
getContextWindow,
|
|
498
|
-
getUsage,
|
|
499
|
-
compressMessage,
|
|
500
|
-
compressToolResult,
|
|
501
|
-
fitToContext,
|
|
502
|
-
forceCompress,
|
|
503
|
-
truncateFileContent,
|
|
504
|
-
COMPRESSION_THRESHOLD,
|
|
505
|
-
SAFETY_MARGIN,
|
|
506
|
-
KEEP_RECENT,
|
|
507
|
-
};
|
package/cli/context.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cli/context.js — Auto-Context: package.json, git, README
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const { execSync } = require('child_process');
|
|
8
|
-
const { C } = require('./ui');
|
|
9
|
-
const { getMergeConflicts } = require('./git');
|
|
10
|
-
|
|
11
|
-
function safe(fn) {
|
|
12
|
-
try {
|
|
13
|
-
return fn();
|
|
14
|
-
} catch {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function gatherProjectContext(cwd) {
|
|
20
|
-
const parts = [];
|
|
21
|
-
|
|
22
|
-
// package.json
|
|
23
|
-
const pkgPath = path.join(cwd, 'package.json');
|
|
24
|
-
if (fs.existsSync(pkgPath)) {
|
|
25
|
-
try {
|
|
26
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
27
|
-
const info = { name: pkg.name, version: pkg.version };
|
|
28
|
-
if (pkg.scripts) info.scripts = Object.keys(pkg.scripts).slice(0, 15);
|
|
29
|
-
if (pkg.dependencies) info.deps = Object.keys(pkg.dependencies).length;
|
|
30
|
-
if (pkg.devDependencies) info.devDeps = Object.keys(pkg.devDependencies).length;
|
|
31
|
-
parts.push(`PACKAGE: ${JSON.stringify(info)}`);
|
|
32
|
-
} catch { /* ignore corrupt package.json */ }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// README (first 50 lines)
|
|
36
|
-
const readmePath = path.join(cwd, 'README.md');
|
|
37
|
-
if (fs.existsSync(readmePath)) {
|
|
38
|
-
const lines = fs.readFileSync(readmePath, 'utf-8').split('\n').slice(0, 50);
|
|
39
|
-
parts.push(`README (first 50 lines):\n${lines.join('\n')}`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Git info
|
|
43
|
-
const branch = safe(() => execSync('git branch --show-current', { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim());
|
|
44
|
-
if (branch) parts.push(`GIT BRANCH: ${branch}`);
|
|
45
|
-
|
|
46
|
-
const status = safe(() => execSync('git status --short', { cwd, encoding: 'utf-8', timeout: 5000, stdio: 'pipe' }).trim());
|
|
47
|
-
if (status) parts.push(`GIT STATUS:\n${status}`);
|
|
48
|
-
|
|
49
|
-
const log = safe(() =>
|
|
50
|
-
execSync('git log --oneline -5', { cwd, encoding: 'utf-8', timeout: 5000, stdio: 'pipe' }).trim()
|
|
51
|
-
);
|
|
52
|
-
if (log) parts.push(`RECENT COMMITS:\n${log}`);
|
|
53
|
-
|
|
54
|
-
// Merge conflicts
|
|
55
|
-
const conflicts = getMergeConflicts();
|
|
56
|
-
if (conflicts.length > 0) {
|
|
57
|
-
const conflictFiles = conflicts.map(c => ` ${c.file}`).join('\n');
|
|
58
|
-
parts.push(`MERGE CONFLICTS (resolve before editing these files):\n${conflictFiles}`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// .gitignore
|
|
62
|
-
const giPath = path.join(cwd, '.gitignore');
|
|
63
|
-
if (fs.existsSync(giPath)) {
|
|
64
|
-
const content = fs.readFileSync(giPath, 'utf-8').trim();
|
|
65
|
-
parts.push(`GITIGNORE:\n${content}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return parts.join('\n\n');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function printContext(cwd) {
|
|
72
|
-
const pkgPath = path.join(cwd, 'package.json');
|
|
73
|
-
let project = '';
|
|
74
|
-
if (fs.existsSync(pkgPath)) {
|
|
75
|
-
try {
|
|
76
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
77
|
-
project = `${pkg.name || '?'} v${pkg.version || '?'}`;
|
|
78
|
-
} catch { /* ignore corrupt package.json */ }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const branch = safe(() => execSync('git branch --show-current', { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim());
|
|
82
|
-
|
|
83
|
-
if (project) console.log(`${C.dim} project: ${project}${C.reset}`);
|
|
84
|
-
if (branch) console.log(`${C.dim} branch: ${branch}${C.reset}`);
|
|
85
|
-
|
|
86
|
-
const conflicts = getMergeConflicts();
|
|
87
|
-
if (conflicts.length > 0) {
|
|
88
|
-
console.log(`${C.red} ⚠ ${conflicts.length} unresolved merge conflict(s):${C.reset}`);
|
|
89
|
-
for (const c of conflicts) {
|
|
90
|
-
console.log(`${C.red} ${c.file}${C.reset}`);
|
|
91
|
-
}
|
|
92
|
-
console.log(`${C.yellow} → Resolve conflicts before starting tasks${C.reset}`);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
console.log();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
module.exports = { gatherProjectContext, printContext };
|