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.
- package/README.md +99 -19
- package/dist/App.js +602 -0
- package/dist/agentic-loop.js +492 -525
- package/dist/cli.js +39 -0
- package/dist/components/CollapsibleBox.js +26 -0
- package/dist/components/ConfigDialog.js +40 -0
- package/dist/components/ConsolidatedToolMessage.js +41 -0
- package/dist/components/FormattedMessage.js +93 -0
- package/dist/components/Table.js +275 -0
- package/dist/config.js +171 -0
- package/dist/mcp.js +170 -0
- package/dist/providers.js +137 -0
- package/dist/sessions.js +161 -0
- package/dist/skills.js +229 -0
- package/dist/sub-agent.js +103 -0
- package/dist/system-prompt.js +131 -0
- package/dist/tools/bash.js +178 -0
- package/dist/tools/edit-file.js +65 -171
- package/dist/tools/index.js +79 -134
- package/dist/tools/list-directory.js +20 -73
- package/dist/tools/read-file.js +57 -101
- package/dist/tools/search-files.js +74 -162
- package/dist/tools/todo.js +57 -140
- package/dist/tools/webfetch.js +310 -0
- package/dist/tools/write-file.js +44 -135
- package/dist/utils/approval.js +69 -0
- package/dist/utils/compactor.js +87 -0
- package/dist/utils/cost-tracker.js +26 -81
- package/dist/utils/format-message.js +26 -0
- package/dist/utils/logger.js +101 -307
- package/dist/utils/path-validation.js +74 -0
- package/package.json +45 -51
- package/LICENSE +0 -21
- package/dist/config/client.js +0 -315
- package/dist/config/commands.js +0 -223
- package/dist/config/manager.js +0 -117
- package/dist/config/mcp-commands.js +0 -266
- package/dist/config/mcp-manager.js +0 -240
- package/dist/config/mcp-types.js +0 -28
- package/dist/config/providers.js +0 -229
- package/dist/config/setup.js +0 -209
- package/dist/config/system-prompt.js +0 -397
- package/dist/config/types.js +0 -4
- package/dist/index.js +0 -229
- package/dist/tools/create-directory.js +0 -76
- package/dist/tools/directory-operations.js +0 -195
- package/dist/tools/file-operations.js +0 -211
- package/dist/tools/run-shell-command.js +0 -746
- package/dist/tools/search-operations.js +0 -179
- package/dist/tools/shell-operations.js +0 -342
- package/dist/tools/task-complete.js +0 -26
- package/dist/tools/view-directory-tree.js +0 -125
- package/dist/tools.js +0 -2
- package/dist/utils/conversation-compactor.js +0 -140
- package/dist/utils/enhanced-prompt.js +0 -23
- package/dist/utils/file-operations-approval.js +0 -373
- package/dist/utils/interrupt-handler.js +0 -127
- package/dist/utils/user-cancellation.js +0 -34
|
@@ -1,107 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
29
|
-
for (const
|
|
30
|
-
tokens += estimateTokens(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
5
|
-
import
|
|
6
|
-
import
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
228
|
-
if
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
+
}
|