protoagent 0.0.5 → 0.1.0

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 (58) hide show
  1. package/README.md +99 -19
  2. package/dist/App.js +602 -0
  3. package/dist/agentic-loop.js +492 -525
  4. package/dist/cli.js +39 -0
  5. package/dist/components/CollapsibleBox.js +26 -0
  6. package/dist/components/ConfigDialog.js +40 -0
  7. package/dist/components/ConsolidatedToolMessage.js +41 -0
  8. package/dist/components/FormattedMessage.js +93 -0
  9. package/dist/components/Table.js +275 -0
  10. package/dist/config.js +171 -0
  11. package/dist/mcp.js +170 -0
  12. package/dist/providers.js +137 -0
  13. package/dist/sessions.js +161 -0
  14. package/dist/skills.js +229 -0
  15. package/dist/sub-agent.js +103 -0
  16. package/dist/system-prompt.js +131 -0
  17. package/dist/tools/bash.js +178 -0
  18. package/dist/tools/edit-file.js +65 -171
  19. package/dist/tools/index.js +79 -134
  20. package/dist/tools/list-directory.js +20 -73
  21. package/dist/tools/read-file.js +57 -101
  22. package/dist/tools/search-files.js +74 -162
  23. package/dist/tools/todo.js +57 -140
  24. package/dist/tools/webfetch.js +310 -0
  25. package/dist/tools/write-file.js +44 -135
  26. package/dist/utils/approval.js +69 -0
  27. package/dist/utils/compactor.js +87 -0
  28. package/dist/utils/cost-tracker.js +26 -81
  29. package/dist/utils/format-message.js +26 -0
  30. package/dist/utils/logger.js +101 -307
  31. package/dist/utils/path-validation.js +74 -0
  32. package/package.json +45 -51
  33. package/LICENSE +0 -21
  34. package/dist/config/client.js +0 -315
  35. package/dist/config/commands.js +0 -223
  36. package/dist/config/manager.js +0 -117
  37. package/dist/config/mcp-commands.js +0 -266
  38. package/dist/config/mcp-manager.js +0 -240
  39. package/dist/config/mcp-types.js +0 -28
  40. package/dist/config/providers.js +0 -229
  41. package/dist/config/setup.js +0 -209
  42. package/dist/config/system-prompt.js +0 -397
  43. package/dist/config/types.js +0 -4
  44. package/dist/index.js +0 -229
  45. package/dist/tools/create-directory.js +0 -76
  46. package/dist/tools/directory-operations.js +0 -195
  47. package/dist/tools/file-operations.js +0 -211
  48. package/dist/tools/run-shell-command.js +0 -746
  49. package/dist/tools/search-operations.js +0 -179
  50. package/dist/tools/shell-operations.js +0 -342
  51. package/dist/tools/task-complete.js +0 -26
  52. package/dist/tools/view-directory-tree.js +0 -125
  53. package/dist/tools.js +0 -2
  54. package/dist/utils/conversation-compactor.js +0 -140
  55. package/dist/utils/enhanced-prompt.js +0 -23
  56. package/dist/utils/file-operations-approval.js +0 -373
  57. package/dist/utils/interrupt-handler.js +0 -127
  58. package/dist/utils/user-cancellation.js +0 -34
@@ -1,107 +1,52 @@
1
1
  /**
2
- * Cost tracking and token counting utilities
3
- */
4
- import { logger } from './logger.js';
5
- /**
6
- * Rough token estimation for OpenAI models
7
- * This is approximate - actual tokenization may vary
2
+ * Token estimation and cost tracking.
3
+ *
4
+ * Uses a rough heuristic (~4 chars per token) for estimation.
5
+ * Prefers actual usage data from the API when available.
8
6
  */
7
+ /** Rough token estimation: ~4 characters per token. */
9
8
  export function estimateTokens(text) {
10
- if (!text)
11
- return 0;
12
- // Rough estimation: ~4 characters per token for English text
13
- // This is conservative and should be close enough for cost estimation
14
9
  return Math.ceil(text.length / 4);
15
10
  }
16
- /**
17
- * Estimate tokens for a message
18
- */
19
- export function estimateMessageTokens(message) {
20
- let tokens = 0;
21
- // Base overhead per message
22
- tokens += 4;
23
- if ('content' in message && message.content) {
24
- if (typeof message.content === 'string') {
25
- tokens += estimateTokens(message.content);
26
- }
11
+ /** Estimate tokens for a single message including overhead. */
12
+ export function estimateMessageTokens(msg) {
13
+ let tokens = 4; // per-message overhead
14
+ if ('content' in msg && typeof msg.content === 'string') {
15
+ tokens += estimateTokens(msg.content);
27
16
  }
28
- if ('tool_calls' in message && message.tool_calls) {
29
- for (const toolCall of message.tool_calls) {
30
- tokens += estimateTokens(toolCall.function.name);
31
- tokens += estimateTokens(toolCall.function.arguments);
32
- tokens += 10; // overhead per tool call
17
+ if ('tool_calls' in msg && Array.isArray(msg.tool_calls)) {
18
+ for (const tc of msg.tool_calls) {
19
+ tokens += estimateTokens(tc.function?.name || '') + estimateTokens(tc.function?.arguments || '') + 10;
33
20
  }
34
21
  }
35
22
  return tokens;
36
23
  }
37
- /**
38
- * Estimate total tokens for conversation history
39
- */
24
+ /** Estimate total tokens for a conversation. */
40
25
  export function estimateConversationTokens(messages) {
41
- let totalTokens = 0;
42
- for (const message of messages) {
43
- totalTokens += estimateMessageTokens(message);
44
- }
45
- // Add some overhead for the conversation structure
46
- totalTokens += 10;
47
- return totalTokens;
26
+ return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0) + 10;
48
27
  }
49
- /**
50
- * Calculate estimated cost for a request
51
- */
52
- export function calculateCost(inputTokens, outputTokens, modelConfig) {
53
- const inputCost = inputTokens * modelConfig.pricing.inputTokens;
54
- const outputCost = outputTokens * modelConfig.pricing.outputTokens;
55
- return inputCost + outputCost;
28
+ /** Calculate dollar cost for a given number of tokens. */
29
+ export function calculateCost(inputTokens, outputTokens, pricing) {
30
+ return inputTokens * pricing.inputPerToken + outputTokens * pricing.outputPerToken;
56
31
  }
57
- /**
58
- * Get context information for the current conversation
59
- */
60
- export function getContextInfo(messages, modelConfig) {
32
+ /** Get context window utilisation info. */
33
+ export function getContextInfo(messages, pricing) {
61
34
  const currentTokens = estimateConversationTokens(messages);
62
- const maxTokens = modelConfig.contextWindow;
35
+ const maxTokens = pricing.contextWindow;
63
36
  const utilizationPercentage = (currentTokens / maxTokens) * 100;
64
- const needsCompaction = utilizationPercentage >= 90; // Compact at 90% capacity
65
37
  return {
66
38
  currentTokens,
67
39
  maxTokens,
68
40
  utilizationPercentage,
69
- needsCompaction
41
+ needsCompaction: utilizationPercentage >= 90,
70
42
  };
71
43
  }
72
- /**
73
- * Log usage and cost information
74
- */
75
- export function logUsageInfo(usage, contextInfo, modelConfig) {
76
- logger.consoleLog(`💰 Usage: ${usage.inputTokens} in + ${usage.outputTokens} out = ${usage.totalTokens} tokens`);
77
- logger.consoleLog(`💸 Estimated cost: $${usage.estimatedCost.toFixed(6)}`);
78
- logger.consoleLog(`📊 Context: ${contextInfo.currentTokens}/${contextInfo.maxTokens} tokens (${contextInfo.utilizationPercentage.toFixed(1)}%)`);
79
- if (contextInfo.needsCompaction) {
80
- logger.consoleLog(`⚠️ Context approaching limit - automatic compaction will trigger soon`);
81
- }
82
- }
83
- /**
84
- * Create usage info from response tokens
85
- */
86
- export function createUsageInfo(inputTokens, outputTokens, modelConfig) {
87
- const totalTokens = inputTokens + outputTokens;
88
- const estimatedCost = calculateCost(inputTokens, outputTokens, modelConfig);
44
+ /** Build a UsageInfo from actual or estimated token counts. */
45
+ export function createUsageInfo(inputTokens, outputTokens, pricing) {
89
46
  return {
90
47
  inputTokens,
91
48
  outputTokens,
92
- totalTokens,
93
- estimatedCost
94
- };
95
- }
96
- /**
97
- * Extract token usage from OpenAI response
98
- */
99
- export function extractTokenUsage(response) {
100
- // For streaming responses, we need to estimate
101
- // In a real implementation, you might want to sum up the tokens from the stream
102
- // For now, we'll use rough estimation
103
- return {
104
- inputTokens: 0, // Will be estimated separately
105
- outputTokens: 0 // Will be estimated separately
49
+ totalTokens: inputTokens + outputTokens,
50
+ estimatedCost: calculateCost(inputTokens, outputTokens, pricing),
106
51
  };
107
52
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Parse Markdown-style formatting and convert to ANSI escape codes.
3
+ *
4
+ * Supports:
5
+ * - **bold** → bold text
6
+ * - *italic* → italic text
7
+ * - ***bold italic*** → bold + italic text
8
+ *
9
+ * Returns a string with ANSI escape codes that Ink will render with styling.
10
+ */
11
+ export function formatMessage(text) {
12
+ // ANSI escape codes for styling
13
+ const BOLD = '\x1b[1m';
14
+ const ITALIC = '\x1b[3m';
15
+ const RESET = '\x1b[0m';
16
+ let result = text;
17
+ // Strip markdown hashtags (headers)
18
+ result = result.replace(/^#+\s+/gm, '');
19
+ // Replace ***bold italic*** first (to avoid matching ** or * inside)
20
+ result = result.replace(/\*\*\*(.+?)\*\*\*/g, `${BOLD}${ITALIC}$1${RESET}`);
21
+ // Replace **bold**
22
+ result = result.replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`);
23
+ // Replace *italic*
24
+ result = result.replace(/\*(.+?)\*/g, `${ITALIC}$1${RESET}`);
25
+ return result;
26
+ }
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Configurable logging utility for ProtoAgent
2
+ * Logger utility with configurable log levels.
3
+ *
4
+ * Levels (from least to most verbose):
5
+ * ERROR (0) → WARN (1) → INFO (2) → DEBUG (3) → TRACE (4)
6
+ *
7
+ * Set the level via `setLogLevel()` or the `--log-level` CLI flag.
8
+ * Logs are written to a file to avoid interfering with Ink UI rendering.
3
9
  */
4
- import fs from 'fs';
5
- import path from 'path';
6
- import os from 'os';
10
+ import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
7
13
  export var LogLevel;
8
14
  (function (LogLevel) {
9
15
  LogLevel[LogLevel["ERROR"] = 0] = "ERROR";
@@ -12,311 +18,99 @@ export var LogLevel;
12
18
  LogLevel[LogLevel["DEBUG"] = 3] = "DEBUG";
13
19
  LogLevel[LogLevel["TRACE"] = 4] = "TRACE";
14
20
  })(LogLevel || (LogLevel = {}));
15
- export var LogFormat;
16
- (function (LogFormat) {
17
- LogFormat["TEXT"] = "text";
18
- LogFormat["JSONL"] = "jsonl";
19
- })(LogFormat || (LogFormat = {}));
20
- class Logger {
21
- constructor() {
22
- this.level = LogLevel.INFO;
23
- this.timestamps = true;
24
- this.colors = true;
25
- this.fileLogging = false;
26
- this.logFilePath = '';
27
- this.logFormat = LogFormat.TEXT; // Default to readable text format
28
- // Initialize with default settings
29
- }
30
- enableFileLogging(logDir, format) {
31
- this.fileLogging = true;
32
- if (format) {
33
- this.logFormat = format;
34
- }
35
- // Create log directory
36
- const defaultLogDir = path.join(os.homedir(), '.protoagent', 'logs');
37
- const targetLogDir = logDir || defaultLogDir;
38
- try {
39
- fs.mkdirSync(targetLogDir, { recursive: true });
40
- }
41
- catch (error) {
42
- console.error('Failed to create log directory:', error);
43
- this.fileLogging = false;
44
- return;
45
- }
46
- // Create unique log file name
47
- const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-');
48
- const sessionId = Date.now().toString().slice(-6);
49
- const extension = this.logFormat === LogFormat.JSONL ? 'jsonl' : 'log';
50
- this.logFilePath = path.join(targetLogDir, `protoagent-${timestamp}-${sessionId}.${extension}`);
51
- // Write initial log entry
52
- this.writeToFile({
53
- timestamp: new Date().toISOString(),
54
- level: 'INFO',
55
- message: `ProtoAgent logging session started`,
56
- component: 'Logger',
57
- metadata: {
58
- logFile: this.logFilePath,
59
- version: '0.0.2',
60
- format: this.logFormat
61
- }
62
- });
63
- // Output the log file path immediately at the beginning
64
- logger.consoleLog(`📝 Log file: ${this.logFilePath}`);
65
- // Note: We can't log this to file since we're in the logger itself
66
- }
67
- disableFileLogging() {
68
- if (this.fileLogging) {
69
- this.writeToFile({
70
- timestamp: new Date().toISOString(),
71
- level: 'INFO',
72
- message: 'ProtoAgent logging session ended',
73
- component: 'Logger'
74
- });
75
- }
76
- this.fileLogging = false;
77
- }
78
- getLogFilePath() {
79
- return this.logFilePath;
80
- }
81
- writeToFile(entry) {
82
- if (!this.fileLogging || !this.logFilePath)
83
- return;
84
- try {
85
- let logLine;
86
- if (this.logFormat === LogFormat.JSONL) {
87
- // JSON Lines format - one JSON object per line
88
- logLine = JSON.stringify({
89
- timestamp: entry.timestamp,
90
- level: entry.level,
91
- message: entry.message,
92
- component: entry.component,
93
- ...entry.metadata // Spread metadata fields to root level
94
- });
95
- }
96
- else {
97
- // Traditional text format
98
- logLine = this.formatFileLogEntry(entry);
99
- }
100
- fs.appendFileSync(this.logFilePath, logLine + '\n');
101
- }
102
- catch (error) {
103
- // Fallback to console if file writing fails
104
- console.error(`Failed to write to log file: ${error}`);
105
- this.fileLogging = false;
106
- }
107
- }
108
- formatFileLogEntry(entry) {
109
- const { timestamp, level, message, component, metadata } = entry;
110
- let formatted = `[${timestamp}] ${level.padEnd(5)} `;
111
- if (component) {
112
- formatted += `[${component}] `;
113
- }
114
- formatted += message;
115
- if (metadata && Object.keys(metadata).length > 0) {
116
- formatted += ` | ${JSON.stringify(metadata)}`;
117
- }
118
- return formatted;
119
- }
120
- setLevel(level) {
121
- this.level = level;
122
- this.debug(`🔍 Log level set to: ${LogLevel[level]}`);
123
- }
124
- getLevel() {
125
- return this.level;
126
- }
127
- setTimestamps(enabled) {
128
- this.timestamps = enabled;
129
- }
130
- setColors(enabled) {
131
- this.colors = enabled;
132
- }
133
- formatMessage(level, message, context) {
134
- let formatted = message;
135
- if (this.timestamps) {
136
- const timestamp = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
137
- formatted = `[${timestamp}] ${formatted}`;
138
- }
139
- if (context) {
140
- const contextParts = [];
141
- if (context.component)
142
- contextParts.push(`${context.component}`);
143
- if (context.operation)
144
- contextParts.push(`${context.operation}`);
145
- if (context.duration !== undefined)
146
- contextParts.push(`${context.duration}ms`);
147
- if (context.tokens !== undefined)
148
- contextParts.push(`${context.tokens} tokens`);
149
- if (context.cost !== undefined)
150
- contextParts.push(`$${context.cost.toFixed(6)}`);
151
- // Add any other context properties
152
- Object.keys(context).forEach(key => {
153
- if (!['component', 'operation', 'duration', 'tokens', 'cost'].includes(key)) {
154
- contextParts.push(`${key}=${context[key]}`);
155
- }
156
- });
157
- if (contextParts.length > 0) {
158
- formatted += ` [${contextParts.join(', ')}]`;
159
- }
160
- }
161
- return formatted;
162
- }
163
- error(message, context, ...args) {
164
- if (this.level >= LogLevel.ERROR) {
165
- const formatted = this.formatMessage('ERROR', message, context);
166
- console.error(this.colors ? `\x1b[31m${formatted}\x1b[0m` : formatted, ...args);
167
- // Write to file if enabled
168
- if (this.fileLogging) {
169
- this.writeToFile({
170
- timestamp: new Date().toISOString(),
171
- level: 'ERROR',
172
- message,
173
- component: context?.component,
174
- metadata: context ? { ...context } : undefined
175
- });
176
- }
177
- }
178
- }
179
- warn(message, context, ...args) {
180
- if (this.level >= LogLevel.WARN) {
181
- const formatted = this.formatMessage('WARN', message, context);
182
- console.warn(this.colors ? `\x1b[33m${formatted}\x1b[0m` : formatted, ...args);
183
- // Write to file if enabled
184
- if (this.fileLogging) {
185
- this.writeToFile({
186
- timestamp: new Date().toISOString(),
187
- level: 'WARN',
188
- message,
189
- component: context?.component,
190
- metadata: context ? { ...context } : undefined
191
- });
192
- }
193
- }
194
- }
195
- info(message, context, ...args) {
196
- if (this.level >= LogLevel.INFO) {
197
- const formatted = this.formatMessage('INFO', message, context);
198
- logger.consoleLog(formatted, ...args);
199
- // Write to file if enabled
200
- if (this.fileLogging) {
201
- this.writeToFile({
202
- timestamp: new Date().toISOString(),
203
- level: 'INFO',
204
- message,
205
- component: context?.component,
206
- metadata: context ? { ...context } : undefined
207
- });
208
- }
209
- }
21
+ let currentLevel = LogLevel.INFO;
22
+ let logFilePath = null;
23
+ let logBuffer = [];
24
+ let logListeners = [];
25
+ export function setLogLevel(level) {
26
+ currentLevel = level;
27
+ }
28
+ export function getLogLevel() {
29
+ return currentLevel;
30
+ }
31
+ export function onLog(listener) {
32
+ logListeners.push(listener);
33
+ // Return unsubscribe function
34
+ return () => {
35
+ logListeners = logListeners.filter(l => l !== listener);
36
+ };
37
+ }
38
+ export function getRecentLogs(count = 50) {
39
+ return logBuffer.slice(-count);
40
+ }
41
+ export function initLogFile() {
42
+ // Create logs directory
43
+ const logsDir = join(homedir(), '.local', 'share', 'protoagent', 'logs');
44
+ if (!existsSync(logsDir)) {
45
+ mkdirSync(logsDir, { recursive: true });
46
+ }
47
+ // Create log file with timestamp
48
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
49
+ logFilePath = join(logsDir, `protoagent-${timestamp}.log`);
50
+ // Write header
51
+ writeToFile(`\n${'='.repeat(80)}\nProtoAgent Log - ${new Date().toISOString()}\n${'='.repeat(80)}\n`);
52
+ return logFilePath;
53
+ }
54
+ function writeToFile(message) {
55
+ if (!logFilePath) {
56
+ initLogFile();
210
57
  }
211
- debug(message, context, ...args) {
212
- if (this.level >= LogLevel.DEBUG) {
213
- const formatted = this.formatMessage('DEBUG', message, context);
214
- logger.consoleLog(this.colors ? `\x1b[36m${formatted}\x1b[0m` : formatted, ...args);
215
- // Write to file if enabled
216
- if (this.fileLogging) {
217
- this.writeToFile({
218
- timestamp: new Date().toISOString(),
219
- level: 'DEBUG',
220
- message,
221
- component: context?.component,
222
- metadata: context ? { ...context } : undefined
223
- });
224
- }
225
- }
58
+ try {
59
+ appendFileSync(logFilePath, message);
226
60
  }
227
- trace(message, context, ...args) {
228
- if (this.level >= LogLevel.TRACE) {
229
- const formatted = this.formatMessage('TRACE', message, context);
230
- logger.consoleLog(this.colors ? `\x1b[90m${formatted}\x1b[0m` : formatted, ...args);
231
- // Write to file if enabled
232
- if (this.fileLogging) {
233
- this.writeToFile({
234
- timestamp: new Date().toISOString(),
235
- level: 'TRACE',
236
- message,
237
- component: context?.component,
238
- metadata: context ? { ...context } : undefined
239
- });
240
- }
241
- }
61
+ catch (err) {
62
+ // Silently fail if we can't write to log file
242
63
  }
243
- // Convenience methods for common operations
244
- startOperation(operation, component) {
245
- const startTime = Date.now();
246
- this.debug(`🚀 Starting: ${operation}`, { component, operation });
64
+ }
65
+ function timestamp() {
66
+ const d = new Date();
67
+ const hh = String(d.getHours()).padStart(2, '0');
68
+ const mm = String(d.getMinutes()).padStart(2, '0');
69
+ const ss = String(d.getSeconds()).padStart(2, '0');
70
+ const ms = String(d.getMilliseconds()).padStart(3, '0');
71
+ return `${hh}:${mm}:${ss}.${ms}`;
72
+ }
73
+ function log(level, label, message, context) {
74
+ if (level > currentLevel)
75
+ return;
76
+ const ts = timestamp();
77
+ // Create log entry
78
+ const entry = {
79
+ timestamp: ts,
80
+ level,
81
+ message,
82
+ context,
83
+ };
84
+ // Add to buffer (keep last 100 entries)
85
+ logBuffer.push(entry);
86
+ if (logBuffer.length > 100) {
87
+ logBuffer.shift();
88
+ }
89
+ // Notify listeners
90
+ logListeners.forEach(listener => listener(entry));
91
+ // Write to file
92
+ const ctx = context ? ` ${JSON.stringify(context)}` : '';
93
+ writeToFile(`[${ts}] ${label.padEnd(5)} ${message}${ctx}\n`);
94
+ }
95
+ export const logger = {
96
+ error: (msg, ctx) => log(LogLevel.ERROR, 'ERROR', msg, ctx),
97
+ warn: (msg, ctx) => log(LogLevel.WARN, 'WARN', msg, ctx),
98
+ info: (msg, ctx) => log(LogLevel.INFO, 'INFO', msg, ctx),
99
+ debug: (msg, ctx) => log(LogLevel.DEBUG, 'DEBUG', msg, ctx),
100
+ trace: (msg, ctx) => log(LogLevel.TRACE, 'TRACE', msg, ctx),
101
+ /** Start a timed operation. Call the returned `end()` to log the duration. */
102
+ startOperation(name) {
103
+ const start = performance.now();
104
+ logger.debug(`${name} started`);
247
105
  return {
248
- end: (result) => {
249
- const duration = Date.now() - startTime;
250
- if (result && result.error) {
251
- this.error(`❌ Failed: ${operation}`, { component, operation, duration, error: result.error });
252
- }
253
- else {
254
- this.debug(`✅ Completed: ${operation}`, { component, operation, duration });
255
- }
256
- }
106
+ end() {
107
+ const ms = (performance.now() - start).toFixed(1);
108
+ logger.debug(`${name} completed`, { durationMs: ms });
109
+ },
257
110
  };
258
- }
259
- apiCall(operation, tokens, cost) {
260
- const context = { component: 'API' };
261
- if (tokens) {
262
- context.tokens = tokens.input + tokens.output;
263
- this.debug(`🌐 API Call: ${operation} (${tokens.input} in, ${tokens.output} out)`, context);
264
- }
265
- else {
266
- this.debug(`🌐 API Call: ${operation}`, context);
267
- }
268
- if (cost) {
269
- this.info(`💸 Estimated cost: $${cost.toFixed(6)}`, context);
270
- }
271
- }
272
- toolExecution(toolName, duration, success = true) {
273
- const emoji = success ? '✅' : '❌';
274
- const level = success ? 'debug' : 'error';
275
- this[level](`${emoji} Tool: ${toolName}`, { component: 'Tool', operation: toolName, duration });
276
- }
277
- contextInfo(current, max, percentage) {
278
- const context = { component: 'Context', tokens: current };
279
- if (percentage >= 90) {
280
- this.warn(`📊 Context: ${current}/${max} tokens (${percentage.toFixed(1)}%) - approaching limit`, context);
281
- }
282
- else if (percentage >= 75) {
283
- this.info(`📊 Context: ${current}/${max} tokens (${percentage.toFixed(1)}%)`, context);
284
- }
285
- else {
286
- this.debug(`📊 Context: ${current}/${max} tokens (${percentage.toFixed(1)}%)`, context);
287
- }
288
- }
289
- // Stream-safe output methods (don't interfere with streaming content)
290
- streamSafeInfo(message) {
291
- if (this.level >= LogLevel.INFO) {
292
- process.stderr.write(`\n${message}\n`);
293
- }
294
- }
295
- streamSafeDebug(message) {
296
- if (this.level >= LogLevel.DEBUG) {
297
- process.stderr.write(`\n[DEBUG] ${message}\n`);
298
- }
299
- }
300
- /**
301
- * ConsoleLog method - always logs to console and optionally to file
302
- * This is the preferred method for user-facing output that should also be logged
303
- * Use this instead of separate console.log + logger.debug calls
304
- */
305
- consoleLog(message, context, ...args) {
306
- // Always output to console (no level filtering for consoleLog method)
307
- console.log(message, ...args);
308
- // Also write to file if enabled, at CONSOLE level (strip ANSI colors)
309
- if (this.fileLogging) {
310
- // Strip ANSI color codes from the message for clean file logging
311
- const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, '');
312
- this.writeToFile({
313
- timestamp: new Date().toISOString(),
314
- level: 'CONSOLE',
315
- message: cleanMessage,
316
- component: context?.component,
317
- metadata: context ? { ...context } : undefined
318
- });
319
- }
320
- }
321
- }
322
- export const logger = new Logger();
111
+ },
112
+ /** Get the path to the current log file */
113
+ getLogFilePath() {
114
+ return logFilePath;
115
+ },
116
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Path validation utility shared by all file tools.
3
+ *
4
+ * Ensures that every file path the agent operates on is within the
5
+ * working directory (process.cwd()). Prevents directory traversal
6
+ * and symlink escape attacks.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ const workingDirectory = process.cwd();
11
+ let allowedRoots = [];
12
+ function isWithinRoot(targetPath, rootPath) {
13
+ const relative = path.relative(rootPath, targetPath);
14
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
15
+ }
16
+ function isAllowedPath(targetPath) {
17
+ return isWithinRoot(targetPath, workingDirectory) || allowedRoots.some((root) => isWithinRoot(targetPath, root));
18
+ }
19
+ export async function setAllowedPathRoots(roots) {
20
+ const normalizedRoots = await Promise.all(roots.map(async (root) => {
21
+ const resolved = path.resolve(root);
22
+ try {
23
+ const realRoot = await fs.realpath(resolved);
24
+ return [path.normalize(resolved), realRoot];
25
+ }
26
+ catch {
27
+ return [path.normalize(resolved)];
28
+ }
29
+ }));
30
+ allowedRoots = Array.from(new Set(normalizedRoots.flat()));
31
+ }
32
+ export function getAllowedPathRoots() {
33
+ return [...allowedRoots];
34
+ }
35
+ /**
36
+ * Resolve and validate a path. Throws if the path is outside cwd.
37
+ * For files that don't exist yet, validates the parent directory.
38
+ */
39
+ export async function validatePath(requestedPath) {
40
+ const resolved = path.resolve(workingDirectory, requestedPath);
41
+ const normalized = path.normalize(resolved);
42
+ // First check: is the normalised path within cwd?
43
+ if (!isAllowedPath(normalized)) {
44
+ throw new Error(`Path "${requestedPath}" is outside the working directory.`);
45
+ }
46
+ // Second check: resolve symlinks and re-check
47
+ try {
48
+ const realPath = await fs.realpath(normalized);
49
+ if (!isAllowedPath(realPath)) {
50
+ throw new Error(`Path "${requestedPath}" resolves (via symlink) outside the working directory.`);
51
+ }
52
+ return realPath;
53
+ }
54
+ catch (err) {
55
+ if (err.code === 'ENOENT') {
56
+ // File doesn't exist yet — validate the parent directory instead
57
+ const parentDir = path.dirname(normalized);
58
+ try {
59
+ const realParent = await fs.realpath(parentDir);
60
+ if (!isAllowedPath(realParent)) {
61
+ throw new Error(`Parent directory of "${requestedPath}" resolves outside the working directory.`);
62
+ }
63
+ return path.join(realParent, path.basename(normalized));
64
+ }
65
+ catch {
66
+ throw new Error(`Parent directory of "${requestedPath}" does not exist.`);
67
+ }
68
+ }
69
+ throw err;
70
+ }
71
+ }
72
+ export function getWorkingDirectory() {
73
+ return workingDirectory;
74
+ }