Files
homeclaw/src/tools/source-access.ts
T
2026-04-29 11:45:59 +09:00

213 lines
7.2 KiB
TypeScript

/**
* source-access.ts — Read-Only Access to SmallClaw Source Code
*
* Gives the AI the ability to read its own source files for error analysis
* and self-repair planning. Deliberately READ-ONLY — no writes, no deletes.
*
* All paths are resolved relative to src/ and clamped there (no traversal).
* These tools are registered in registry.ts alongside all other tools.
*/
import fs from 'fs';
import path from 'path';
import { ToolResult } from '../types.js';
// ─── Path Resolution ──────────────────────────────────────────────────────────
function resolveSourceRoot(): string {
// Works from both src/ (dev) and dist/ (compiled) contexts
return path.resolve(__dirname, '..', '..', 'src');
}
function resolveSourcePath(relPath: string): string | null {
const srcRoot = resolveSourceRoot();
const resolved = path.resolve(srcRoot, relPath);
// Security: clamp strictly inside src/
if (!resolved.startsWith(srcRoot + path.sep) && resolved !== srcRoot) return null;
return resolved;
}
function formatSize(bytes: number): string {
if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes > 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
// ─── read_source ──────────────────────────────────────────────────────────────
export interface ReadSourceArgs {
path: string; // relative to src/ e.g. "gateway/telegram-channel.ts"
start_line?: number; // 1-based, default 1
num_lines?: number; // default 120, max 300
}
export async function executeReadSource(args: ReadSourceArgs): Promise<ToolResult> {
if (!args?.path?.trim()) {
return { success: false, error: 'path is required (relative to src/, e.g. "gateway/server-v2.ts")' };
}
const absPath = resolveSourcePath(args.path.trim());
if (!absPath) {
return { success: false, error: `Path escapes src/ directory: ${args.path}` };
}
if (!fs.existsSync(absPath)) {
return { success: false, error: `Source file not found: src/${args.path}` };
}
const stat = fs.statSync(absPath);
if (!stat.isFile()) {
return { success: false, error: `Not a file: src/${args.path} — use list_source to browse directories` };
}
let content: string;
try {
content = fs.readFileSync(absPath, 'utf-8');
} catch (err: any) {
return { success: false, error: `Failed to read file: ${err.message}` };
}
const allLines = content.split('\n');
const totalLines = allLines.length;
const MAX_LINES = 300;
const DEFAULT_LINES = 120;
const startLine = Math.max(1, Number(args.start_line || 1) || 1);
const numLines = Math.min(MAX_LINES, Math.max(1, Number(args.num_lines || DEFAULT_LINES) || DEFAULT_LINES));
const startIdx = startLine - 1;
const slice = allLines.slice(startIdx, startIdx + numLines);
// Format with line numbers (matches how read_file works in workspace)
const numbered = slice.map((line, i) => `${String(startLine + i).padStart(4)} | ${line}`).join('\n');
return {
success: true,
data: {
path: `src/${args.path}`,
abs_path: absPath,
total_lines: totalLines,
file_size: formatSize(stat.size),
window: {
start_line: startLine,
end_line: startLine + slice.length - 1,
returned_lines: slice.length,
truncated: totalLines > (startLine - 1 + numLines),
},
content: numbered,
},
};
}
export const readSourceTool = {
name: 'read_source',
description:
'Read a SmallClaw source file (read-only). Use this to analyze errors, understand how a module works, ' +
'or prepare a repair proposal. Paths are relative to src/ e.g. "gateway/telegram-channel.ts". ' +
'Returns numbered lines. Use start_line + num_lines to paginate large files.',
execute: executeReadSource,
schema: {
path: 'string (required) — path relative to src/, e.g. "gateway/server-v2.ts" or "tools/files.ts"',
start_line: 'number (optional) — 1-based start line, default 1',
num_lines: 'number (optional) — lines to return, default 120, max 300',
},
jsonSchema: {
type: 'object',
required: ['path'],
properties: {
path: { type: 'string', description: 'Path relative to src/, e.g. "gateway/server-v2.ts"' },
start_line: { type: 'number', description: '1-based start line (default 1)' },
num_lines: { type: 'number', description: 'Lines to return (default 120, max 300)' },
},
additionalProperties: false,
},
};
// ─── list_source ──────────────────────────────────────────────────────────────
export interface ListSourceArgs {
path?: string; // relative to src/, default "" = root of src/
}
export async function executeListSource(args: ListSourceArgs): Promise<ToolResult> {
const relPath = (args?.path || '').trim();
const absPath = relPath ? resolveSourcePath(relPath) : resolveSourceRoot();
if (!absPath) {
return { success: false, error: `Path escapes src/ directory: ${relPath}` };
}
if (!fs.existsSync(absPath)) {
return { success: false, error: `Directory not found: src/${relPath || ''}` };
}
const stat = fs.statSync(absPath);
if (!stat.isFile() && !stat.isDirectory()) {
return { success: false, error: `Not a file or directory: src/${relPath}` };
}
// If it's actually a file, just describe it
if (stat.isFile()) {
return {
success: true,
data: {
path: `src/${relPath}`,
type: 'file',
size: formatSize(stat.size),
note: 'Use read_source to read this file',
},
};
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(absPath, { withFileTypes: true });
} catch (err: any) {
return { success: false, error: `Failed to list directory: ${err.message}` };
}
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
const files = entries
.filter(e => e.isFile())
.map(e => {
const size = formatSize(fs.statSync(path.join(absPath, e.name)).size);
return { name: e.name, size };
})
.sort((a, b) => a.name.localeCompare(b.name));
const srcRoot = resolveSourceRoot();
const displayPath = `src/${path.relative(srcRoot, absPath).replace(/\\/g, '/') || ''}`.replace(/\/$/, '');
return {
success: true,
data: {
path: displayPath,
directories: dirs,
files: files.map(f => `${f.name} (${f.size})`),
total_entries: entries.length,
},
};
}
export const listSourceTool = {
name: 'list_source',
description:
'List files and directories inside the SmallClaw src/ folder. ' +
'Use with no args to see the top-level structure. ' +
'Pass a subdirectory like "gateway" or "tools" to drill in.',
execute: executeListSource,
schema: {
path: 'string (optional) — subdirectory relative to src/, e.g. "gateway" or "tools". Omit for root.',
},
jsonSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Subdirectory relative to src/ (omit for root listing)' },
},
additionalProperties: false,
},
};