- Add full Telegram bot functionality with Z.AI API integration
- Implement 4 tools: Bash, FileEdit, WebSearch, Git
- Add 3 agents: Code Reviewer, Architect, DevOps Engineer
- Add 6 skills for common coding tasks
- Add systemd service file for 24/7 operation
- Add nginx configuration for HTTPS webhook
- Add comprehensive documentation
- Implement WebSocket server for real-time updates
- Add logging system with Winston
- Add environment validation
🤖 zCode CLI X - Agentic coder with Z.AI + Telegram integration
324 lines
14 KiB
JavaScript
324 lines
14 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.BetaLocalFilesystemMemoryTool = exports.betaMemoryTool = void 0;
|
|
const tslib_1 = require("../../internal/tslib.js");
|
|
var memory_1 = require("../../helpers/beta/memory.js");
|
|
Object.defineProperty(exports, "betaMemoryTool", { enumerable: true, get: function () { return memory_1.betaMemoryTool; } });
|
|
const fs = tslib_1.__importStar(require("fs/promises"));
|
|
const path = tslib_1.__importStar(require("path"));
|
|
const crypto_1 = require("crypto");
|
|
async function exists(path) {
|
|
return await fs
|
|
.access(path)
|
|
.then(() => true)
|
|
.catch((err) => {
|
|
if (err.code === 'ENOENT')
|
|
return false;
|
|
throw err;
|
|
});
|
|
}
|
|
/**
|
|
* Atomically writes content to a file by writing to a temporary file first and then renaming it.
|
|
* This ensures the target file is never in a partially written state, preventing data corruption
|
|
* if the process crashes or is interrupted during the write operation. The rename operation is
|
|
* atomic on most file systems, guaranteeing that readers will only ever see the complete old
|
|
* content or the complete new content, never a mix or partial state.
|
|
*
|
|
* @param targetPath - The path where the file should be written
|
|
* @param content - The content to write to the file
|
|
*/
|
|
async function atomicWriteFile(targetPath, content) {
|
|
const dir = path.dirname(targetPath);
|
|
const tempPath = path.join(dir, `.tmp-${process.pid}-${(0, crypto_1.randomUUID)()}`);
|
|
let handle;
|
|
try {
|
|
handle = await fs.open(tempPath, 'wx');
|
|
await handle.writeFile(content, 'utf-8');
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = undefined;
|
|
await fs.rename(tempPath, targetPath);
|
|
}
|
|
catch (err) {
|
|
if (handle) {
|
|
await handle.close().catch(() => { });
|
|
}
|
|
await fs.unlink(tempPath).catch(() => { });
|
|
throw err;
|
|
}
|
|
}
|
|
/**
|
|
* Validates that a target path doesn't escape the memory root via symlinks.
|
|
*
|
|
* Prevents symlink attacks where a malicious symlink inside /memories points
|
|
* outside (e.g., /memories/foo -> /etc), which would allow operations like
|
|
* creating /memories/foo/passwd to actually write to /etc/passwd.
|
|
*
|
|
* Walks up from the target path to find the deepest existing ancestor,
|
|
* then resolves it to ensure the real path stays within memoryRoot.
|
|
*/
|
|
async function validateNoSymlinkEscape(targetPath, memoryRoot) {
|
|
const resolvedRoot = await fs.realpath(memoryRoot);
|
|
let current = targetPath;
|
|
while (true) {
|
|
try {
|
|
const resolved = await fs.realpath(current);
|
|
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
|
|
throw new Error(`Path would escape /memories directory via symlink`);
|
|
}
|
|
return;
|
|
}
|
|
catch (err) {
|
|
if (err.code !== 'ENOENT')
|
|
throw err;
|
|
const parent = path.dirname(current);
|
|
if (parent === current || current === memoryRoot) {
|
|
return;
|
|
}
|
|
current = parent;
|
|
}
|
|
}
|
|
}
|
|
async function readFileContent(fullPath, memoryPath) {
|
|
try {
|
|
return await fs.readFile(fullPath, 'utf-8');
|
|
}
|
|
catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
throw new Error(`The file ${memoryPath} no longer exists (may have been deleted or renamed concurrently).`);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
function formatFileSize(bytes) {
|
|
if (bytes === 0)
|
|
return '0B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'K', 'M', 'G'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
const size = bytes / Math.pow(k, i);
|
|
return (size % 1 === 0 ? size.toString() : size.toFixed(1)) + sizes[i];
|
|
}
|
|
const MAX_LINES = 999999;
|
|
const LINE_NUMBER_WIDTH = String(MAX_LINES).length;
|
|
class BetaLocalFilesystemMemoryTool {
|
|
constructor(basePath = './memory') {
|
|
this.basePath = basePath;
|
|
this.memoryRoot = path.join(this.basePath, 'memories');
|
|
}
|
|
static async init(basePath = './memory') {
|
|
const memory = new BetaLocalFilesystemMemoryTool(basePath);
|
|
await fs.mkdir(memory.memoryRoot, { recursive: true });
|
|
return memory;
|
|
}
|
|
async validatePath(memoryPath) {
|
|
if (!memoryPath.startsWith('/memories')) {
|
|
throw new Error(`Path must start with /memories, got: ${memoryPath}`);
|
|
}
|
|
const relativePath = memoryPath.slice('/memories'.length).replace(/^\//, '');
|
|
const fullPath = relativePath ? path.join(this.memoryRoot, relativePath) : this.memoryRoot;
|
|
const resolvedPath = path.resolve(fullPath);
|
|
const resolvedRoot = path.resolve(this.memoryRoot);
|
|
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + path.sep)) {
|
|
throw new Error(`Path ${memoryPath} would escape /memories directory`);
|
|
}
|
|
await validateNoSymlinkEscape(resolvedPath, this.memoryRoot);
|
|
return resolvedPath;
|
|
}
|
|
async view(command) {
|
|
const fullPath = await this.validatePath(command.path);
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(fullPath);
|
|
}
|
|
catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
throw new Error(`The path ${command.path} does not exist. Please provide a valid path.`);
|
|
}
|
|
throw err;
|
|
}
|
|
if (stat.isDirectory()) {
|
|
const items = [];
|
|
const collectItems = async (dirPath, relativePath, depth) => {
|
|
if (depth > 2)
|
|
return;
|
|
const dirContents = await fs.readdir(dirPath);
|
|
for (const item of dirContents.sort()) {
|
|
if (item.startsWith('.') || item === 'node_modules') {
|
|
continue;
|
|
}
|
|
const itemPath = path.join(dirPath, item);
|
|
const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
|
|
let itemStat;
|
|
try {
|
|
itemStat = await fs.stat(itemPath);
|
|
}
|
|
catch {
|
|
continue;
|
|
}
|
|
if (itemStat.isDirectory()) {
|
|
items.push({ size: formatFileSize(itemStat.size), path: `${itemRelativePath}/` });
|
|
if (depth < 2) {
|
|
await collectItems(itemPath, itemRelativePath, depth + 1);
|
|
}
|
|
}
|
|
else if (itemStat.isFile()) {
|
|
items.push({ size: formatFileSize(itemStat.size), path: itemRelativePath });
|
|
}
|
|
}
|
|
};
|
|
await collectItems(fullPath, '', 1);
|
|
const header = `Here're the files and directories up to 2 levels deep in ${command.path}, excluding hidden items and node_modules:`;
|
|
const dirSize = formatFileSize(stat.size);
|
|
const lines = [
|
|
`${dirSize}\t${command.path}`,
|
|
...items.map((item) => `${item.size}\t${command.path}/${item.path}`),
|
|
];
|
|
return `${header}\n${lines.join('\n')}`;
|
|
}
|
|
else if (stat.isFile()) {
|
|
const content = await readFileContent(fullPath, command.path);
|
|
const lines = content.split('\n');
|
|
if (lines.length > MAX_LINES) {
|
|
throw new Error(`File ${command.path} has too many lines (${lines.length}). Maximum is ${MAX_LINES.toLocaleString()} lines.`);
|
|
}
|
|
let displayLines = lines;
|
|
let startNum = 1;
|
|
if (command.view_range && command.view_range.length === 2) {
|
|
const startLine = Math.max(1, command.view_range[0]) - 1;
|
|
const endLine = command.view_range[1] === -1 ? lines.length : command.view_range[1];
|
|
displayLines = lines.slice(startLine, endLine);
|
|
startNum = startLine + 1;
|
|
}
|
|
const numberedLines = displayLines.map((line, i) => `${String(i + startNum).padStart(LINE_NUMBER_WIDTH, ' ')}\t${line}`);
|
|
return `Here's the content of ${command.path} with line numbers:\n${numberedLines.join('\n')}`;
|
|
}
|
|
else {
|
|
throw new Error(`Unsupported file type for ${command.path}`);
|
|
}
|
|
}
|
|
async create(command) {
|
|
const fullPath = await this.validatePath(command.path);
|
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
let handle;
|
|
try {
|
|
handle = await fs.open(fullPath, 'wx');
|
|
await handle.writeFile(command.file_text, 'utf-8');
|
|
await handle.sync();
|
|
}
|
|
catch (err) {
|
|
if (err?.code === 'EEXIST') {
|
|
throw new Error(`File ${command.path} already exists`);
|
|
}
|
|
throw err;
|
|
}
|
|
finally {
|
|
await handle?.close().catch(() => { });
|
|
}
|
|
return `File created successfully at: ${command.path}`;
|
|
}
|
|
async str_replace(command) {
|
|
const fullPath = await this.validatePath(command.path);
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(fullPath);
|
|
}
|
|
catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
throw new Error(`The path ${command.path} does not exist. Please provide a valid path.`);
|
|
}
|
|
throw err;
|
|
}
|
|
if (!stat.isFile()) {
|
|
throw new Error(`The path ${command.path} is not a file.`);
|
|
}
|
|
const content = await readFileContent(fullPath, command.path);
|
|
const lines = content.split('\n');
|
|
const matchingLines = [];
|
|
lines.forEach((line, index) => {
|
|
if (line.includes(command.old_str)) {
|
|
matchingLines.push(index + 1);
|
|
}
|
|
});
|
|
if (matchingLines.length === 0) {
|
|
throw new Error(`No replacement was performed, old_str \`${command.old_str}\` did not appear verbatim in ${command.path}.`);
|
|
}
|
|
else if (matchingLines.length > 1) {
|
|
throw new Error(`No replacement was performed. Multiple occurrences of old_str \`${command.old_str}\` in lines: ${matchingLines.join(', ')}. Please ensure it is unique`);
|
|
}
|
|
const newContent = content.replace(command.old_str, command.new_str);
|
|
await atomicWriteFile(fullPath, newContent);
|
|
const newLines = newContent.split('\n');
|
|
const changedLineIndex = matchingLines[0] - 1;
|
|
const contextStart = Math.max(0, changedLineIndex - 2);
|
|
const contextEnd = Math.min(newLines.length, changedLineIndex + 3);
|
|
const snippet = newLines.slice(contextStart, contextEnd).map((line, i) => {
|
|
const lineNum = contextStart + i + 1;
|
|
return `${String(lineNum).padStart(LINE_NUMBER_WIDTH, ' ')}\t${line}`;
|
|
});
|
|
return `The memory file has been edited. Here is the snippet showing the change (with line numbers):\n${snippet.join('\n')}`;
|
|
}
|
|
async insert(command) {
|
|
const fullPath = await this.validatePath(command.path);
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(fullPath);
|
|
}
|
|
catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
throw new Error(`The path ${command.path} does not exist. Please provide a valid path.`);
|
|
}
|
|
throw err;
|
|
}
|
|
if (!stat.isFile()) {
|
|
throw new Error(`The path ${command.path} is not a file.`);
|
|
}
|
|
const content = await readFileContent(fullPath, command.path);
|
|
const lines = content.split('\n');
|
|
if (command.insert_line < 0 || command.insert_line > lines.length) {
|
|
throw new Error(`Invalid \`insert_line\` parameter: ${command.insert_line}. It should be within the range of lines of the file: [0, ${lines.length}]`);
|
|
}
|
|
lines.splice(command.insert_line, 0, command.insert_text.replace(/\n$/, ''));
|
|
await atomicWriteFile(fullPath, lines.join('\n'));
|
|
return `The file ${command.path} has been edited.`;
|
|
}
|
|
async delete(command) {
|
|
const fullPath = await this.validatePath(command.path);
|
|
if (command.path === '/memories') {
|
|
throw new Error('Cannot delete the /memories directory itself');
|
|
}
|
|
try {
|
|
await fs.rm(fullPath, { recursive: true, force: false });
|
|
}
|
|
catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
throw new Error(`The path ${command.path} does not exist`);
|
|
}
|
|
throw err;
|
|
}
|
|
return `Successfully deleted ${command.path}`;
|
|
}
|
|
async rename(command) {
|
|
const oldFullPath = await this.validatePath(command.old_path);
|
|
const newFullPath = await this.validatePath(command.new_path);
|
|
// POSIX rename() silently overwrites existing files without error,
|
|
// so we can't catch this atomically. Best-effort check to warn user.
|
|
if (await exists(newFullPath)) {
|
|
throw new Error(`The destination ${command.new_path} already exists`);
|
|
}
|
|
const newDir = path.dirname(newFullPath);
|
|
await fs.mkdir(newDir, { recursive: true });
|
|
try {
|
|
await fs.rename(oldFullPath, newFullPath);
|
|
}
|
|
catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
throw new Error(`The path ${command.old_path} does not exist`);
|
|
}
|
|
throw err;
|
|
}
|
|
return `Successfully renamed ${command.old_path} to ${command.new_path}`;
|
|
}
|
|
}
|
|
exports.BetaLocalFilesystemMemoryTool = BetaLocalFilesystemMemoryTool;
|
|
//# sourceMappingURL=node.js.map
|