nex-code 0.3.5 → 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/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
- };
@@ -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 };