protoagent 0.1.13 → 0.1.15

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,74 @@
1
+ /**
2
+ * Path suggestions utility — Find similar paths when a file isn't found.
3
+ *
4
+ * Used by read_file and edit_file to suggest alternatives when
5
+ * the requested path doesn't exist (helps recover from typos).
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import leven from 'leven';
10
+ import { getWorkingDirectory } from './path-validation.js';
11
+ const MAX_DEPTH = 6;
12
+ const MAX_ENTRIES = 200;
13
+ const MAX_CANDIDATES = 50;
14
+ const MAX_SUGGESTIONS = 3;
15
+ /**
16
+ * Collect all file paths recursively up to MAX_DEPTH.
17
+ */
18
+ async function collectAllPaths(cwd) {
19
+ const paths = [];
20
+ const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
21
+ async function walk(dir, currentPath) {
22
+ if (paths.length >= MAX_CANDIDATES)
23
+ return;
24
+ let entries;
25
+ try {
26
+ const dirEntries = await fs.readdir(dir, { withFileTypes: true });
27
+ entries = dirEntries
28
+ .filter(e => !skipDirs.has(e.name))
29
+ .slice(0, MAX_ENTRIES)
30
+ .map(e => e.name);
31
+ }
32
+ catch {
33
+ return;
34
+ }
35
+ for (const entry of entries) {
36
+ if (paths.length >= MAX_CANDIDATES)
37
+ return;
38
+ const entryPath = currentPath ? `${currentPath}/${entry}` : entry;
39
+ const fullPath = path.join(dir, entry);
40
+ paths.push(entryPath);
41
+ // Continue walking deeper
42
+ try {
43
+ const stat = await fs.stat(fullPath);
44
+ if (stat.isDirectory() && entryPath.split('/').length < MAX_DEPTH) {
45
+ await walk(fullPath, entryPath);
46
+ }
47
+ }
48
+ catch {
49
+ // skip
50
+ }
51
+ }
52
+ }
53
+ await walk(cwd, '');
54
+ return paths;
55
+ }
56
+ /**
57
+ * Find similar paths when a requested file doesn't exist.
58
+ * Uses Levenshtein distance to find the closest matches.
59
+ */
60
+ export async function findSimilarPaths(requestedPath) {
61
+ const cwd = getWorkingDirectory();
62
+ // Collect all available paths
63
+ const allPaths = await collectAllPaths(cwd);
64
+ // Calculate Levenshtein distance for each path
65
+ const scored = allPaths.map(candidatePath => ({
66
+ path: candidatePath,
67
+ distance: leven(requestedPath.toLowerCase(), candidatePath.toLowerCase()),
68
+ }));
69
+ // Sort by distance (lower is better) and take top suggestions
70
+ scored.sort((a, b) => a.distance - b.distance);
71
+ return scored
72
+ .slice(0, MAX_SUGGESTIONS)
73
+ .map(s => s.path);
74
+ }
@@ -7,14 +7,11 @@
7
7
  */
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
+ import isPathInside from 'is-path-inside';
10
11
  const workingDirectory = process.cwd();
11
12
  let allowedRoots = [];
12
- function isWithinRoot(targetPath, rootPath) {
13
- const relative = path.relative(rootPath, targetPath);
14
- return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
15
- }
16
13
  function isAllowedPath(targetPath) {
17
- return isWithinRoot(targetPath, workingDirectory) || allowedRoots.some((root) => isWithinRoot(targetPath, root));
14
+ return isPathInside(targetPath, workingDirectory) || allowedRoots.some((root) => isPathInside(targetPath, root));
18
15
  }
19
16
  export async function setAllowedPathRoots(roots) {
20
17
  const normalizedRoots = await Promise.all(roots.map(async (root) => {
@@ -0,0 +1,53 @@
1
+ // Extract the most meaningful detail from tool args based on tool type
2
+ export function extractToolDetail(tool, args) {
3
+ switch (tool) {
4
+ case 'read_file':
5
+ case 'write_file':
6
+ case 'edit_file':
7
+ return typeof args.file_path === 'string' ? args.file_path : '';
8
+ case 'list_directory':
9
+ return typeof args.directory_path === 'string' ? args.directory_path : '(current)';
10
+ case 'search_files':
11
+ return typeof args.search_term === 'string' ? `"${args.search_term}"` : '';
12
+ case 'bash':
13
+ if (typeof args.command !== 'string')
14
+ return '';
15
+ const parts = args.command.split(/\s+/);
16
+ return parts.slice(0, 3).join(' ') + (parts.length > 3 ? '...' : '');
17
+ case 'todo_write':
18
+ return Array.isArray(args.todos) ? `${args.todos.length} task(s)` : '';
19
+ case 'todo_read':
20
+ return 'read';
21
+ case 'webfetch':
22
+ return typeof args.url === 'string' ? new URL(args.url).hostname : '';
23
+ case 'sub_agent':
24
+ return 'nested task...';
25
+ default: {
26
+ // Fallback: first string argument, truncated to 30 chars
27
+ const firstEntry = Object.entries(args).find(([, v]) => typeof v === 'string');
28
+ if (!firstEntry)
29
+ return '';
30
+ const value = String(firstEntry[1]);
31
+ return value.length > 30 ? value.slice(0, 30) + '...' : value;
32
+ }
33
+ }
34
+ }
35
+ // Format sub-agent activity: "Sub-agent read_file: src/App.tsx"
36
+ export function formatSubAgentActivity(tool, args) {
37
+ if (!args || typeof args !== 'object') {
38
+ return `Sub-agent running ${tool}...`;
39
+ }
40
+ const detail = extractToolDetail(tool, args);
41
+ if (!detail) {
42
+ return `Sub-agent running ${tool}...`;
43
+ }
44
+ return `Sub-agent ${tool.replace(/_/g, ' ')}: ${detail}`;
45
+ }
46
+ // Format tool activity: "read_file src/App.tsx"
47
+ export function formatToolActivity(tool, args) {
48
+ if (!args || typeof args !== 'object') {
49
+ return tool;
50
+ }
51
+ const detail = extractToolDetail(tool, args);
52
+ return detail ? `${tool} ${detail}` : tool;
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -16,7 +16,9 @@
16
16
  "build": "npm run clean && tsc && node -e \"const fs=require('node:fs'); if (fs.existsSync('dist/cli.js')) fs.chmodSync('dist/cli.js', 0o755)\"",
17
17
  "build:watch": "tsc --watch",
18
18
  "prepack": "npm run build",
19
- "docs:dev": "vitepress dev docs",
19
+ "docs:dev": "concurrently \"npm run docs:dev:worker\" \"npm run docs:dev:docs\" --kill-others",
20
+ "docs:dev:worker": "cd docs/worker && npm exec wrangler -- dev --port 8787 --ip 0.0.0.0",
21
+ "docs:dev:docs": "vitepress dev docs --port 5173 --host",
20
22
  "docs:build": "vitepress build docs",
21
23
  "docs:preview": "vitepress preview docs"
22
24
  },
@@ -46,25 +48,30 @@
46
48
  "@inkjs/ui": "^2.0.0",
47
49
  "@modelcontextprotocol/sdk": "^1.27.1",
48
50
  "commander": "^14.0.1",
51
+ "diff": "^8.0.4",
49
52
  "he": "^1.2.0",
50
53
  "html-to-text": "^9.0.5",
51
54
  "ink": "^6.8.0",
52
- "ink-big-text": "^2.0.0",
55
+ "is-path-inside": "^4.0.0",
53
56
  "jsonc-parser": "^3.3.1",
57
+ "leven": "^4.1.0",
54
58
  "openai": "^5.23.1",
55
59
  "react": "^19.1.1",
60
+ "strip-ansi": "^7.2.0",
56
61
  "turndown": "^7.2.2",
57
62
  "yaml": "^2.8.2",
58
- "yoga-layout": "^3.2.1"
63
+ "zod": "^3.25.76"
59
64
  },
60
65
  "devDependencies": {
61
66
  "@eslint/js": "^9.36.0",
62
67
  "@tailwindcss/postcss": "^4.1.18",
68
+ "@types/diff": "^7.0.2",
63
69
  "@types/he": "^1.2.3",
64
70
  "@types/html-to-text": "^9.0.4",
65
71
  "@types/node": "^24.5.2",
66
72
  "@types/react": "^19.1.15",
67
73
  "@types/turndown": "^5.0.6",
74
+ "concurrently": "^9.1.2",
68
75
  "eslint": "^9.36.0",
69
76
  "ink-testing-library": "^4.0.0",
70
77
  "tailwindcss": "^4.0.0",
@@ -1,27 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Text } from 'ink';
3
- import { LeftBar } from './LeftBar.js';
4
- export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, maxPreviewLines = 3, maxPreviewChars = 500, expanded = false, marginBottom = 0, }) => {
5
- const lines = content.split('\n');
6
- const isTooManyLines = lines.length > maxPreviewLines;
7
- const isTooManyChars = content.length > maxPreviewChars;
8
- const isLong = isTooManyLines || isTooManyChars;
9
- // If content is short, always show it
10
- if (!isLong) {
11
- return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsx(Text, { color: titleColor, dimColor: dimColor, bold: true, children: title }), _jsx(Text, { dimColor: dimColor, children: content })] }));
12
- }
13
- // For long content, show preview or full content
14
- let preview;
15
- if (expanded) {
16
- preview = content;
17
- }
18
- else {
19
- // Truncate by lines first, then by characters
20
- const linesTruncated = lines.slice(0, maxPreviewLines).join('\n');
21
- preview = linesTruncated.length > maxPreviewChars
22
- ? linesTruncated.slice(0, maxPreviewChars)
23
- : linesTruncated;
24
- }
25
- const hasMore = !expanded;
26
- return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsxs(Text, { color: titleColor, dimColor: dimColor, bold: true, children: [expanded ? '▼' : '▶', " ", title] }), _jsx(Text, { dimColor: dimColor, children: preview }), hasMore && _jsx(Text, { dimColor: true, children: "... (use /expand to see all)" })] }));
27
- };
@@ -1,42 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * ConfigDialog — Modal-like dialog for changing config mid-conversation
4
- *
5
- * Allows users to update provider, model, or API key without losing chat history.
6
- */
7
- import { useState } from 'react';
8
- import { Box, Text } from 'ink';
9
- import { PasswordInput, Select } from '@inkjs/ui';
10
- import { getAllProviders, getProvider } from '../providers.js';
11
- import { resolveApiKey } from '../config.js';
12
- export const ConfigDialog = ({ currentConfig, onComplete, onCancel, }) => {
13
- const [step, setStep] = useState('select_provider');
14
- const [selectedProviderId, setSelectedProviderId] = useState(currentConfig.provider);
15
- const [selectedModelId, setSelectedModelId] = useState(currentConfig.model);
16
- const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
17
- label: `${provider.name} - ${model.name}`,
18
- value: `${provider.id}:::${model.id}`,
19
- })));
20
- const currentProvider = getProvider(currentConfig.provider);
21
- // Provider selection step
22
- if (step === 'select_provider') {
23
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Change Configuration" }), _jsxs(Text, { dimColor: true, children: ["Current: ", currentProvider?.name, " / ", currentConfig.model] }), _jsx(Text, { dimColor: true, children: "Select a new provider and model:" }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: providerItems.map((item) => ({ value: item.value, label: item.label })), onChange: (value) => {
24
- const [providerId, modelId] = value.split(':::');
25
- setSelectedProviderId(providerId);
26
- setSelectedModelId(modelId);
27
- setStep('enter_api_key');
28
- } }) })] }));
29
- }
30
- // API key entry step
31
- const provider = getProvider(selectedProviderId);
32
- const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
33
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Confirm Configuration" }), _jsxs(Text, { dimColor: true, children: ["Provider: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: hasResolvedAuth ? 'Optional API key (leave empty to keep resolved auth):' : 'Enter your API key:' }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
34
- const finalApiKey = value.trim().length > 0 ? value.trim() : currentConfig.apiKey;
35
- const newConfig = {
36
- provider: selectedProviderId,
37
- model: selectedModelId,
38
- ...(finalApiKey?.trim() ? { apiKey: finalApiKey.trim() } : {}),
39
- };
40
- onComplete(newConfig);
41
- } })] }));
42
- };
@@ -1,34 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import { FormattedMessage } from './FormattedMessage.js';
4
- import { LeftBar } from './LeftBar.js';
5
- export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = false, }) => {
6
- const toolNames = toolCalls.map((toolCall) => toolCall.name);
7
- const title = `Called: ${toolNames.join(', ')}`;
8
- const containsTodoTool = toolCalls.some((toolCall) => toolCall.name === 'todo_read' || toolCall.name === 'todo_write');
9
- const titleColor = containsTodoTool ? 'green' : 'cyan';
10
- const isExpanded = expanded || containsTodoTool;
11
- if (isExpanded) {
12
- return (_jsxs(LeftBar, { color: titleColor, children: [_jsxs(Text, { color: titleColor, bold: true, children: ["\u25BC ", title] }), toolCalls.map((toolCall, idx) => {
13
- const result = toolResults.get(toolCall.id);
14
- if (!result)
15
- return null;
16
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["[", result.name, "]:"] }), _jsx(FormattedMessage, { content: result.content })] }, idx));
17
- })] }));
18
- }
19
- const compactLines = toolCalls.flatMap((toolCall) => {
20
- const result = toolResults.get(toolCall.id);
21
- if (!result)
22
- return [];
23
- const compactContent = result.content
24
- .replace(/\s+/g, ' ')
25
- .trim();
26
- return [`[${result.name}] ${compactContent}`];
27
- });
28
- const compactPreview = compactLines.join(' | ');
29
- const previewLimit = 180;
30
- const preview = compactPreview.length > previewLimit
31
- ? `${compactPreview.slice(0, previewLimit).trimEnd()}... (use /expand)`
32
- : compactPreview;
33
- return (_jsxs(LeftBar, { color: "white", children: [_jsxs(Text, { color: titleColor, dimColor: true, bold: true, children: ["\u25B6 ", title] }), _jsx(Text, { dimColor: true, children: preview })] }));
34
- };
@@ -1,170 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- import { renderFormattedText } from '../utils/format-message.js';
4
- import { LeftBar } from './LeftBar.js';
5
- export const DEFERRED_TABLE_PLACEHOLDER = 'table loading';
6
- const graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
7
- ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
8
- : null;
9
- const COMBINING_MARK_PATTERN = /\p{Mark}/u;
10
- const ZERO_WIDTH_PATTERN = /[\u200B-\u200D\uFE0E\uFE0F]/u;
11
- const DOUBLE_WIDTH_PATTERN = /[\u1100-\u115F\u2329\u232A\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{1F900}-\u{1F9FF}\u{1F1E6}-\u{1F1FF}]/u;
12
- function splitGraphemes(text) {
13
- if (!text)
14
- return [];
15
- if (graphemeSegmenter) {
16
- return Array.from(graphemeSegmenter.segment(text), (segment) => segment.segment);
17
- }
18
- return Array.from(text);
19
- }
20
- function getGraphemeWidth(grapheme) {
21
- if (!grapheme)
22
- return 0;
23
- if (ZERO_WIDTH_PATTERN.test(grapheme))
24
- return 0;
25
- if (COMBINING_MARK_PATTERN.test(grapheme))
26
- return 0;
27
- if (/^[\u0000-\u001F\u007F-\u009F]$/.test(grapheme))
28
- return 0;
29
- if (DOUBLE_WIDTH_PATTERN.test(grapheme))
30
- return 2;
31
- return 1;
32
- }
33
- function getTextWidth(text) {
34
- return splitGraphemes(text).reduce((width, grapheme) => width + getGraphemeWidth(grapheme), 0);
35
- }
36
- function padToWidth(text, width) {
37
- const padding = Math.max(0, width - getTextWidth(text));
38
- return text + ' '.repeat(padding);
39
- }
40
- function parseMarkdownTableToRows(markdown) {
41
- const lines = markdown.trim().split('\n');
42
- if (lines.length < 3)
43
- return null;
44
- const parseRow = (row) => row.split('|')
45
- .map((cell) => cell.trim())
46
- .filter((cell, index, array) => {
47
- if (index === 0 && cell === '')
48
- return false;
49
- if (index === array.length - 1 && cell === '')
50
- return false;
51
- return true;
52
- });
53
- const header = parseRow(lines[0]);
54
- const separator = parseRow(lines[1]);
55
- if (header.length === 0 || separator.length === 0)
56
- return null;
57
- if (!separator.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, ''))))
58
- return null;
59
- const rows = lines.slice(2).map(parseRow);
60
- return [header, ...rows];
61
- }
62
- function renderPreformattedTable(markdown) {
63
- const rows = parseMarkdownTableToRows(markdown);
64
- if (!rows || rows.length === 0) {
65
- return markdown.trim();
66
- }
67
- const columnCount = Math.max(...rows.map((row) => row.length));
68
- const normalizedRows = rows.map((row) => Array.from({ length: columnCount }, (_, index) => row[index] ?? ''));
69
- const widths = Array.from({ length: columnCount }, (_, index) => Math.max(...normalizedRows.map((row) => getTextWidth(row[index]))));
70
- const formatRow = (row) => row
71
- .map((cell, index) => padToWidth(cell, widths[index]))
72
- .join(' ')
73
- .trimEnd();
74
- const header = formatRow(normalizedRows[0]);
75
- const divider = widths.map((width) => '-'.repeat(width)).join(' ');
76
- const body = normalizedRows.slice(1).map(formatRow);
77
- return [header, divider, ...body].join('\n');
78
- }
79
- /**
80
- * FormattedMessage component
81
- *
82
- * Parses a markdown string and renders:
83
- * - Standard text with ANSI formatting
84
- * - Markdown tables as preformatted monospace text
85
- * - Code blocks (rendered in a box)
86
- */
87
- export const FormattedMessage = ({ content, deferTables = false }) => {
88
- if (!content)
89
- return null;
90
- const lines = content.split('\n');
91
- const blocks = [];
92
- let currentBlockContent = [];
93
- let currentBlockType = 'text';
94
- for (let i = 0; i < lines.length; i++) {
95
- const line = lines[i];
96
- const trimmedLine = line.trim();
97
- // 1. Handle Code Blocks
98
- if (currentBlockType === 'code') {
99
- currentBlockContent.push(line);
100
- // Check for end of code block
101
- if (trimmedLine.startsWith('```')) {
102
- blocks.push({ type: 'code', content: currentBlockContent.join('\n') });
103
- currentBlockContent = [];
104
- currentBlockType = 'text';
105
- }
106
- continue;
107
- }
108
- // Start Code Block
109
- if (trimmedLine.startsWith('```')) {
110
- // Finish pending text block
111
- if (currentBlockContent.length > 0) {
112
- blocks.push({ type: 'text', content: currentBlockContent.join('\n') });
113
- }
114
- currentBlockContent = [line];
115
- currentBlockType = 'code';
116
- continue;
117
- }
118
- // 2. Handle Tables
119
- if (currentBlockType === 'table') {
120
- if (trimmedLine.startsWith('|')) {
121
- currentBlockContent.push(line);
122
- continue;
123
- }
124
- else {
125
- // End of table block found (line doesn't start with |)
126
- blocks.push({ type: 'table', content: currentBlockContent.join('\n') });
127
- // Reset to text and fall through to re-process this line
128
- currentBlockContent = [];
129
- currentBlockType = 'text';
130
- }
131
- }
132
- // Start Table Block check
133
- // A table start requires a pipe AND a subsequent separator line
134
- const isTableStart = trimmedLine.startsWith('|');
135
- const nextLine = lines[i + 1];
136
- const isNextLineSeparator = nextLine && nextLine.trim().startsWith('|') && nextLine.includes('---');
137
- if (isTableStart && isNextLineSeparator) {
138
- // Finish pending text block
139
- if (currentBlockContent.length > 0) {
140
- blocks.push({ type: 'text', content: currentBlockContent.join('\n') });
141
- }
142
- currentBlockContent = [line];
143
- currentBlockType = 'table';
144
- continue;
145
- }
146
- // 3. Handle Text
147
- currentBlockContent.push(line);
148
- }
149
- // Push final block
150
- if (currentBlockContent.length > 0) {
151
- blocks.push({ type: currentBlockType, content: currentBlockContent.join('\n') });
152
- }
153
- return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, index) => {
154
- if (block.type === 'table') {
155
- if (!block.content.trim())
156
- return null;
157
- if (deferTables) {
158
- return (_jsx(Box, { marginY: 1, children: _jsx(Text, { dimColor: true, children: DEFERRED_TABLE_PLACEHOLDER }) }, index));
159
- }
160
- return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { children: renderPreformattedTable(block.content) }) }, index));
161
- }
162
- if (block.type === 'code') {
163
- return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: block.content }) }, index));
164
- }
165
- // Text Block
166
- if (!block.content.trim())
167
- return null;
168
- return (_jsx(Box, { marginBottom: 0, children: _jsx(Text, { children: renderFormattedText(block.content) }) }, index));
169
- }) }));
170
- };