protoagent 0.0.1

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.
@@ -0,0 +1,136 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { randomBytes } from 'crypto';
4
+ import { requestFileOperationApproval } from '../utils/file-operations-approval.js';
5
+ import { logger } from '../utils/logger.js';
6
+ // Current working directory for file operations
7
+ const workingDirectory = process.cwd();
8
+ // Security utilities
9
+ function normalizePath(p) {
10
+ return path.normalize(p);
11
+ }
12
+ async function validatePath(requestedPath) {
13
+ const absolute = path.isAbsolute(requestedPath)
14
+ ? path.resolve(requestedPath)
15
+ : path.resolve(workingDirectory, requestedPath);
16
+ const normalizedRequested = normalizePath(absolute);
17
+ // Check if path is within working directory
18
+ if (!normalizedRequested.startsWith(workingDirectory)) {
19
+ throw new Error(`Access denied - path outside working directory: ${absolute}`);
20
+ }
21
+ // Handle symlinks by checking their real path
22
+ try {
23
+ const realPath = await fs.realpath(absolute);
24
+ const normalizedReal = normalizePath(realPath);
25
+ if (!normalizedReal.startsWith(workingDirectory)) {
26
+ throw new Error(`Access denied - symlink target outside working directory: ${realPath}`);
27
+ }
28
+ return realPath;
29
+ }
30
+ catch (error) {
31
+ // For new files that don't exist yet, verify parent directory
32
+ if (error.code === 'ENOENT') {
33
+ const parentDir = path.dirname(absolute);
34
+ try {
35
+ const realParentPath = await fs.realpath(parentDir);
36
+ const normalizedParent = normalizePath(realParentPath);
37
+ if (!normalizedParent.startsWith(workingDirectory)) {
38
+ throw new Error(`Access denied - parent directory outside working directory: ${realParentPath}`);
39
+ }
40
+ return absolute;
41
+ }
42
+ catch {
43
+ throw new Error(`Parent directory does not exist: ${parentDir}`);
44
+ }
45
+ }
46
+ throw error;
47
+ }
48
+ }
49
+ export async function writeFile(filePath, content) {
50
+ try {
51
+ const validPath = await validatePath(filePath);
52
+ // Check if file already exists for approval context
53
+ let fileExists = false;
54
+ try {
55
+ await fs.access(validPath);
56
+ fileExists = true;
57
+ }
58
+ catch {
59
+ // File doesn't exist, which is expected for write operations
60
+ }
61
+ // Request user approval for write operation
62
+ const approvalContext = {
63
+ operation: 'write',
64
+ filePath: filePath,
65
+ description: fileExists
66
+ ? `Overwrite existing file with ${content.length} characters of new content`
67
+ : `Create new file with ${content.length} characters of content`,
68
+ contentPreview: content.length > 500
69
+ ? `${content.substring(0, 250)}...\n\n...${content.substring(content.length - 250)}`
70
+ : content
71
+ };
72
+ const approved = await requestFileOperationApproval(approvalContext);
73
+ if (!approved) {
74
+ return `Write operation cancelled by user: ${filePath}`;
75
+ }
76
+ logger.debug(`šŸ“ Writing file: ${filePath} (${content.length} chars)`, { component: 'WriteFile', operation: 'writeFile' });
77
+ try {
78
+ // Security: 'wx' flag ensures exclusive creation - fails if file/symlink exists,
79
+ // preventing writes through pre-existing symlinks
80
+ await fs.writeFile(validPath, content, { encoding: "utf-8", flag: 'wx' });
81
+ logger.info(`āœ… Created new file: ${filePath}`, { component: 'WriteFile', operation: 'writeFile' });
82
+ }
83
+ catch (error) {
84
+ if (error.code === 'EEXIST') {
85
+ // Security: Use atomic rename to prevent race conditions where symlinks
86
+ // could be created between validation and write. Rename operations
87
+ // replace the target file atomically and don't follow symlinks.
88
+ const tempPath = `${validPath}.${randomBytes(16).toString('hex')}.tmp`;
89
+ try {
90
+ await fs.writeFile(tempPath, content, 'utf-8');
91
+ await fs.rename(tempPath, validPath);
92
+ logger.info(`āœ… Overwrote existing file: ${filePath}`, { component: 'WriteFile', operation: 'writeFile' });
93
+ }
94
+ catch (renameError) {
95
+ try {
96
+ await fs.unlink(tempPath);
97
+ }
98
+ catch { }
99
+ throw renameError;
100
+ }
101
+ }
102
+ else {
103
+ throw error;
104
+ }
105
+ }
106
+ return `Successfully wrote to ${filePath}`;
107
+ }
108
+ catch (error) {
109
+ if (error instanceof Error) {
110
+ throw new Error(`Failed to write file: ${error.message}`);
111
+ }
112
+ throw new Error('Failed to write file: Unknown error');
113
+ }
114
+ }
115
+ // Tool definition
116
+ export const writeFileTool = {
117
+ type: 'function',
118
+ function: {
119
+ name: 'write_file',
120
+ description: 'Create a new file or completely overwrite an existing file with new content. Use this when you need to create new files or update existing ones. The file will be created in the current working directory or subdirectories.',
121
+ parameters: {
122
+ type: 'object',
123
+ properties: {
124
+ file_path: {
125
+ type: 'string',
126
+ description: 'The path to the file to write, relative to the current working directory. Examples: "src/newfile.ts", "config.json", "README.md"'
127
+ },
128
+ content: {
129
+ type: 'string',
130
+ description: 'The content to write to the file. This will completely replace any existing content.'
131
+ }
132
+ },
133
+ required: ['file_path', 'content']
134
+ }
135
+ }
136
+ };
package/dist/tools.js ADDED
@@ -0,0 +1,2 @@
1
+ // Re-export everything from the tools directory
2
+ export * from './tools/index.js';
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Conversation history compaction utilities
3
+ */
4
+ /**
5
+ * Provides the system prompt for the history compression process.
6
+ * This prompt instructs the model to act as a specialized state manager,
7
+ * think in a scratchpad, and produce a structured XML summary.
8
+ */
9
+ export function getCompressionPrompt() {
10
+ return `
11
+ You are the component that summarizes internal chat history into a given structure.
12
+
13
+ When the conversation history grows too large, you will be invoked to distill the entire history into a concise, structured XML snapshot. This snapshot is CRITICAL, as it will become the agent's *only* memory of the past. The agent will resume its work based solely on this snapshot. All crucial details, plans, errors, and user directives MUST be preserved.
14
+
15
+ First, you will think through the entire history in a private <scratchpad>. Review the user's overall goal, the agent's actions, tool outputs, file modifications, and any unresolved questions. Identify every piece of information that is essential for future actions.
16
+
17
+ After your reasoning is complete, generate the final <state_snapshot> XML object. Be incredibly dense with information. Omit any irrelevant conversational filler.
18
+
19
+ The structure MUST be as follows:
20
+
21
+ <state_snapshot>
22
+ <overall_goal>
23
+ <!-- A single, concise sentence describing the user's high-level objective. -->
24
+ <!-- Example: "Refactor the authentication service to use a new JWT library." -->
25
+ </overall_goal>
26
+
27
+ <key_knowledge>
28
+ <!-- Crucial facts, conventions, and constraints the agent must remember based on the conversation history and interaction with the user. Use bullet points. -->
29
+ <!-- Example:
30
+ - Build Command: \`npm run build\`
31
+ - Testing: Tests are run with \`npm test\`. Test files must end in \`.test.ts\`.
32
+ - API Endpoint: The primary API endpoint is \`https://api.example.com/v2\`.
33
+
34
+ -->
35
+ </key_knowledge>
36
+
37
+ <file_system_state>
38
+ <!-- List files that have been created, read, modified, or deleted. Note their status and critical learnings. -->
39
+ <!-- Example:
40
+ - CWD: \`/home/user/project/src\`
41
+ - READ: \`package.json\` - Confirmed 'axios' is a dependency.
42
+ - MODIFIED: \`services/auth.ts\` - Replaced 'jsonwebtoken' with 'jose'.
43
+ - CREATED: \`tests/new-feature.test.ts\` - Initial test structure for the new feature.
44
+ -->
45
+ </file_system_state>
46
+
47
+ <recent_actions>
48
+ <!-- A summary of the last few significant agent actions and their outcomes. Focus on facts. -->
49
+ <!-- Example:
50
+ - Ran \`grep 'old_function'\` which returned 3 results in 2 files.
51
+ - Ran \`npm run test\`, which failed due to a snapshot mismatch in \`UserProfile.test.ts\`.
52
+ - Ran \`ls -F static/\` and discovered image assets are stored as \`.webp\`.
53
+ -->
54
+ </recent_actions>
55
+
56
+ <current_plan>
57
+ <!-- The agent's step-by-step plan. Mark completed steps. -->
58
+ <!-- Example:
59
+ 1. [DONE] Identify all files using the deprecated 'UserAPI'.
60
+ 2. [IN PROGRESS] Refactor \`src/components/UserProfile.tsx\` to use the new 'ProfileAPI'.
61
+ 3. [TODO] Refactor the remaining files.
62
+ 4. [TODO] Update tests to reflect the API change.
63
+ -->
64
+ </current_plan>
65
+ </state_snapshot>
66
+ `.trim();
67
+ }
68
+ /**
69
+ * Compact conversation history by creating a summary
70
+ */
71
+ export async function compactConversation(client, model, messages, recentMessagesToKeep = 5) {
72
+ console.log('\nšŸ—œļø Compacting conversation history to manage context window...');
73
+ // Keep the system message and recent messages
74
+ const systemMessage = messages.find(m => m.role === 'system');
75
+ const recentMessages = messages.slice(-recentMessagesToKeep);
76
+ // Get the history to compress (excluding system and recent messages)
77
+ const historyToCompress = messages.slice(systemMessage ? 1 : 0, -recentMessagesToKeep);
78
+ if (historyToCompress.length === 0) {
79
+ console.log(' No history to compress, keeping current messages');
80
+ return messages;
81
+ }
82
+ try {
83
+ // Create compression request
84
+ const compressionMessages = [
85
+ {
86
+ role: 'system',
87
+ content: getCompressionPrompt()
88
+ },
89
+ {
90
+ role: 'user',
91
+ content: `Please compress the following conversation history:\n\n${JSON.stringify(historyToCompress, null, 2)}`
92
+ }
93
+ ];
94
+ // Get compression summary (non-streaming for simplicity)
95
+ const response = await client.chat.completions.create({
96
+ model,
97
+ messages: compressionMessages,
98
+ max_tokens: 2000,
99
+ temperature: 0.1
100
+ });
101
+ const compressionSummary = response.choices[0]?.message?.content;
102
+ if (!compressionSummary) {
103
+ console.log(' Failed to generate compression summary, keeping original messages');
104
+ return messages;
105
+ }
106
+ // Create the compressed history message
107
+ const compressedMessage = {
108
+ role: 'system',
109
+ content: `Previous conversation summary:\n\n${compressionSummary}`
110
+ };
111
+ // Build new message array
112
+ const compactedMessages = [];
113
+ // Add original system message if it exists
114
+ if (systemMessage) {
115
+ compactedMessages.push(systemMessage);
116
+ }
117
+ // Add the compressed history
118
+ compactedMessages.push(compressedMessage);
119
+ // Add recent messages
120
+ compactedMessages.push(...recentMessages);
121
+ console.log(` āœ… Compressed ${historyToCompress.length} messages into summary`);
122
+ console.log(` šŸ“ Keeping ${recentMessages.length} recent messages`);
123
+ return compactedMessages;
124
+ }
125
+ catch (error) {
126
+ console.log(` āŒ Compression failed: ${error}. Keeping original messages.`);
127
+ return messages;
128
+ }
129
+ }
130
+ /**
131
+ * Check if conversation needs compaction and perform it if necessary
132
+ */
133
+ export async function checkAndCompactIfNeeded(client, model, messages, maxTokens, currentTokens) {
134
+ const utilizationPercentage = (currentTokens / maxTokens) * 100;
135
+ if (utilizationPercentage >= 90) {
136
+ return await compactConversation(client, model, messages);
137
+ }
138
+ return messages;
139
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Cost tracking and token counting utilities
3
+ */
4
+ /**
5
+ * Rough token estimation for OpenAI models
6
+ * This is approximate - actual tokenization may vary
7
+ */
8
+ export function estimateTokens(text) {
9
+ if (!text)
10
+ return 0;
11
+ // Rough estimation: ~4 characters per token for English text
12
+ // This is conservative and should be close enough for cost estimation
13
+ return Math.ceil(text.length / 4);
14
+ }
15
+ /**
16
+ * Estimate tokens for a message
17
+ */
18
+ export function estimateMessageTokens(message) {
19
+ let tokens = 0;
20
+ // Base overhead per message
21
+ tokens += 4;
22
+ if ('content' in message && message.content) {
23
+ if (typeof message.content === 'string') {
24
+ tokens += estimateTokens(message.content);
25
+ }
26
+ }
27
+ if ('tool_calls' in message && message.tool_calls) {
28
+ for (const toolCall of message.tool_calls) {
29
+ tokens += estimateTokens(toolCall.function.name);
30
+ tokens += estimateTokens(toolCall.function.arguments);
31
+ tokens += 10; // overhead per tool call
32
+ }
33
+ }
34
+ return tokens;
35
+ }
36
+ /**
37
+ * Estimate total tokens for conversation history
38
+ */
39
+ export function estimateConversationTokens(messages) {
40
+ let totalTokens = 0;
41
+ for (const message of messages) {
42
+ totalTokens += estimateMessageTokens(message);
43
+ }
44
+ // Add some overhead for the conversation structure
45
+ totalTokens += 10;
46
+ return totalTokens;
47
+ }
48
+ /**
49
+ * Calculate estimated cost for a request
50
+ */
51
+ export function calculateCost(inputTokens, outputTokens, modelConfig) {
52
+ const inputCost = inputTokens * modelConfig.pricing.inputTokens;
53
+ const outputCost = outputTokens * modelConfig.pricing.outputTokens;
54
+ return inputCost + outputCost;
55
+ }
56
+ /**
57
+ * Get context information for the current conversation
58
+ */
59
+ export function getContextInfo(messages, modelConfig) {
60
+ const currentTokens = estimateConversationTokens(messages);
61
+ const maxTokens = modelConfig.contextWindow;
62
+ const utilizationPercentage = (currentTokens / maxTokens) * 100;
63
+ const needsCompaction = utilizationPercentage >= 90; // Compact at 90% capacity
64
+ return {
65
+ currentTokens,
66
+ maxTokens,
67
+ utilizationPercentage,
68
+ needsCompaction
69
+ };
70
+ }
71
+ /**
72
+ * Log usage and cost information
73
+ */
74
+ export function logUsageInfo(usage, contextInfo, modelConfig) {
75
+ console.log(`šŸ’° Usage: ${usage.inputTokens} in + ${usage.outputTokens} out = ${usage.totalTokens} tokens`);
76
+ console.log(`šŸ’ø Estimated cost: $${usage.estimatedCost.toFixed(6)}`);
77
+ console.log(`šŸ“Š Context: ${contextInfo.currentTokens}/${contextInfo.maxTokens} tokens (${contextInfo.utilizationPercentage.toFixed(1)}%)`);
78
+ if (contextInfo.needsCompaction) {
79
+ console.log(`āš ļø Context approaching limit - automatic compaction will trigger soon`);
80
+ }
81
+ }
82
+ /**
83
+ * Create usage info from response tokens
84
+ */
85
+ export function createUsageInfo(inputTokens, outputTokens, modelConfig) {
86
+ const totalTokens = inputTokens + outputTokens;
87
+ const estimatedCost = calculateCost(inputTokens, outputTokens, modelConfig);
88
+ return {
89
+ inputTokens,
90
+ outputTokens,
91
+ totalTokens,
92
+ estimatedCost
93
+ };
94
+ }
95
+ /**
96
+ * Extract token usage from OpenAI response
97
+ */
98
+ export function extractTokenUsage(response) {
99
+ // For streaming responses, we need to estimate
100
+ // In a real implementation, you might want to sum up the tokens from the stream
101
+ // For now, we'll use rough estimation
102
+ return {
103
+ inputTokens: 0, // Will be estimated separately
104
+ outputTokens: 0 // Will be estimated separately
105
+ };
106
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * File operations approval utility for ProtoAgent
3
+ * Handles user acceptance for write and edit file operations
4
+ */
5
+ import inquirer from 'inquirer';
6
+ import { logger } from './logger.js';
7
+ // Global state for approval settings
8
+ let globalConfig = null;
9
+ let dangerouslyAcceptAll = false;
10
+ let approvedOperationsForSession = new Set();
11
+ export function setFileOperationConfig(config) {
12
+ globalConfig = config;
13
+ }
14
+ export function setDangerouslyAcceptAllFileOps(accept) {
15
+ dangerouslyAcceptAll = accept;
16
+ if (accept) {
17
+ logger.warn('āš ļø DANGER MODE: All file operations will be auto-approved without confirmation!');
18
+ }
19
+ }
20
+ /**
21
+ * Request user approval for file write/edit operations
22
+ */
23
+ export async function requestFileOperationApproval(context) {
24
+ const { operation, filePath, description, contentPreview } = context;
25
+ // Check if we should auto-approve
26
+ if (dangerouslyAcceptAll) {
27
+ logger.info(`šŸš€ Auto-approving (--dangerously-accept-all): ${operation} ${filePath}`);
28
+ return true;
29
+ }
30
+ const operationType = operation === 'write' ? 'write_file' : 'edit_file';
31
+ if (approvedOperationsForSession.has(operationType)) {
32
+ logger.info(`šŸš€ Auto-approving (${operationType} approved for session): ${operation} ${filePath}`);
33
+ return true;
34
+ }
35
+ // Show detailed information about the operation
36
+ console.log(`\nšŸ” File Operation Requested: ${operation.toUpperCase()}`);
37
+ console.log(`šŸ“ File: ${filePath}`);
38
+ console.log(`šŸ“ Description: ${description}`);
39
+ if (contentPreview) {
40
+ console.log(`\nšŸ“„ Content Preview:`);
41
+ const lines = contentPreview.split('\n');
42
+ if (lines.length > 10) {
43
+ console.log(lines.slice(0, 5).join('\n'));
44
+ console.log(`... (${lines.length - 10} more lines) ...`);
45
+ console.log(lines.slice(-5).join('\n'));
46
+ }
47
+ else {
48
+ console.log(contentPreview);
49
+ }
50
+ }
51
+ // Ask for user approval with enhanced options
52
+ const { choice } = await inquirer.prompt([
53
+ {
54
+ type: 'list',
55
+ name: 'choice',
56
+ message: 'Choose your action:',
57
+ choices: [
58
+ { name: `1. āœ… Approve this ${operation} operation`, value: 'approve' },
59
+ { name: `2. āœ… Approve and allow all ${operation} operations for this session`, value: 'approve_session' },
60
+ { name: '3. āŒ Cancel this operation', value: 'cancel' },
61
+ { name: '4. šŸ“ Show more details and decide', value: 'details' }
62
+ ],
63
+ default: 'approve'
64
+ }
65
+ ]);
66
+ switch (choice) {
67
+ case 'approve':
68
+ logger.info(`āœ… User approved: ${operation} ${filePath}`);
69
+ return true;
70
+ case 'approve_session':
71
+ approvedOperationsForSession.add(operationType);
72
+ logger.info(`šŸ”“ "${operationType}" approved for session - all future ${operation} operations will auto-execute`);
73
+ logger.info(`āœ… User approved: ${operation} ${filePath}`);
74
+ return true;
75
+ case 'cancel':
76
+ logger.info(`āŒ User cancelled: ${operation} ${filePath}`);
77
+ return false;
78
+ case 'details':
79
+ await showFileOperationDetails(context);
80
+ // Recursively ask again after showing details
81
+ return await requestFileOperationApproval(context);
82
+ default:
83
+ logger.info(`āŒ User cancelled: ${operation} ${filePath}`);
84
+ return false;
85
+ }
86
+ }
87
+ /**
88
+ * Show detailed information about the file operation
89
+ */
90
+ async function showFileOperationDetails(context) {
91
+ const { operation, filePath, description, contentPreview } = context;
92
+ console.log(`\nšŸ“‹ Detailed File Operation Information:`);
93
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
94
+ console.log(`šŸ”§ Operation Type: ${operation.toUpperCase()}`);
95
+ console.log(`šŸ“ Target File: ${filePath}`);
96
+ console.log(`šŸ“ Description: ${description}`);
97
+ console.log(`šŸ“‚ Working Directory: ${process.cwd()}`);
98
+ // Show file status
99
+ try {
100
+ const fs = await import('fs/promises');
101
+ const stats = await fs.stat(filePath);
102
+ console.log(`šŸ“Š File exists: YES`);
103
+ console.log(`šŸ“… Last modified: ${stats.mtime.toLocaleString()}`);
104
+ console.log(`šŸ“ File size: ${stats.size} bytes`);
105
+ }
106
+ catch (error) {
107
+ console.log(`šŸ“Š File exists: NO (will be created)`);
108
+ }
109
+ // Show full content if available
110
+ if (contentPreview) {
111
+ console.log(`\nšŸ“„ Full Content Preview:`);
112
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
113
+ console.log(contentPreview);
114
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
115
+ }
116
+ // Show security information
117
+ console.log(`\nšŸ”’ Security Information:`);
118
+ console.log(`• File path is validated and restricted to working directory`);
119
+ console.log(`• Operation will be performed atomically with backup`);
120
+ console.log(`• You can undo changes using git if needed`);
121
+ console.log(`\nšŸ’” Recommendation:`);
122
+ if (operation === 'write' && contentPreview) {
123
+ console.log(`• WRITE operation will ${contentPreview.includes('export') ? 'create a new file' : 'replace file content'}`);
124
+ console.log(`• Consider approving if the content looks correct`);
125
+ }
126
+ else if (operation === 'edit') {
127
+ console.log(`• EDIT operation will make targeted changes to existing file`);
128
+ console.log(`• Generally safer than write operations`);
129
+ }
130
+ await inquirer.prompt([{ type: 'input', name: 'continue', message: 'Press Enter to continue...' }]);
131
+ }
132
+ /**
133
+ * Get current session approval status
134
+ */
135
+ export function getSessionApprovalStatus() {
136
+ return {
137
+ writeApproved: approvedOperationsForSession.has('write_file'),
138
+ editApproved: approvedOperationsForSession.has('edit_file'),
139
+ dangerousMode: dangerouslyAcceptAll
140
+ };
141
+ }
142
+ /**
143
+ * Clear session approvals
144
+ */
145
+ export function clearSessionApprovals() {
146
+ approvedOperationsForSession.clear();
147
+ logger.info('šŸ”„ Session approvals cleared');
148
+ }
149
+ /**
150
+ * Show current approval status
151
+ */
152
+ export function showApprovalStatus() {
153
+ const status = getSessionApprovalStatus();
154
+ console.log('\nšŸ“Š Current File Operation Approval Status:');
155
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
156
+ console.log(`šŸš€ Dangerous Mode (auto-approve all): ${status.dangerousMode ? 'āœ… ENABLED' : 'āŒ Disabled'}`);
157
+ console.log(`āœļø Edit operations for session: ${status.editApproved ? 'āœ… Auto-approved' : 'āŒ Require approval'}`);
158
+ console.log(`šŸ“ Write operations for session: ${status.writeApproved ? 'āœ… Auto-approved' : 'āŒ Require approval'}`);
159
+ if (status.dangerousMode) {
160
+ console.log(`\nāš ļø WARNING: Dangerous mode is enabled - all file operations will be auto-approved!`);
161
+ }
162
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
163
+ }