junecoder 1.0.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 +2 -0
- package/agent.mjs +650 -0
- package/checkpoint.mjs +43 -0
- package/cli.js +199 -0
- package/config.mjs +118 -0
- package/context.mjs +160 -0
- package/distill.mjs +178 -0
- package/executor.mjs +207 -0
- package/mcp.mjs +209 -0
- package/memory.mjs +218 -0
- package/metaTools.mjs +523 -0
- package/package.json +26 -0
- package/provider.mjs +145 -0
- package/session.mjs +114 -0
- package/skills.mjs +147 -0
- package/tools/bash.mjs +41 -0
- package/tools/delete.mjs +57 -0
- package/tools/edit.mjs +71 -0
- package/tools/fetch.mjs +55 -0
- package/tools/glob.mjs +83 -0
- package/tools/grep.mjs +58 -0
- package/tools/index.mjs +41 -0
- package/tools/ls.mjs +62 -0
- package/tools/read.mjs +50 -0
- package/tools/websearch.mjs +58 -0
- package/tools/write.mjs +54 -0
- package/tools.mjs +15 -0
- package/tui.mjs +437 -0
package/session.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence — save/restore/archive conversation history.
|
|
3
|
+
*
|
|
4
|
+
* Saves display lines (rendered conversation) plus agent history to disk,
|
|
5
|
+
* allowing sessions to survive restarts.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
/** Directory under ~/.junecode/ for session storage. */
|
|
12
|
+
function sessionsDir(cwd) {
|
|
13
|
+
const dir = join(homedir(), '.junecode', 'sessions');
|
|
14
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Encode a path for use as a filename (replace / with _). */
|
|
19
|
+
function pathSlug(cwd) {
|
|
20
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_+/, '') || 'default';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Path to the session file for a given project directory. */
|
|
24
|
+
export function sessionPath(cwd) {
|
|
25
|
+
return join(sessionsDir(), pathSlug(cwd) + '.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Save current agent history and display lines. */
|
|
29
|
+
export function saveSession(agent, displayLines) {
|
|
30
|
+
try {
|
|
31
|
+
const data = {
|
|
32
|
+
cwd: agent.cwd,
|
|
33
|
+
history: agent.history,
|
|
34
|
+
displayLines: displayLines || [],
|
|
35
|
+
planMode: agent.planMode,
|
|
36
|
+
tasks: agent.tasks,
|
|
37
|
+
goal: agent.goal,
|
|
38
|
+
savedAt: Date.now(),
|
|
39
|
+
};
|
|
40
|
+
writeFileSync(sessionPath(agent.cwd), JSON.stringify(data, null, 2), 'utf-8');
|
|
41
|
+
} catch {
|
|
42
|
+
// Non-fatal: session save fails silently
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Load the last session for a project directory. Returns null if none found. */
|
|
47
|
+
export function loadSession(cwd) {
|
|
48
|
+
const path = sessionPath(cwd);
|
|
49
|
+
if (!existsSync(path)) return null;
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(path, 'utf-8');
|
|
52
|
+
return JSON.parse(raw);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Clear session data for a project directory. */
|
|
59
|
+
export function clearSession(cwd) {
|
|
60
|
+
try {
|
|
61
|
+
const path = sessionPath(cwd);
|
|
62
|
+
if (existsSync(path)) unlinkSync(path);
|
|
63
|
+
} catch { /* ignore */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Archive the current session with timestamp. */
|
|
67
|
+
export function archiveCurrent(cwd) {
|
|
68
|
+
try {
|
|
69
|
+
const src = sessionPath(cwd);
|
|
70
|
+
if (!existsSync(src)) return;
|
|
71
|
+
const ts = Date.now();
|
|
72
|
+
const dst = join(sessionsDir(), pathSlug(cwd) + '_' + ts + '.json');
|
|
73
|
+
renameSync(src, dst);
|
|
74
|
+
} catch { /* ignore */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** List available session slots for a given project directory. */
|
|
78
|
+
export function listSlots(cwd) {
|
|
79
|
+
try {
|
|
80
|
+
const dir = sessionsDir();
|
|
81
|
+
const prefix = pathSlug(cwd);
|
|
82
|
+
const files = readdirSync(dir).filter(f => f.startsWith(prefix) && f.endsWith('.json'));
|
|
83
|
+
return files.map(f => {
|
|
84
|
+
const match = f.match(/_(\d+)\.json$/);
|
|
85
|
+
return {
|
|
86
|
+
file: f,
|
|
87
|
+
timestamp: match ? parseInt(match[1]) : 0,
|
|
88
|
+
label: match ? new Date(parseInt(match[1])).toLocaleString() : f,
|
|
89
|
+
};
|
|
90
|
+
}).sort((a, b) => b.timestamp - a.timestamp);
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Switch to a different session slot. Returns the restored data or null. */
|
|
97
|
+
export function switchToSlot(cwd, slot) {
|
|
98
|
+
try {
|
|
99
|
+
const dir = sessionsDir();
|
|
100
|
+
const src = join(dir, slot);
|
|
101
|
+
if (!existsSync(src)) return null;
|
|
102
|
+
const raw = readFileSync(src, 'utf-8');
|
|
103
|
+
// Save current session first
|
|
104
|
+
const curPath = sessionPath(cwd);
|
|
105
|
+
if (existsSync(curPath)) {
|
|
106
|
+
archiveCurrent(cwd);
|
|
107
|
+
}
|
|
108
|
+
// Copy slot to current session path
|
|
109
|
+
writeFileSync(curPath, raw, 'utf-8');
|
|
110
|
+
return JSON.parse(raw);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/skills.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills module — loads and formats project skills from .junecode/skills/.
|
|
3
|
+
*
|
|
4
|
+
* Skill files are markdown (.md) with optional YAML-like frontmatter.
|
|
5
|
+
* Frontmatter keys: name (string), description (string).
|
|
6
|
+
* Without frontmatter, name derives from filename and description from first line.
|
|
7
|
+
*/
|
|
8
|
+
import { join, basename, extname } from 'node:path';
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Load skills from cwd/.junecode/skills/.
|
|
13
|
+
* @param {string} cwd
|
|
14
|
+
* @returns {{ name: string, description: string, path: string }[]}
|
|
15
|
+
*/
|
|
16
|
+
export function loadSkills(cwd) {
|
|
17
|
+
const skillsDir = join(cwd, '.junecode', 'skills');
|
|
18
|
+
if (!existsSync(skillsDir)) return [];
|
|
19
|
+
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const skills = [];
|
|
28
|
+
for (const ent of entries) {
|
|
29
|
+
if (!ent.isFile()) continue;
|
|
30
|
+
const ext = extname(ent.name).toLowerCase();
|
|
31
|
+
if (ext !== '.md') continue;
|
|
32
|
+
|
|
33
|
+
const filePath = join(skillsDir, ent.name);
|
|
34
|
+
let content;
|
|
35
|
+
try {
|
|
36
|
+
content = readFileSync(filePath, 'utf-8');
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { name, description } = parseFrontmatter(content, ent.name);
|
|
42
|
+
skills.push({ name, description, path: filePath });
|
|
43
|
+
}
|
|
44
|
+
return skills;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Format a skill listing for inclusion in the system prompt.
|
|
49
|
+
* @param {object[]} skills
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function formatSkillListing(skills) {
|
|
53
|
+
if (!skills || skills.length === 0) return 'No skills loaded.';
|
|
54
|
+
return skills.map((s) => `- ${s.name}: ${s.description}`).join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a specific skill file by name.
|
|
59
|
+
* @param {string} cwd
|
|
60
|
+
* @param {string} name
|
|
61
|
+
* @returns {string|null} skill content or null if not found
|
|
62
|
+
*/
|
|
63
|
+
export function readSkill(cwd, name) {
|
|
64
|
+
const skillsDir = join(cwd, '.junecode', 'skills');
|
|
65
|
+
if (!existsSync(skillsDir)) return null;
|
|
66
|
+
|
|
67
|
+
// Try exact name match (with/without .md extension)
|
|
68
|
+
const candidates = [
|
|
69
|
+
join(skillsDir, name),
|
|
70
|
+
join(skillsDir, name + '.md'),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
for (const cand of candidates) {
|
|
74
|
+
if (existsSync(cand)) {
|
|
75
|
+
try {
|
|
76
|
+
return readFileSync(cand, 'utf-8');
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback: scan directory for a match by frontmatter name
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const ent of entries) {
|
|
92
|
+
if (!ent.isFile() || extname(ent.name).toLowerCase() !== '.md') continue;
|
|
93
|
+
const filePath = join(skillsDir, ent.name);
|
|
94
|
+
try {
|
|
95
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
96
|
+
const fm = parseFrontmatter(content, ent.name);
|
|
97
|
+
if (fm.name === name) return content;
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Internal helpers ──────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse optional YAML-like frontmatter from markdown content.
|
|
110
|
+
* Frontmatter is delimited by --- lines at the top of the file.
|
|
111
|
+
* Returns { name, description } — falls back to filename/first-line if absent.
|
|
112
|
+
*/
|
|
113
|
+
function parseFrontmatter(content, filename) {
|
|
114
|
+
const noExt = basename(filename, extname(filename));
|
|
115
|
+
let name = noExt;
|
|
116
|
+
let description = '';
|
|
117
|
+
|
|
118
|
+
// Try to parse frontmatter
|
|
119
|
+
const lines = content.split('\n');
|
|
120
|
+
if (lines[0]?.trim() === '---') {
|
|
121
|
+
const endIdx = lines.indexOf('---', 1);
|
|
122
|
+
if (endIdx > 0) {
|
|
123
|
+
const fmLines = lines.slice(1, endIdx);
|
|
124
|
+
for (const line of fmLines) {
|
|
125
|
+
const colonIdx = line.indexOf(':');
|
|
126
|
+
if (colonIdx > 0) {
|
|
127
|
+
const key = line.slice(0, colonIdx).trim();
|
|
128
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
129
|
+
if (key === 'name') name = value;
|
|
130
|
+
else if (key === 'description') description = value;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Fallback: use first non-empty non-heading line as description
|
|
137
|
+
if (!description) {
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
const trimmed = line.trim();
|
|
140
|
+
if (!trimmed || trimmed.startsWith('---') || trimmed.startsWith('#')) continue;
|
|
141
|
+
description = trimmed.slice(0, 100);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { name, description };
|
|
147
|
+
}
|
package/tools/bash.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash tool — execute a shell command and return stdout+stderr.
|
|
3
|
+
*/
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export const bashTool = {
|
|
7
|
+
name: 'bash',
|
|
8
|
+
description:
|
|
9
|
+
'Execute a shell command and return stdout+stderr. Use for running commands, builds, tests.',
|
|
10
|
+
parameters: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
command: { type: 'string', description: 'Shell command to execute' },
|
|
14
|
+
timeout: { type: 'number', description: 'Timeout in ms (default 120000)' },
|
|
15
|
+
},
|
|
16
|
+
required: ['command'],
|
|
17
|
+
},
|
|
18
|
+
readonly: false,
|
|
19
|
+
parallel: false,
|
|
20
|
+
|
|
21
|
+
async execute(args, agent) {
|
|
22
|
+
const cwd = agent?.cwd || process.cwd();
|
|
23
|
+
const timeout = args.timeout || 120_000;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const output = execSync(args.command, {
|
|
27
|
+
cwd,
|
|
28
|
+
encoding: 'utf-8',
|
|
29
|
+
timeout,
|
|
30
|
+
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
|
31
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
32
|
+
});
|
|
33
|
+
return output || '(command completed with no output)';
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const stdout = err.stdout || '';
|
|
36
|
+
const stderr = err.stderr || '';
|
|
37
|
+
const msg = err.message || String(err);
|
|
38
|
+
return `Command failed (exit code ${err.status || '?'}): ${msg}\n${stdout}${stderr}`;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
package/tools/delete.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* delete tool — delete a file.
|
|
3
|
+
*/
|
|
4
|
+
import { unlinkSync, existsSync, statSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const deleteTool = {
|
|
8
|
+
name: 'delete',
|
|
9
|
+
description:
|
|
10
|
+
'Delete a file. Refuses to delete git-tracked files as a safety measure.',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
path: { type: 'string', description: 'File path, relative to cwd or absolute' },
|
|
15
|
+
force: { type: 'boolean', description: 'Allow deleting git-tracked files (default false)' },
|
|
16
|
+
},
|
|
17
|
+
required: ['path'],
|
|
18
|
+
},
|
|
19
|
+
readonly: false,
|
|
20
|
+
parallel: false,
|
|
21
|
+
|
|
22
|
+
async execute(args, agent) {
|
|
23
|
+
const cwd = agent?.cwd || process.cwd();
|
|
24
|
+
const abs = resolve(cwd, args.path);
|
|
25
|
+
|
|
26
|
+
if (!existsSync(abs)) {
|
|
27
|
+
return `Error: file not found: ${args.path}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const st = statSync(abs);
|
|
31
|
+
if (st.isDirectory()) {
|
|
32
|
+
return `Error: "${args.path}" is a directory. Use bash to remove directories.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// Check if git-tracked
|
|
37
|
+
if (!args.force) {
|
|
38
|
+
const { execSync } = await import('node:child_process');
|
|
39
|
+
try {
|
|
40
|
+
execSync(`git ls-files --error-unmatch "${abs}"`, {
|
|
41
|
+
cwd,
|
|
42
|
+
stdio: 'ignore',
|
|
43
|
+
timeout: 3000,
|
|
44
|
+
});
|
|
45
|
+
return `Error: "${args.path}" is tracked by git. Use force=true to delete, or remove via bash with explicit user confirmation.`;
|
|
46
|
+
} catch {
|
|
47
|
+
// Not git-tracked — OK to delete
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
unlinkSync(abs);
|
|
52
|
+
return `Deleted: ${args.path}`;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return `Error deleting file: ${err.message}`;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
package/tools/edit.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* edit tool — replace exact text in a file.
|
|
3
|
+
*
|
|
4
|
+
* Uses execSync (cat > file) for disk writes to avoid virtual-filesystem isolation
|
|
5
|
+
* that can cause writes to be lost when the agent session ends.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { resolve, dirname } from 'node:path';
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
/** Escape a path for safe use in a single-quoted shell string. */
|
|
12
|
+
function shellEscape(str) {
|
|
13
|
+
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const editTool = {
|
|
17
|
+
name: 'edit',
|
|
18
|
+
description:
|
|
19
|
+
'Edit a file by exact string replacement. old_string must match exactly once unless replace_all is set.',
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
path: { type: 'string', description: 'File path' },
|
|
24
|
+
old_string: { type: 'string', description: 'Exact text to find and replace' },
|
|
25
|
+
new_string: { type: 'string', description: 'Replacement text' },
|
|
26
|
+
replace_all: { type: 'boolean', description: 'Replace all occurrences instead of just one (default false)' },
|
|
27
|
+
},
|
|
28
|
+
required: ['path', 'old_string', 'new_string'],
|
|
29
|
+
},
|
|
30
|
+
readonly: false,
|
|
31
|
+
parallel: false,
|
|
32
|
+
|
|
33
|
+
async execute(args, agent) {
|
|
34
|
+
const cwd = agent?.cwd || process.cwd();
|
|
35
|
+
const abs = resolve(cwd, args.path);
|
|
36
|
+
|
|
37
|
+
if (!existsSync(abs)) {
|
|
38
|
+
return `Error: file not found: ${args.path}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const original = readFileSync(abs, 'utf-8');
|
|
43
|
+
const count = original.split(args.old_string).length - 1;
|
|
44
|
+
|
|
45
|
+
if (count === 0) {
|
|
46
|
+
return `Error: old_string not found in ${args.path}. Make sure the text matches exactly (whitespace, indentation).`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (count > 1 && !args.replace_all) {
|
|
50
|
+
return `Error: old_string matches ${count} times in ${args.path}. Use replace_all=true or add more surrounding context to make it unique.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const updated = args.replace_all
|
|
54
|
+
? original.split(args.old_string).join(args.new_string)
|
|
55
|
+
: original.replace(args.old_string, args.new_string);
|
|
56
|
+
|
|
57
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
58
|
+
execSync(`cat > ${shellEscape(abs)}`, {
|
|
59
|
+
cwd,
|
|
60
|
+
input: updated,
|
|
61
|
+
encoding: 'utf-8',
|
|
62
|
+
timeout: 10_000,
|
|
63
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
64
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
65
|
+
});
|
|
66
|
+
return `Replaced ${args.replace_all ? count : 1} occurrence(s) in ${args.path}`;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return `Error editing file: ${err.message}`;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
package/tools/fetch.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetch tool — fetch a URL and return its content as text.
|
|
3
|
+
*/
|
|
4
|
+
export const fetchTool = {
|
|
5
|
+
name: 'fetch',
|
|
6
|
+
description:
|
|
7
|
+
'Fetch a URL and return its content as text. HTML pages are stripped to readable text.',
|
|
8
|
+
parameters: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
url: { type: 'string', description: 'http/https URL' },
|
|
12
|
+
},
|
|
13
|
+
required: ['url'],
|
|
14
|
+
},
|
|
15
|
+
readonly: true,
|
|
16
|
+
parallel: true,
|
|
17
|
+
|
|
18
|
+
async execute(args, _agent) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(args.url, {
|
|
21
|
+
signal: AbortSignal.timeout(20_000),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
return `Error: HTTP ${response.status} ${response.statusText}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const contentType = response.headers.get('content-type') || '';
|
|
29
|
+
const text = await response.text();
|
|
30
|
+
|
|
31
|
+
// If HTML, strip tags for readability
|
|
32
|
+
if (contentType.includes('html') || text.trim().startsWith('<!') || text.trim().startsWith('<html')) {
|
|
33
|
+
const stripped = text
|
|
34
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
35
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
36
|
+
.replace(/<[^>]+>/g, ' ')
|
|
37
|
+
.replace(/&/g, '&')
|
|
38
|
+
.replace(/</g, '<')
|
|
39
|
+
.replace(/>/g, '>')
|
|
40
|
+
.replace(/"/g, '"')
|
|
41
|
+
.replace(/'/g, "'")
|
|
42
|
+
.replace(/\s+/g, ' ')
|
|
43
|
+
.trim();
|
|
44
|
+
|
|
45
|
+
return stripped.slice(0, 50_000) +
|
|
46
|
+
(stripped.length > 50_000 ? '\n\n[truncated]' : '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return text.slice(0, 50_000) +
|
|
50
|
+
(text.length > 50_000 ? '\n\n[truncated]' : '');
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return `Error fetching URL: ${err.message}`;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
package/tools/glob.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* glob tool — find files by glob pattern.
|
|
3
|
+
*/
|
|
4
|
+
import { readdirSync } from 'node:fs';
|
|
5
|
+
import { resolve, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const IGNORED_DIRS = new Set([
|
|
8
|
+
'node_modules', '.git', 'dist', 'build', '.turbo', 'coverage',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export const globTool = {
|
|
12
|
+
name: 'glob',
|
|
13
|
+
description:
|
|
14
|
+
'Find files by glob pattern. Returns matching paths. Supports ** for recursive matching.',
|
|
15
|
+
parameters: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
pattern: { type: 'string', description: 'Glob pattern — supports **, *, ?, and character classes' },
|
|
19
|
+
path: { type: 'string', description: 'Directory to search in (default cwd)' },
|
|
20
|
+
},
|
|
21
|
+
required: ['pattern'],
|
|
22
|
+
},
|
|
23
|
+
readonly: true,
|
|
24
|
+
parallel: true,
|
|
25
|
+
|
|
26
|
+
async execute(args, agent) {
|
|
27
|
+
const cwd = agent?.cwd || process.cwd();
|
|
28
|
+
const base = args.path ? resolve(cwd, args.path) : cwd;
|
|
29
|
+
const regex = globToRegex(args.pattern);
|
|
30
|
+
const results = [];
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
for (const relPath of walkFilesSync(base)) {
|
|
34
|
+
if (regex.test(relPath)) {
|
|
35
|
+
results.push(relPath);
|
|
36
|
+
if (results.length >= 1000) break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
return '(no matches or glob failed)';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (results.length === 0) return '(no matches)';
|
|
44
|
+
|
|
45
|
+
results.sort();
|
|
46
|
+
return results.slice(0, 200).join('\n') +
|
|
47
|
+
(results.length > 200 ? `\n... and ${results.length - 200} more` : '');
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Recursively walk files, yielding relative paths (skips IGNORED_DIRS). */
|
|
52
|
+
function* walkFilesSync(dir, rel = '') {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue;
|
|
61
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
|
62
|
+
if (e.isDirectory()) {
|
|
63
|
+
yield* walkFilesSync(join(dir, e.name), relPath);
|
|
64
|
+
} else {
|
|
65
|
+
yield relPath;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Convert a glob pattern to a RegExp: **\/ matches zero-or-more dirs, ** matches across dirs, * matches within a segment, ? matches a single char. */
|
|
71
|
+
function globToRegex(pattern) {
|
|
72
|
+
const DS = '\u0001'; // placeholder for **/
|
|
73
|
+
const DP = '\u0002'; // placeholder for **
|
|
74
|
+
const escaped = pattern
|
|
75
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
76
|
+
.replace(/\*\*\//g, DS)
|
|
77
|
+
.replace(/\*\*/g, DP)
|
|
78
|
+
.replace(/\*/g, '[^/]*')
|
|
79
|
+
.replace(/\?/g, '[^/]')
|
|
80
|
+
.replaceAll(DS, '(?:.*/)?')
|
|
81
|
+
.replaceAll(DP, '.*');
|
|
82
|
+
return new RegExp('^' + escaped + '$');
|
|
83
|
+
}
|
package/tools/grep.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grep tool — search file contents with a regex.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
5
|
+
import { resolve, relative, extname } from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const TEXT_EXTS = new Set([
|
|
9
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
10
|
+
'.json', '.md', '.txt', '.yml', '.yaml', '.toml',
|
|
11
|
+
'.css', '.html', '.xml', '.svg', '.py', '.rb', '.go',
|
|
12
|
+
'.rs', '.java', '.c', '.h', '.cpp', '.hpp', '.sh',
|
|
13
|
+
'.mjs', '.env', '.gitignore', '.dockerignore',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export const grepTool = {
|
|
17
|
+
name: 'grep',
|
|
18
|
+
description:
|
|
19
|
+
'Search file contents with a regex. Returns matching lines as path:line: content.',
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
pattern: { type: 'string', description: 'JavaScript regular expression' },
|
|
24
|
+
path: { type: 'string', description: 'Directory or file to search (default cwd)' },
|
|
25
|
+
glob: { type: 'string', description: "Only search files matching this glob (e.g. '*.mjs')" },
|
|
26
|
+
},
|
|
27
|
+
required: ['pattern'],
|
|
28
|
+
},
|
|
29
|
+
readonly: true,
|
|
30
|
+
parallel: true,
|
|
31
|
+
|
|
32
|
+
async execute(args, agent) {
|
|
33
|
+
const cwd = agent?.cwd || process.cwd();
|
|
34
|
+
const base = args.path ? resolve(cwd, args.path) : cwd;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
let cmd = `grep -rn --include='*.${args.glob || '*'}' `;
|
|
38
|
+
// Escape the pattern for shell
|
|
39
|
+
const escaped = args.pattern.replace(/'/g, "'\\''");
|
|
40
|
+
cmd += `'${escaped}' "${base}" 2>/dev/null | head -200`;
|
|
41
|
+
|
|
42
|
+
const output = execSync(cmd, {
|
|
43
|
+
encoding: 'utf-8',
|
|
44
|
+
timeout: 10000,
|
|
45
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
46
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
47
|
+
}).trim();
|
|
48
|
+
|
|
49
|
+
if (!output) return '(no matches)';
|
|
50
|
+
|
|
51
|
+
const lines = output.split('\n').filter(Boolean);
|
|
52
|
+
return lines.slice(0, 200).join('\n') +
|
|
53
|
+
(lines.length > 200 ? `\n... and ${lines.length - 200} more` : '');
|
|
54
|
+
} catch {
|
|
55
|
+
return '(no matches or grep failed)';
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
package/tools/index.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools index — toOpenAISchema converter and baseTools array.
|
|
3
|
+
*/
|
|
4
|
+
import { readTool } from './read.mjs';
|
|
5
|
+
import { writeTool } from './write.mjs';
|
|
6
|
+
import { editTool } from './edit.mjs';
|
|
7
|
+
import { bashTool } from './bash.mjs';
|
|
8
|
+
import { globTool } from './glob.mjs';
|
|
9
|
+
import { grepTool } from './grep.mjs';
|
|
10
|
+
import { lsTool } from './ls.mjs';
|
|
11
|
+
import { fetchTool } from './fetch.mjs';
|
|
12
|
+
import { websearchTool } from './websearch.mjs';
|
|
13
|
+
import { deleteTool } from './delete.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert an internal tool definition to OpenAI function-calling schema.
|
|
17
|
+
*/
|
|
18
|
+
export function toOpenAISchema(tool) {
|
|
19
|
+
return {
|
|
20
|
+
type: 'function',
|
|
21
|
+
function: {
|
|
22
|
+
name: tool.name,
|
|
23
|
+
description: tool.description,
|
|
24
|
+
parameters: tool.parameters,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** All base external tools as an array. */
|
|
30
|
+
export const baseTools = [
|
|
31
|
+
readTool,
|
|
32
|
+
writeTool,
|
|
33
|
+
editTool,
|
|
34
|
+
bashTool,
|
|
35
|
+
globTool,
|
|
36
|
+
grepTool,
|
|
37
|
+
lsTool,
|
|
38
|
+
fetchTool,
|
|
39
|
+
websearchTool,
|
|
40
|
+
deleteTool,
|
|
41
|
+
];
|