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
package/dist/tools/read-file.js
CHANGED
|
@@ -1,111 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 readFile(filePath, offset, limit) {
|
|
50
|
-
try {
|
|
51
|
-
const validPath = await validatePath(filePath);
|
|
52
|
-
// Check if file exists and is actually a file
|
|
53
|
-
const stats = await fs.stat(validPath);
|
|
54
|
-
if (!stats.isFile()) {
|
|
55
|
-
throw new Error('Path is not a file');
|
|
56
|
-
}
|
|
57
|
-
// Read and return file content
|
|
58
|
-
const content = await fs.readFile(validPath, 'utf-8');
|
|
59
|
-
const lines = content.split('\n');
|
|
60
|
-
// Apply offset and limit if specified
|
|
61
|
-
const startLine = offset ? Math.max(0, offset - 1) : 0; // Convert to 0-based indexing
|
|
62
|
-
const endLine = limit ? Math.min(lines.length, startLine + limit) : Math.min(lines.length, startLine + DEFAULT_READ_LIMIT);
|
|
63
|
-
// Truncate lines that are too long
|
|
64
|
-
const processedLines = lines.slice(startLine, endLine).map(line => line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + '...[truncated]' : line);
|
|
65
|
-
const result = processedLines.join('\n');
|
|
66
|
-
// Add metadata if file was truncated or offset was used
|
|
67
|
-
let metadata = '';
|
|
68
|
-
if (offset && offset > 1) {
|
|
69
|
-
metadata += `\n[File started at line ${offset}]`;
|
|
70
|
-
}
|
|
71
|
-
if (endLine < lines.length) {
|
|
72
|
-
metadata += `\n[File truncated - showing lines ${startLine + 1}-${endLine} of ${lines.length} total lines]`;
|
|
73
|
-
}
|
|
74
|
-
if (processedLines.some(line => line.includes('...[truncated]'))) {
|
|
75
|
-
metadata += `\n[Some lines truncated at ${MAX_LINE_LENGTH} characters]`;
|
|
76
|
-
}
|
|
77
|
-
return result + metadata;
|
|
78
|
-
}
|
|
79
|
-
catch (error) {
|
|
80
|
-
if (error instanceof Error) {
|
|
81
|
-
throw new Error(`Failed to read file: ${error.message}`);
|
|
82
|
-
}
|
|
83
|
-
throw new Error('Failed to read file: Unknown error');
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
// Tool definition
|
|
1
|
+
/**
|
|
2
|
+
* read_file tool — Read file contents with optional offset and limit.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { createReadStream } from 'node:fs';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { validatePath } from '../utils/path-validation.js';
|
|
87
8
|
export const readFileTool = {
|
|
88
9
|
type: 'function',
|
|
89
10
|
function: {
|
|
90
11
|
name: 'read_file',
|
|
91
|
-
description: 'Read the contents of a file
|
|
12
|
+
description: 'Read the contents of a file. Returns the file content with line numbers. Use offset and limit to read specific sections of large files.',
|
|
92
13
|
parameters: {
|
|
93
14
|
type: 'object',
|
|
94
15
|
properties: {
|
|
95
|
-
file_path: {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
},
|
|
99
|
-
offset: {
|
|
100
|
-
type: 'integer',
|
|
101
|
-
description: 'Optional: The 1-based line number to start reading from. If not specified, reads from the beginning of the file.'
|
|
102
|
-
},
|
|
103
|
-
limit: {
|
|
104
|
-
type: 'integer',
|
|
105
|
-
description: 'Optional: The maximum number of lines to read. If not specified, reads up to 2000 lines. Use this with offset to read specific sections of large files.'
|
|
106
|
-
}
|
|
16
|
+
file_path: { type: 'string', description: 'Path to the file to read (relative to working directory).' },
|
|
17
|
+
offset: { type: 'number', description: 'Line number to start reading from (0-based). Defaults to 0.' },
|
|
18
|
+
limit: { type: 'number', description: 'Maximum number of lines to read. Defaults to 2000.' },
|
|
107
19
|
},
|
|
108
|
-
required: ['file_path']
|
|
20
|
+
required: ['file_path'],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export async function readFile(filePath, offset = 0, limit = 2000) {
|
|
25
|
+
const validated = await validatePath(filePath);
|
|
26
|
+
const start = Math.max(0, offset);
|
|
27
|
+
const maxLines = Math.max(0, limit);
|
|
28
|
+
const lines = [];
|
|
29
|
+
let totalLines = 0;
|
|
30
|
+
const stream = createReadStream(validated, { encoding: 'utf8' });
|
|
31
|
+
const lineReader = readline.createInterface({
|
|
32
|
+
input: stream,
|
|
33
|
+
crlfDelay: Infinity,
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
for await (const line of lineReader) {
|
|
37
|
+
if (totalLines >= start && lines.length < maxLines) {
|
|
38
|
+
lines.push(line);
|
|
39
|
+
}
|
|
40
|
+
totalLines++;
|
|
41
|
+
}
|
|
42
|
+
const stats = await fs.stat(validated);
|
|
43
|
+
if (stats.size === 0) {
|
|
44
|
+
totalLines = 0;
|
|
45
|
+
}
|
|
46
|
+
else if (lines.length === 0 && totalLines === 0) {
|
|
47
|
+
totalLines = 1;
|
|
109
48
|
}
|
|
110
49
|
}
|
|
111
|
-
|
|
50
|
+
finally {
|
|
51
|
+
lineReader.close();
|
|
52
|
+
stream.destroy();
|
|
53
|
+
}
|
|
54
|
+
const end = Math.min(totalLines, start + lines.length);
|
|
55
|
+
// Add line numbers (1-based)
|
|
56
|
+
const numbered = lines.map((line, i) => {
|
|
57
|
+
const lineNum = String(start + i + 1).padStart(5, ' ');
|
|
58
|
+
// Truncate very long lines
|
|
59
|
+
const truncated = line.length > 2000 ? line.slice(0, 2000) + '... (truncated)' : line;
|
|
60
|
+
return `${lineNum} | ${truncated}`;
|
|
61
|
+
});
|
|
62
|
+
const rangeLabel = lines.length === 0
|
|
63
|
+
? 'none'
|
|
64
|
+
: `${Math.min(start + 1, totalLines)}-${end}`;
|
|
65
|
+
const header = `File: ${filePath} (${totalLines} lines total, showing ${rangeLabel})`;
|
|
66
|
+
return `${header}\n${numbered.join('\n')}`;
|
|
67
|
+
}
|
|
@@ -1,177 +1,89 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const realParentPath = await fs.realpath(parentDir);
|
|
33
|
-
const normalizedParent = normalizePath(realParentPath);
|
|
34
|
-
if (!normalizedParent.startsWith(workingDirectory)) {
|
|
35
|
-
throw new Error(`Access denied - parent directory outside working directory: ${realParentPath}`);
|
|
36
|
-
}
|
|
37
|
-
return absolute;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
throw new Error(`Parent directory does not exist: ${parentDir}`);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
// Search utility function
|
|
47
|
-
async function searchInFile(filePath, searchTerm, caseSensitive = false) {
|
|
1
|
+
/**
|
|
2
|
+
* search_files tool — Recursive text search across files.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { validatePath } from '../utils/path-validation.js';
|
|
7
|
+
export const searchFilesTool = {
|
|
8
|
+
type: 'function',
|
|
9
|
+
function: {
|
|
10
|
+
name: 'search_files',
|
|
11
|
+
description: 'Search for a text pattern across files in a directory (recursive). Returns matching lines with file paths and line numbers.',
|
|
12
|
+
parameters: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
search_term: { type: 'string', description: 'The text or regex pattern to search for.' },
|
|
16
|
+
directory_path: { type: 'string', description: 'Directory to search in. Defaults to ".".' },
|
|
17
|
+
file_extensions: {
|
|
18
|
+
type: 'array',
|
|
19
|
+
items: { type: 'string' },
|
|
20
|
+
description: 'Filter by file extensions, e.g. [".ts", ".js"]. Searches all files if omitted.',
|
|
21
|
+
},
|
|
22
|
+
case_sensitive: { type: 'boolean', description: 'Whether the search is case-sensitive. Defaults to true.' },
|
|
23
|
+
},
|
|
24
|
+
required: ['search_term'],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export async function searchFiles(searchTerm, directoryPath = '.', caseSensitive = true, fileExtensions) {
|
|
29
|
+
const validated = await validatePath(directoryPath);
|
|
30
|
+
const flags = caseSensitive ? 'g' : 'gi';
|
|
31
|
+
let regex;
|
|
48
32
|
try {
|
|
49
|
-
|
|
50
|
-
const lines = content.split('\n');
|
|
51
|
-
const matchingLines = [];
|
|
52
|
-
let totalMatches = 0;
|
|
53
|
-
const searchPattern = caseSensitive ? searchTerm : searchTerm.toLowerCase();
|
|
54
|
-
lines.forEach((line, index) => {
|
|
55
|
-
const searchLine = caseSensitive ? line : line.toLowerCase();
|
|
56
|
-
if (searchLine.includes(searchPattern)) {
|
|
57
|
-
totalMatches++;
|
|
58
|
-
matchingLines.push(`${index + 1}: ${line}`);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
return { matches: totalMatches, lines: matchingLines };
|
|
33
|
+
regex = new RegExp(searchTerm, flags);
|
|
62
34
|
}
|
|
63
35
|
catch (error) {
|
|
64
|
-
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
return `Error: invalid regex pattern "${searchTerm}": ${message}`;
|
|
65
38
|
}
|
|
66
|
-
}
|
|
67
|
-
async function searchInDirectory(dirPath, searchTerm, caseSensitive = false, fileExtensions = []) {
|
|
68
|
-
const validPath = await validatePath(dirPath);
|
|
69
39
|
const results = [];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
40
|
+
const MAX_RESULTS = 100;
|
|
41
|
+
async function search(dir) {
|
|
42
|
+
if (results.length >= MAX_RESULTS)
|
|
43
|
+
return;
|
|
44
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (results.length >= MAX_RESULTS)
|
|
47
|
+
break;
|
|
48
|
+
const fullPath = path.join(dir, entry.name);
|
|
49
|
+
// Skip common non-useful directories
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (['node_modules', '.git', 'dist', 'build', 'coverage', '__pycache__'].includes(entry.name))
|
|
79
52
|
continue;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
53
|
+
await search(fullPath);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Filter by extension
|
|
57
|
+
if (fileExtensions && fileExtensions.length > 0) {
|
|
58
|
+
const ext = path.extname(entry.name);
|
|
59
|
+
if (!fileExtensions.includes(ext))
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
|
64
|
+
const lines = content.split('\n');
|
|
65
|
+
for (let i = 0; i < lines.length && results.length < MAX_RESULTS; i++) {
|
|
66
|
+
if (regex.test(lines[i])) {
|
|
67
|
+
const relativePath = path.relative(validated, fullPath);
|
|
68
|
+
let lineContent = lines[i].trim();
|
|
69
|
+
// Truncate long lines
|
|
70
|
+
if (lineContent.length > 500) {
|
|
71
|
+
lineContent = lineContent.slice(0, 500) + '... (truncated)';
|
|
98
72
|
}
|
|
73
|
+
results.push(`${relativePath}:${i + 1}: ${lineContent}`);
|
|
99
74
|
}
|
|
100
|
-
|
|
101
|
-
// Skip files we can't read
|
|
102
|
-
}
|
|
75
|
+
regex.lastIndex = 0; // reset regex state
|
|
103
76
|
}
|
|
104
77
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
78
|
+
catch {
|
|
79
|
+
// Skip files we can't read (binary, permission issues)
|
|
80
|
+
}
|
|
108
81
|
}
|
|
109
82
|
}
|
|
110
|
-
await
|
|
83
|
+
await search(validated);
|
|
111
84
|
if (results.length === 0) {
|
|
112
|
-
return `No matches found for "${searchTerm}" in ${
|
|
113
|
-
}
|
|
114
|
-
return results.join('\n');
|
|
115
|
-
}
|
|
116
|
-
export async function searchFiles(searchTerm, dirPath = '.', caseSensitive = false, fileExtensions = []) {
|
|
117
|
-
try {
|
|
118
|
-
if (!searchTerm.trim()) {
|
|
119
|
-
throw new Error('Search term cannot be empty');
|
|
120
|
-
}
|
|
121
|
-
const validPath = await validatePath(dirPath);
|
|
122
|
-
// Check if path exists and is a directory
|
|
123
|
-
const stats = await fs.stat(validPath);
|
|
124
|
-
if (!stats.isDirectory()) {
|
|
125
|
-
throw new Error('Search path must be a directory');
|
|
126
|
-
}
|
|
127
|
-
const results = await searchInDirectory(validPath, searchTerm, caseSensitive, fileExtensions);
|
|
128
|
-
const searchInfo = [
|
|
129
|
-
`🔍 Searching for: "${searchTerm}"`,
|
|
130
|
-
`📂 In directory: ${path.relative(workingDirectory, validPath) || '.'}`,
|
|
131
|
-
`📝 Case sensitive: ${caseSensitive ? 'Yes' : 'No'}`,
|
|
132
|
-
fileExtensions.length > 0 ? `🎯 File types: ${fileExtensions.join(', ')}` : '🎯 All file types',
|
|
133
|
-
'',
|
|
134
|
-
].join('\n');
|
|
135
|
-
return searchInfo + results;
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
if (error instanceof Error) {
|
|
139
|
-
throw new Error(`Failed to search files: ${error.message}`);
|
|
140
|
-
}
|
|
141
|
-
throw new Error('Failed to search files: Unknown error');
|
|
85
|
+
return `No matches found for "${searchTerm}" in ${directoryPath}`;
|
|
142
86
|
}
|
|
87
|
+
const suffix = results.length >= MAX_RESULTS ? `\n(results truncated at ${MAX_RESULTS})` : '';
|
|
88
|
+
return `Found ${results.length} match(es) for "${searchTerm}":\n${results.join('\n')}${suffix}`;
|
|
143
89
|
}
|
|
144
|
-
// Tool definition
|
|
145
|
-
export const searchFilesTool = {
|
|
146
|
-
type: 'function',
|
|
147
|
-
function: {
|
|
148
|
-
name: 'search_files',
|
|
149
|
-
description: 'Search for a text string within files in a directory. Supports case sensitivity and file type filtering. Returns the list of files and matching lines. Use this to find specific code, configuration settings, or documentation within your project files.',
|
|
150
|
-
parameters: {
|
|
151
|
-
type: 'object',
|
|
152
|
-
properties: {
|
|
153
|
-
search_term: {
|
|
154
|
-
type: 'string',
|
|
155
|
-
description: 'The text string to search for within files.'
|
|
156
|
-
},
|
|
157
|
-
directory_path: {
|
|
158
|
-
type: 'string',
|
|
159
|
-
description: 'The directory to search within, relative to the current working directory. Use "." for the current directory.'
|
|
160
|
-
},
|
|
161
|
-
case_sensitive: {
|
|
162
|
-
type: 'boolean',
|
|
163
|
-
description: 'Whether the search should be case sensitive.'
|
|
164
|
-
},
|
|
165
|
-
file_extensions: {
|
|
166
|
-
type: 'array',
|
|
167
|
-
items: {
|
|
168
|
-
type: 'string',
|
|
169
|
-
description: 'File extension to include in the search (e.g., "js", "txt"). Leave empty to search all file types.'
|
|
170
|
-
},
|
|
171
|
-
description: 'List of file extensions to include in the search.'
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
required: ['search_term', 'directory_path']
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
};
|
package/dist/tools/todo.js
CHANGED
|
@@ -1,177 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* todo_read / todo_write tools - in-memory task tracking.
|
|
3
|
+
*
|
|
4
|
+
* The agent uses these to plan multi-step work and track progress.
|
|
5
|
+
* Todos are stored per session. The active session can also persist them
|
|
6
|
+
* through the session store.
|
|
3
7
|
*/
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
status: z.enum(['pending', 'in_progress', 'completed', 'cancelled']).describe('Current status of the task'),
|
|
10
|
-
priority: z.enum(['high', 'medium', 'low']).describe('Priority level of the task'),
|
|
11
|
-
created: z.string().optional().describe('Creation timestamp'),
|
|
12
|
-
updated: z.string().optional().describe('Last update timestamp')
|
|
13
|
-
});
|
|
14
|
-
// In-memory storage for todo items (could be persisted to file later)
|
|
15
|
-
const todoStorage = {};
|
|
16
|
-
/**
|
|
17
|
-
* Get current session ID (simplified - could use actual session management)
|
|
18
|
-
*/
|
|
19
|
-
function getCurrentSessionId() {
|
|
20
|
-
return 'default_session';
|
|
8
|
+
const DEFAULT_SESSION_ID = '__default__';
|
|
9
|
+
// Session-scoped in-memory storage
|
|
10
|
+
const todosBySession = new Map();
|
|
11
|
+
function getSessionKey(sessionId) {
|
|
12
|
+
return sessionId ?? DEFAULT_SESSION_ID;
|
|
21
13
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function
|
|
26
|
-
|
|
14
|
+
function cloneTodos(todos) {
|
|
15
|
+
return todos.map((todo) => ({ ...todo }));
|
|
16
|
+
}
|
|
17
|
+
function formatTodos(todos, heading) {
|
|
18
|
+
if (todos.length === 0) {
|
|
19
|
+
return `${heading}\nNo TODOs.`;
|
|
20
|
+
}
|
|
21
|
+
const statusIcons = {
|
|
22
|
+
pending: '[ ]',
|
|
23
|
+
in_progress: '[~]',
|
|
24
|
+
completed: '[x]',
|
|
25
|
+
cancelled: '[-]',
|
|
26
|
+
};
|
|
27
|
+
const lines = todos.map((t) => `${statusIcons[t.status]} [${t.priority}] ${t.content} (${t.id})`);
|
|
28
|
+
return `${heading}\n${lines.join('\n')}`;
|
|
27
29
|
}
|
|
28
|
-
/**
|
|
29
|
-
* Tool for reading the current todo list
|
|
30
|
-
*/
|
|
31
30
|
export const todoReadTool = {
|
|
32
31
|
type: 'function',
|
|
33
32
|
function: {
|
|
34
33
|
name: 'todo_read',
|
|
35
|
-
description: 'Read the current
|
|
34
|
+
description: 'Read the current TODO list to check progress on tasks.',
|
|
36
35
|
parameters: {
|
|
37
36
|
type: 'object',
|
|
38
37
|
properties: {},
|
|
39
|
-
required: []
|
|
40
|
-
}
|
|
41
|
-
}
|
|
38
|
+
required: [],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
42
41
|
};
|
|
43
|
-
/**
|
|
44
|
-
* Tool for writing/updating the todo list
|
|
45
|
-
*/
|
|
46
42
|
export const todoWriteTool = {
|
|
47
43
|
type: 'function',
|
|
48
44
|
function: {
|
|
49
45
|
name: 'todo_write',
|
|
50
|
-
description: '
|
|
46
|
+
description: 'Replace the TODO list with an updated version. Use this to plan tasks, update progress, and mark items complete.',
|
|
51
47
|
parameters: {
|
|
52
48
|
type: 'object',
|
|
53
49
|
properties: {
|
|
54
50
|
todos: {
|
|
55
51
|
type: 'array',
|
|
56
|
-
description: 'The complete updated
|
|
52
|
+
description: 'The complete updated TODO list.',
|
|
57
53
|
items: {
|
|
58
54
|
type: 'object',
|
|
59
55
|
properties: {
|
|
60
|
-
id: {
|
|
61
|
-
|
|
62
|
-
description: 'Unique identifier for the todo item. Use existing ID to update, or provide new ID for new items.'
|
|
63
|
-
},
|
|
64
|
-
content: {
|
|
65
|
-
type: 'string',
|
|
66
|
-
description: 'Brief description of the task'
|
|
67
|
-
},
|
|
56
|
+
id: { type: 'string', description: 'Unique identifier for the item.' },
|
|
57
|
+
content: { type: 'string', description: 'Description of the task.' },
|
|
68
58
|
status: {
|
|
69
59
|
type: 'string',
|
|
70
60
|
enum: ['pending', 'in_progress', 'completed', 'cancelled'],
|
|
71
|
-
description: 'Current status
|
|
61
|
+
description: 'Current status.',
|
|
72
62
|
},
|
|
73
63
|
priority: {
|
|
74
64
|
type: 'string',
|
|
75
65
|
enum: ['high', 'medium', 'low'],
|
|
76
|
-
description: 'Priority level
|
|
77
|
-
}
|
|
66
|
+
description: 'Priority level.',
|
|
67
|
+
},
|
|
78
68
|
},
|
|
79
|
-
required: ['id', 'content', 'status', 'priority']
|
|
80
|
-
}
|
|
81
|
-
}
|
|
69
|
+
required: ['id', 'content', 'status', 'priority'],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
82
72
|
},
|
|
83
|
-
required: ['todos']
|
|
84
|
-
}
|
|
85
|
-
}
|
|
73
|
+
required: ['todos'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
86
76
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
export async function handleTodoRead() {
|
|
91
|
-
const sessionId = getCurrentSessionId();
|
|
92
|
-
const todos = todoStorage[sessionId] || [];
|
|
93
|
-
if (todos.length === 0) {
|
|
94
|
-
return 'Todo list is empty. No tasks found.';
|
|
95
|
-
}
|
|
96
|
-
const activeTodos = todos.filter(t => t.status !== 'completed');
|
|
97
|
-
const completedTodos = todos.filter(t => t.status === 'completed');
|
|
98
|
-
let result = `Todo List (${activeTodos.length} active, ${completedTodos.length} completed):\n\n`;
|
|
99
|
-
// Show active todos first
|
|
100
|
-
if (activeTodos.length > 0) {
|
|
101
|
-
result += '📋 ACTIVE TASKS:\n';
|
|
102
|
-
activeTodos.forEach((todo, index) => {
|
|
103
|
-
const priorityIcon = todo.priority === 'high' ? '🔴' : todo.priority === 'medium' ? '🟡' : '🟢';
|
|
104
|
-
const statusIcon = todo.status === 'in_progress' ? '🔄' : '⏳';
|
|
105
|
-
result += `${index + 1}. ${statusIcon} ${priorityIcon} ${todo.content} [${todo.status.toUpperCase()}]\n`;
|
|
106
|
-
result += ` ID: ${todo.id}\n\n`;
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
// Show completed todos
|
|
110
|
-
if (completedTodos.length > 0) {
|
|
111
|
-
result += '✅ COMPLETED TASKS:\n';
|
|
112
|
-
completedTodos.forEach((todo, index) => {
|
|
113
|
-
result += `${index + 1}. ✅ ${todo.content}\n`;
|
|
114
|
-
result += ` ID: ${todo.id}\n\n`;
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
return result.trim();
|
|
77
|
+
export function readTodos(sessionId) {
|
|
78
|
+
const todos = todosBySession.get(getSessionKey(sessionId)) ?? [];
|
|
79
|
+
return formatTodos(todos, `TODO List (${todos.length} items):`);
|
|
118
80
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const sessionId = getCurrentSessionId();
|
|
124
|
-
try {
|
|
125
|
-
// Validate and process todos
|
|
126
|
-
const validatedTodos = [];
|
|
127
|
-
const timestamp = new Date().toISOString();
|
|
128
|
-
for (const todo of todos) {
|
|
129
|
-
// Parse and validate each todo item
|
|
130
|
-
const parsed = TodoItemSchema.parse({
|
|
131
|
-
...todo,
|
|
132
|
-
updated: timestamp,
|
|
133
|
-
created: todo.created || timestamp
|
|
134
|
-
});
|
|
135
|
-
validatedTodos.push(parsed);
|
|
136
|
-
}
|
|
137
|
-
// Update storage
|
|
138
|
-
todoStorage[sessionId] = validatedTodos;
|
|
139
|
-
// Generate summary
|
|
140
|
-
const activeTodos = validatedTodos.filter(t => t.status !== 'completed');
|
|
141
|
-
const completedTodos = validatedTodos.filter(t => t.status === 'completed');
|
|
142
|
-
const highPriorityTodos = activeTodos.filter(t => t.priority === 'high');
|
|
143
|
-
let result = `✅ Todo list updated successfully!\n\n`;
|
|
144
|
-
result += `📊 Summary:\n`;
|
|
145
|
-
result += `- Total tasks: ${validatedTodos.length}\n`;
|
|
146
|
-
result += `- Active tasks: ${activeTodos.length}\n`;
|
|
147
|
-
result += `- Completed tasks: ${completedTodos.length}\n`;
|
|
148
|
-
result += `- High priority tasks: ${highPriorityTodos.length}\n\n`;
|
|
149
|
-
if (highPriorityTodos.length > 0) {
|
|
150
|
-
result += `🔴 HIGH PRIORITY TASKS:\n`;
|
|
151
|
-
highPriorityTodos.forEach((todo, index) => {
|
|
152
|
-
const statusIcon = todo.status === 'in_progress' ? '🔄' : '⏳';
|
|
153
|
-
result += `${index + 1}. ${statusIcon} ${todo.content}\n`;
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
return result.trim();
|
|
157
|
-
}
|
|
158
|
-
catch (error) {
|
|
159
|
-
return `❌ Error updating todo list: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
160
|
-
}
|
|
81
|
+
export function writeTodos(newTodos, sessionId) {
|
|
82
|
+
const todos = cloneTodos(newTodos);
|
|
83
|
+
todosBySession.set(getSessionKey(sessionId), todos);
|
|
84
|
+
return formatTodos(todos, `TODO List Updated (${todos.length} items):`);
|
|
161
85
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
export
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (!args.todos || !Array.isArray(args.todos)) {
|
|
171
|
-
return '❌ Error: todos parameter must be an array';
|
|
172
|
-
}
|
|
173
|
-
return await handleTodoWrite(args.todos);
|
|
174
|
-
default:
|
|
175
|
-
return `❌ Unknown todo tool: ${toolName}`;
|
|
176
|
-
}
|
|
86
|
+
export function getTodosForSession(sessionId) {
|
|
87
|
+
return cloneTodos(todosBySession.get(getSessionKey(sessionId)) ?? []);
|
|
88
|
+
}
|
|
89
|
+
export function setTodosForSession(sessionId, todos) {
|
|
90
|
+
todosBySession.set(getSessionKey(sessionId), cloneTodos(todos));
|
|
91
|
+
}
|
|
92
|
+
export function clearTodos(sessionId) {
|
|
93
|
+
todosBySession.delete(getSessionKey(sessionId));
|
|
177
94
|
}
|