v1.0
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import fsSync from 'fs';
|
||||
import os from 'os';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PATCH_OUTPUT_MAX_CHARS = 8000;
|
||||
|
||||
// Helper function to check if path is allowed
|
||||
function resolveWorkspacePath(targetPath: string): string {
|
||||
const config = getConfig().getConfig();
|
||||
const workspace = config.workspace.path;
|
||||
if (path.isAbsolute(targetPath)) return targetPath;
|
||||
return path.join(workspace, targetPath);
|
||||
}
|
||||
|
||||
function normalizePathForCompare(p: string): string {
|
||||
const resolved = path.resolve(String(p || ''));
|
||||
if (process.platform === 'win32') return resolved.toLowerCase();
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isPathInside(basePath: string, targetPath: string): boolean {
|
||||
const base = normalizePathForCompare(basePath);
|
||||
const target = normalizePathForCompare(targetPath);
|
||||
if (!base || !target) return false;
|
||||
const rel = path.relative(base, target);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
}
|
||||
|
||||
function isPathAllowed(targetPath: string): { allowed: boolean; reason?: string } {
|
||||
const config = getConfig().getConfig();
|
||||
const permissions = config.tools.permissions.files;
|
||||
const absPath = path.resolve(String(targetPath || ''));
|
||||
|
||||
// Check blocked paths
|
||||
for (const blocked of permissions.blocked_paths) {
|
||||
if (isPathInside(blocked, absPath)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Path is in blocked directory: ${blocked}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check allowed paths
|
||||
const isInAllowedPath = permissions.allowed_paths.some(allowed =>
|
||||
isPathInside(allowed, absPath)
|
||||
);
|
||||
|
||||
if (!isInAllowedPath) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Path is not in any allowed directory. Allowed: ${permissions.allowed_paths.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
function truncateOutput(text: string): string {
|
||||
const t = String(text || '').trim();
|
||||
if (!t) return '';
|
||||
if (t.length <= PATCH_OUTPUT_MAX_CHARS) return t;
|
||||
return `${t.slice(0, PATCH_OUTPUT_MAX_CHARS)} ...[truncated]`;
|
||||
}
|
||||
|
||||
function countSkippedPatches(text: string): number {
|
||||
const src = String(text || '')
|
||||
.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
if (!src) return 0;
|
||||
const matches = src.match(/Skipped patch\b/gi);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
function parsePatchPathToken(raw: string): string {
|
||||
const trimmed = String(raw || '').trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.startsWith('"')) {
|
||||
const m = trimmed.match(/^"([^"]+)"/);
|
||||
return m?.[1] || '';
|
||||
}
|
||||
return trimmed.split(/\s+/)[0] || '';
|
||||
}
|
||||
|
||||
function normalizePatchPath(rawPath: string): string {
|
||||
let p = String(rawPath || '').trim();
|
||||
if (!p || p === '/dev/null') return '';
|
||||
if (p.startsWith('a/') || p.startsWith('b/')) p = p.slice(2);
|
||||
return p;
|
||||
}
|
||||
|
||||
function extractPatchTargetPaths(patchText: string): string[] {
|
||||
const paths = new Set<string>();
|
||||
const lines = String(patchText || '').split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
const m = line.match(/^diff --git\s+(?:"([^"]+)"|(\S+))\s+(?:"([^"]+)"|(\S+))/);
|
||||
const left = normalizePatchPath(m?.[1] || m?.[2] || '');
|
||||
const right = normalizePatchPath(m?.[3] || m?.[4] || '');
|
||||
if (left) paths.add(left);
|
||||
if (right) paths.add(right);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
const token = parsePatchPathToken(line.slice(4));
|
||||
const normalized = normalizePatchPath(token);
|
||||
if (normalized) paths.add(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('rename from ')) {
|
||||
const fromPath = normalizePatchPath(line.slice('rename from '.length));
|
||||
if (fromPath) paths.add(fromPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('rename to ')) {
|
||||
const toPath = normalizePatchPath(line.slice('rename to '.length));
|
||||
if (toPath) paths.add(toPath);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(paths);
|
||||
}
|
||||
|
||||
function validatePatchPaths(paths: string[]): { ok: true; relativePaths: string[] } | { ok: false; error: string } {
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
return { ok: false, error: 'No target paths found in patch. Include standard unified diff headers (---/+++).' };
|
||||
}
|
||||
|
||||
const unique = Array.from(new Set(paths.map(p => String(p || '').trim()).filter(Boolean)));
|
||||
for (const relPath of unique) {
|
||||
if (path.isAbsolute(relPath)) {
|
||||
return { ok: false, error: `Patch path must be relative: ${relPath}` };
|
||||
}
|
||||
const absPath = resolveWorkspacePath(relPath);
|
||||
const pathCheck = isPathAllowed(absPath);
|
||||
if (!pathCheck.allowed) {
|
||||
return { ok: false, error: `Patch path not allowed (${relPath}): ${pathCheck.reason}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, relativePaths: unique };
|
||||
}
|
||||
|
||||
async function runGitApply(workspacePath: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||
const out = await execFileAsync('git', args, {
|
||||
cwd: workspacePath,
|
||||
windowsHide: true,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
encoding: 'utf8',
|
||||
} as any);
|
||||
return {
|
||||
stdout: String((out as any)?.stdout || ''),
|
||||
stderr: String((out as any)?.stderr || ''),
|
||||
};
|
||||
}
|
||||
|
||||
// READ TOOL
|
||||
export interface ReadToolArgs {
|
||||
path: string;
|
||||
start_line?: number;
|
||||
num_lines?: number;
|
||||
}
|
||||
|
||||
type RetrievalMode = 'fast' | 'standard' | 'deep';
|
||||
|
||||
function getLocalConfigFilePath(): string {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
return fsSync.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
}
|
||||
|
||||
function getRetrievalMode(): RetrievalMode {
|
||||
try {
|
||||
const p = getLocalConfigFilePath();
|
||||
if (!fsSync.existsSync(p)) return 'standard';
|
||||
const raw = JSON.parse(fsSync.readFileSync(p, 'utf-8'));
|
||||
const mode = String(raw?.agent_policy?.retrieval_mode || 'standard').toLowerCase();
|
||||
if (mode === 'fast' || mode === 'deep') return mode;
|
||||
return 'standard';
|
||||
} catch {
|
||||
return 'standard';
|
||||
}
|
||||
}
|
||||
|
||||
function retrievalMaxLines(mode: RetrievalMode): number {
|
||||
if (mode === 'fast') return 120;
|
||||
if (mode === 'deep') return 480;
|
||||
return 240;
|
||||
}
|
||||
|
||||
export async function executeRead(args: ReadToolArgs): Promise<ToolResult> {
|
||||
try {
|
||||
const absPath = resolveWorkspacePath(args.path);
|
||||
const pathCheck = isPathAllowed(absPath);
|
||||
if (!pathCheck.allowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: pathCheck.reason
|
||||
};
|
||||
}
|
||||
const content = await fs.readFile(absPath, 'utf-8');
|
||||
const allLines = content.split('\n');
|
||||
const mode = getRetrievalMode();
|
||||
const cap = retrievalMaxLines(mode);
|
||||
const startLine = Math.max(1, Number(args.start_line || 1) || 1);
|
||||
const requested = Math.max(1, Number(args.num_lines || cap) || cap);
|
||||
const window = Math.min(requested, cap);
|
||||
const startIdx = Math.max(0, startLine - 1);
|
||||
const selected = allLines.slice(startIdx, startIdx + window);
|
||||
const outContent = selected.join('\n');
|
||||
const endLine = startLine + selected.length - 1;
|
||||
const truncated = (allLines.length > selected.length) || startLine > 1 || requested > cap;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: absPath,
|
||||
content: outContent,
|
||||
size: outContent.length,
|
||||
lines: allLines.length,
|
||||
window: {
|
||||
retrieval_mode: mode,
|
||||
start_line: startLine,
|
||||
end_line: endLine,
|
||||
returned_lines: selected.length,
|
||||
max_lines_cap: cap,
|
||||
truncated,
|
||||
},
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to read file: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// WRITE TOOL
|
||||
export interface WriteToolArgs {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function executeWrite(args: WriteToolArgs): Promise<ToolResult> {
|
||||
try {
|
||||
if (!args || typeof args.path !== 'string' || !args.path.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'path is required'
|
||||
};
|
||||
}
|
||||
if (typeof (args as any).content !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'content must be a string'
|
||||
};
|
||||
}
|
||||
const absPath = resolveWorkspacePath(args.path);
|
||||
const pathCheck = isPathAllowed(absPath);
|
||||
if (!pathCheck.allowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: pathCheck.reason
|
||||
};
|
||||
}
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(absPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
// Write file
|
||||
await fs.writeFile(absPath, args.content, 'utf-8');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: absPath,
|
||||
size: args.content.length,
|
||||
lines: args.content.split('\n').length
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to write file: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// EDIT TOOL (find and replace)
|
||||
export interface EditToolArgs {
|
||||
path: string;
|
||||
old_str: string;
|
||||
new_str: string;
|
||||
}
|
||||
|
||||
export async function executeEdit(args: EditToolArgs): Promise<ToolResult> {
|
||||
try {
|
||||
const absPath = resolveWorkspacePath(args.path);
|
||||
const pathCheck = isPathAllowed(absPath);
|
||||
if (!pathCheck.allowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: pathCheck.reason
|
||||
};
|
||||
}
|
||||
// Read current content
|
||||
const content = await fs.readFile(absPath, 'utf-8');
|
||||
// Check if old_str exists
|
||||
if (!content.includes(args.old_str)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `String not found in file: "${args.old_str.slice(0, 50)}..."`
|
||||
};
|
||||
}
|
||||
// Count occurrences
|
||||
const occurrences = (content.match(new RegExp(args.old_str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
if (occurrences > 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: `String appears ${occurrences} times in file. For safety, it must appear exactly once. Please be more specific.`
|
||||
};
|
||||
}
|
||||
// Perform replacement
|
||||
const newContent = content.replace(args.old_str, args.new_str);
|
||||
// Write back
|
||||
await fs.writeFile(absPath, newContent, 'utf-8');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: absPath,
|
||||
replacements: 1,
|
||||
old_length: content.length,
|
||||
new_length: newContent.length,
|
||||
diff: newContent.length - content.length
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to edit file: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// LIST DIRECTORY TOOL
|
||||
export interface ListToolArgs {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function executeList(args: ListToolArgs): Promise<ToolResult> {
|
||||
try {
|
||||
const absPath = resolveWorkspacePath(args.path);
|
||||
const pathCheck = isPathAllowed(absPath);
|
||||
if (!pathCheck.allowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: pathCheck.reason
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(absPath, { withFileTypes: true });
|
||||
|
||||
const files = entries
|
||||
.filter(e => e.isFile())
|
||||
.map(e => e.name);
|
||||
|
||||
const directories = entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: absPath,
|
||||
files,
|
||||
directories,
|
||||
total: entries.length
|
||||
}
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to list directory: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tool exports
|
||||
export const readTool = {
|
||||
name: 'read',
|
||||
description: 'Read file contents (snippet-windowed by retrieval mode caps)',
|
||||
execute: executeRead,
|
||||
schema: {
|
||||
path: 'string (required) - Path to the file to read',
|
||||
start_line: 'number (optional) - 1-based starting line (default 1)',
|
||||
num_lines: 'number (optional) - number of lines to return (capped by retrieval mode)',
|
||||
}
|
||||
};
|
||||
|
||||
export const writeTool = {
|
||||
name: 'write',
|
||||
description: 'Create or overwrite a file',
|
||||
execute: executeWrite,
|
||||
schema: {
|
||||
path: 'string (required) - Path to the file',
|
||||
content: 'string (required) - File contents'
|
||||
}
|
||||
};
|
||||
|
||||
export const editTool = {
|
||||
name: 'edit',
|
||||
description: 'Edit a file by replacing text (string must appear exactly once)',
|
||||
execute: executeEdit,
|
||||
schema: {
|
||||
path: 'string (required) - Path to the file',
|
||||
old_str: 'string (required) - Text to find (must appear exactly once)',
|
||||
new_str: 'string (required) - Replacement text'
|
||||
}
|
||||
};
|
||||
|
||||
export const listTool = {
|
||||
name: 'list',
|
||||
description: 'List files and directories',
|
||||
execute: executeList,
|
||||
schema: {
|
||||
path: 'string (required) - Path to directory'
|
||||
}
|
||||
};
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────────
|
||||
import { rmSync, existsSync } from 'fs';
|
||||
|
||||
async function executeDelete(args: { path: string; recursive?: boolean }): Promise<ToolResult> {
|
||||
if (!args.path?.trim()) return { success: false, error: 'path is required' };
|
||||
const absPath = resolveWorkspacePath(args.path);
|
||||
if (!existsSync(absPath)) return { success: false, error: `Path does not exist: ${absPath}` };
|
||||
try {
|
||||
rmSync(absPath, { recursive: args.recursive ?? false, force: true });
|
||||
return { success: true, stdout: `Deleted: ${absPath}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Delete failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteTool = {
|
||||
name: 'delete',
|
||||
description: 'Delete a file or directory',
|
||||
execute: executeDelete,
|
||||
schema: {
|
||||
path: 'string (required) - Path to delete',
|
||||
recursive: 'boolean (optional) - Delete directories recursively (default false)'
|
||||
}
|
||||
};
|
||||
|
||||
// ── RENAME / MOVE ───────────────────────────────────────────────────────────
|
||||
export interface RenameArgs {
|
||||
path: string;
|
||||
new_path: string;
|
||||
}
|
||||
export async function executeRename(args: RenameArgs): Promise<ToolResult> {
|
||||
try {
|
||||
const src = resolveWorkspacePath(args.path);
|
||||
const dest = resolveWorkspacePath(args.new_path);
|
||||
const srcCheck = isPathAllowed(src);
|
||||
const destCheck = isPathAllowed(dest);
|
||||
if (!srcCheck.allowed) return { success: false, error: srcCheck.reason };
|
||||
if (!destCheck.allowed) return { success: false, error: destCheck.reason };
|
||||
// Ensure source exists
|
||||
if (!(await fs.stat(src).catch(() => null))) {
|
||||
return { success: false, error: `Source does not exist: ${src}` };
|
||||
}
|
||||
// Ensure destination dir
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.rename(src, dest);
|
||||
return { success: true, data: { from: src, to: dest } };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Rename failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const renameTool = {
|
||||
name: 'rename',
|
||||
description: 'Rename or move a file/directory',
|
||||
execute: executeRename,
|
||||
schema: {
|
||||
path: 'string (required) - Existing path',
|
||||
new_path: 'string (required) - New path'
|
||||
}
|
||||
};
|
||||
|
||||
// ── COPY ─────────────────────────────────────────────────────────────────────
|
||||
export interface CopyArgs {
|
||||
path: string;
|
||||
dest: string;
|
||||
}
|
||||
export async function executeCopy(args: CopyArgs): Promise<ToolResult> {
|
||||
try {
|
||||
const src = resolveWorkspacePath(args.path);
|
||||
const dest = resolveWorkspacePath(args.dest);
|
||||
const srcCheck = isPathAllowed(src);
|
||||
const destCheck = isPathAllowed(dest);
|
||||
if (!srcCheck.allowed) return { success: false, error: srcCheck.reason };
|
||||
if (!destCheck.allowed) return { success: false, error: destCheck.reason };
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.copyFile(src, dest);
|
||||
return { success: true, data: { from: src, to: dest } };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Copy failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const copyTool = {
|
||||
name: 'copy',
|
||||
description: 'Copy a file',
|
||||
execute: executeCopy,
|
||||
schema: {
|
||||
path: 'string (required) - Source file',
|
||||
dest: 'string (required) - Destination path'
|
||||
}
|
||||
};
|
||||
|
||||
// ── MKDIR ────────────────────────────────────────────────────────────────────
|
||||
export interface MkdirArgs {
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
}
|
||||
export async function executeMkdir(args: MkdirArgs): Promise<ToolResult> {
|
||||
try {
|
||||
const abs = resolveWorkspacePath(args.path);
|
||||
const pathCheck = isPathAllowed(abs);
|
||||
if (!pathCheck.allowed) return { success: false, error: pathCheck.reason };
|
||||
await fs.mkdir(abs, { recursive: args.recursive ?? true });
|
||||
return { success: true, data: { path: abs } };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Mkdir failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const mkdirTool = {
|
||||
name: 'mkdir',
|
||||
description: 'Create a directory',
|
||||
execute: executeMkdir,
|
||||
schema: {
|
||||
path: 'string (required) - Directory path',
|
||||
recursive: 'boolean (optional) - Create parents'
|
||||
}
|
||||
};
|
||||
|
||||
// ── STAT / INFO ──────────────────────────────────────────────────────────────
|
||||
export interface StatArgs {
|
||||
path: string;
|
||||
}
|
||||
export async function executeStat(args: StatArgs): Promise<ToolResult> {
|
||||
try {
|
||||
const abs = resolveWorkspacePath(args.path);
|
||||
const pathCheck = isPathAllowed(abs);
|
||||
if (!pathCheck.allowed) return { success: false, error: pathCheck.reason };
|
||||
const st = await fs.stat(abs);
|
||||
return { success: true, data: { path: abs, size: st.size, mtime: st.mtime, isFile: st.isFile(), isDirectory: st.isDirectory() } };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Stat failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const statTool = {
|
||||
name: 'stat',
|
||||
description: 'Get file info',
|
||||
execute: executeStat,
|
||||
schema: {
|
||||
path: 'string (required) - Path to file or directory'
|
||||
}
|
||||
};
|
||||
|
||||
// ── APPEND ───────────────────────────────────────────────────────────────────
|
||||
export interface AppendArgs {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
export async function executeAppend(args: AppendArgs): Promise<ToolResult> {
|
||||
try {
|
||||
const abs = resolveWorkspacePath(args.path);
|
||||
const pathCheck = isPathAllowed(abs);
|
||||
if (!pathCheck.allowed) return { success: false, error: pathCheck.reason };
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.appendFile(abs, args.content, 'utf-8');
|
||||
return { success: true, data: { path: abs } };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Append failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const appendTool = {
|
||||
name: 'append',
|
||||
description: 'Append text to a file (creates file if missing)',
|
||||
execute: executeAppend,
|
||||
schema: {
|
||||
path: 'string (required) - Path to file',
|
||||
content: 'string (required) - Text to append'
|
||||
}
|
||||
};
|
||||
|
||||
// ── APPLY PATCH ────────────────────────────────────────────────────────────────
|
||||
export interface ApplyPatchArgs {
|
||||
patch: string;
|
||||
check?: boolean;
|
||||
}
|
||||
|
||||
export async function executeApplyPatch(args: ApplyPatchArgs): Promise<ToolResult> {
|
||||
const patchText = String(args?.patch || '');
|
||||
if (!patchText.trim()) {
|
||||
return { success: false, error: 'patch is required (unified diff string).' };
|
||||
}
|
||||
|
||||
const targetPaths = extractPatchTargetPaths(patchText);
|
||||
const validation = validatePatchPaths(targetPaths);
|
||||
if (!validation.ok) return { success: false, error: validation.error };
|
||||
|
||||
const workspacePath = getConfig().getConfig().workspace.path;
|
||||
const tempPatchPath = path.join(
|
||||
os.tmpdir(),
|
||||
`smallclaw-apply-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPatchPath, patchText, 'utf-8');
|
||||
const checked = await runGitApply(workspacePath, ['apply', '--check', '--whitespace=nowarn', '--recount', '--verbose', tempPatchPath]);
|
||||
const checkedOutput = [checked.stdout, checked.stderr].filter(Boolean).join('\n');
|
||||
const skippedOnCheck = countSkippedPatches(checkedOutput);
|
||||
if (skippedOnCheck >= validation.relativePaths.length) {
|
||||
const msg = truncateOutput(checkedOutput) || 'Patch check skipped all target files.';
|
||||
return { success: false, error: `apply_patch check failed: ${msg}` };
|
||||
}
|
||||
|
||||
if (args.check === true) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
checked_only: true,
|
||||
files: validation.relativePaths,
|
||||
file_count: validation.relativePaths.length,
|
||||
},
|
||||
stdout: `Patch check passed for ${validation.relativePaths.length} file(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
const applied = await runGitApply(workspacePath, ['apply', '--whitespace=nowarn', '--recount', '--verbose', tempPatchPath]);
|
||||
const rawOutput = [applied.stdout, applied.stderr].filter(Boolean).join('\n');
|
||||
const skippedOnApply = countSkippedPatches(rawOutput);
|
||||
if (skippedOnApply >= validation.relativePaths.length) {
|
||||
const msg = truncateOutput(rawOutput) || 'Patch apply skipped all target files.';
|
||||
return { success: false, error: `apply_patch failed: ${msg}` };
|
||||
}
|
||||
const output = truncateOutput(rawOutput);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
files: validation.relativePaths,
|
||||
file_count: validation.relativePaths.length,
|
||||
},
|
||||
stdout: output || `Patch applied to ${validation.relativePaths.length} file(s).`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const details = truncateOutput(String(err?.stderr || err?.stdout || err?.message || err || 'unknown error'));
|
||||
return { success: false, error: `apply_patch failed: ${details}` };
|
||||
} finally {
|
||||
await fs.unlink(tempPatchPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export const applyPatchTool = {
|
||||
name: 'apply_patch',
|
||||
description: 'Apply a unified diff patch to workspace files',
|
||||
execute: executeApplyPatch,
|
||||
schema: {
|
||||
patch: 'string (required) - Unified diff patch text',
|
||||
check: 'boolean (optional) - Validate patch only without applying it',
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* memory-file-search.ts — Keyword search across persona files
|
||||
*
|
||||
* Exposes memory_file_search tool: searches USER.md + SOUL.md (+ optionally
|
||||
* IDENTITY.md and today's intraday notes) by keyword, returning only matching
|
||||
* snippets — not full file contents.
|
||||
*
|
||||
* Distinct from memory_search which searches the structured fact store.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
export async function executeMemoryFileSearch(args: {
|
||||
keywords: string | string[];
|
||||
scope?: string;
|
||||
context_lines?: number;
|
||||
}): Promise<ToolResult> {
|
||||
// Parse keywords
|
||||
let keywords: string[] = [];
|
||||
if (Array.isArray(args?.keywords)) {
|
||||
keywords = args.keywords.map(k => String(k).toLowerCase().trim()).filter(Boolean);
|
||||
} else if (typeof args?.keywords === 'string') {
|
||||
keywords = args.keywords
|
||||
.split(/[\s,]+/)
|
||||
.map(k => k.toLowerCase().trim())
|
||||
.filter(k => k.length > 0);
|
||||
}
|
||||
|
||||
if (keywords.length === 0) {
|
||||
return { success: false, error: 'No valid keywords provided' };
|
||||
}
|
||||
|
||||
const scope = String(args?.scope || 'both').toLowerCase().trim();
|
||||
const contextLines = Math.max(0, Math.min(3, Number(args?.context_lines ?? 1)));
|
||||
|
||||
const workspacePath = getConfig().getWorkspacePath();
|
||||
|
||||
// Determine which files to search
|
||||
const filesToSearch: Array<{ label: string; path: string }> = [];
|
||||
|
||||
if (scope === 'user' || scope === 'both') {
|
||||
filesToSearch.push({ label: 'user.md', path: path.join(workspacePath, 'USER.md') });
|
||||
}
|
||||
if (scope === 'soul' || scope === 'both') {
|
||||
filesToSearch.push({ label: 'soul.md', path: path.join(workspacePath, 'SOUL.md') });
|
||||
}
|
||||
if (scope === 'identity') {
|
||||
filesToSearch.push({ label: 'identity.md', path: path.join(workspacePath, 'IDENTITY.md') });
|
||||
}
|
||||
if (scope === 'intraday') {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
filesToSearch.push({
|
||||
label: `intraday-notes (${today})`,
|
||||
path: path.join(workspacePath, 'memory', `${today}-intraday-notes.md`),
|
||||
});
|
||||
}
|
||||
|
||||
const matches: Array<{
|
||||
file: string;
|
||||
line_number: number;
|
||||
matched_keywords: string[];
|
||||
snippet: string;
|
||||
relevance: number;
|
||||
}> = [];
|
||||
|
||||
for (const fileInfo of filesToSearch) {
|
||||
if (!fs.existsSync(fileInfo.path)) continue;
|
||||
|
||||
const content = fs.readFileSync(fileInfo.path, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lowerLine = lines[i].toLowerCase();
|
||||
const matchedKeywords = keywords.filter(kw => lowerLine.includes(kw));
|
||||
if (matchedKeywords.length === 0) continue;
|
||||
|
||||
const startLine = Math.max(0, i - contextLines);
|
||||
const endLine = Math.min(lines.length - 1, i + contextLines);
|
||||
const snippet = lines.slice(startLine, endLine + 1).join('\n');
|
||||
|
||||
matches.push({
|
||||
file: fileInfo.label,
|
||||
line_number: i + 1,
|
||||
matched_keywords: matchedKeywords,
|
||||
snippet,
|
||||
relevance: matchedKeywords.length / keywords.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance, limit to 15 matches
|
||||
matches.sort((a, b) => b.relevance - a.relevance || a.line_number - b.line_number);
|
||||
const limited = matches.slice(0, 15);
|
||||
|
||||
const stdout = limited.length > 0
|
||||
? limited.map(m =>
|
||||
`[${m.file}:${m.line_number}] (matched: ${m.matched_keywords.join(', ')})\n${m.snippet}`
|
||||
).join('\n\n---\n\n')
|
||||
: 'No matches found.';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout,
|
||||
data: {
|
||||
keywords,
|
||||
scope,
|
||||
total_matches: matches.length,
|
||||
shown: limited.length,
|
||||
matches: limited,
|
||||
note: 'Returns snippets only, not full files. Limited to top 15 matches.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const memoryFileSearchTool = {
|
||||
name: 'memory_file_search',
|
||||
description: 'Search persona files (USER.md, SOUL.md, IDENTITY.md, intraday notes) by keyword. Returns matching snippets only — not full file. Use when you want to quickly check if something was recorded without reading the whole file.',
|
||||
execute: executeMemoryFileSearch,
|
||||
schema: {
|
||||
keywords: 'string or array (required) — keywords to search for (space/comma separated)',
|
||||
scope: 'string (optional) — which files: user, soul, identity, intraday, or both (default: both = user+soul)',
|
||||
context_lines: 'number (optional, 0-3) — lines of context around each match (default: 1)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keywords: {
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'array', items: { type: 'string' } },
|
||||
],
|
||||
description: 'Keywords to search for',
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
enum: ['user', 'soul', 'identity', 'intraday', 'both'],
|
||||
description: 'Which files to search (default: both = user + soul)',
|
||||
},
|
||||
context_lines: {
|
||||
type: 'number',
|
||||
description: 'Lines of context around each match (0-3, default: 1)',
|
||||
},
|
||||
},
|
||||
required: ['keywords'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface MMRItem {
|
||||
id: string;
|
||||
score: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface MMROptions {
|
||||
enabled?: boolean;
|
||||
lambda?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
function tokenize(text: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const t of String(text || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/)) {
|
||||
if (t.length >= 3) out.add(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
if (!a.size && !b.size) return 0;
|
||||
let intersection = 0;
|
||||
for (const t of a) {
|
||||
if (b.has(t)) intersection += 1;
|
||||
}
|
||||
const union = a.size + b.size - intersection;
|
||||
return union > 0 ? (intersection / union) : 0;
|
||||
}
|
||||
|
||||
export function mmrRerank(items: MMRItem[], opts: MMROptions = {}): MMRItem[] {
|
||||
if (!Array.isArray(items) || items.length <= 1) return Array.isArray(items) ? items : [];
|
||||
if (opts.enabled === false) return items.slice();
|
||||
|
||||
const lambda = clamp(typeof opts.lambda === 'number' ? opts.lambda : 0.7, 0, 1);
|
||||
const max = Math.max(1, Math.min(Math.floor(opts.max ?? items.length), items.length));
|
||||
const maxScore = Math.max(1e-9, ...items.map((i) => Number.isFinite(i.score) ? i.score : 0));
|
||||
|
||||
const pool = items.map((item) => ({
|
||||
item,
|
||||
tokens: tokenize(item.content),
|
||||
rel: clamp((Number.isFinite(item.score) ? item.score : 0) / maxScore, 0, 1),
|
||||
}));
|
||||
|
||||
const chosen: typeof pool = [];
|
||||
while (chosen.length < max && pool.length > 0) {
|
||||
let bestIdx = 0;
|
||||
let bestValue = -Infinity;
|
||||
|
||||
for (let i = 0; i < pool.length; i++) {
|
||||
const candidate = pool[i];
|
||||
let maxSim = 0;
|
||||
for (const picked of chosen) {
|
||||
const sim = jaccard(candidate.tokens, picked.tokens);
|
||||
if (sim > maxSim) maxSim = sim;
|
||||
}
|
||||
const mmrValue = (lambda * candidate.rel) - ((1 - lambda) * maxSim);
|
||||
if (mmrValue > bestValue) {
|
||||
bestValue = mmrValue;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
const [next] = pool.splice(bestIdx, 1);
|
||||
if (next) chosen.push(next);
|
||||
}
|
||||
|
||||
return chosen.map((x) => x.item);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* memory-read.ts — Read full persona file contents
|
||||
*
|
||||
* Exposes memory_read tool: reads USER.md, SOUL.md, or IDENTITY.md in full.
|
||||
* Complements memory_search (snippets) and persona_read (line-numbered).
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
const FILE_MAP: Record<string, string> = {
|
||||
user: 'USER.md',
|
||||
soul: 'SOUL.md',
|
||||
identity: 'IDENTITY.md',
|
||||
memory: 'MEMORY.md',
|
||||
};
|
||||
|
||||
export async function executeMemoryRead(args: { target: string }): Promise<ToolResult> {
|
||||
const target = String(args?.target || '').toLowerCase().trim();
|
||||
const filename = FILE_MAP[target];
|
||||
|
||||
if (!filename) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid target "${target}". Valid options: ${Object.keys(FILE_MAP).join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const workspacePath = getConfig().getWorkspacePath();
|
||||
const filePath = path.join(workspacePath, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `File not found: ${filename}`,
|
||||
};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return {
|
||||
success: true,
|
||||
stdout: content,
|
||||
data: {
|
||||
target,
|
||||
file: filename,
|
||||
line_count: content.split('\n').length,
|
||||
char_count: content.length,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to read ${FILE_MAP[target] || target}: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const memoryReadTool = {
|
||||
name: 'memory_read',
|
||||
description: 'Read complete contents of a persona/memory file (user, soul, identity, or memory). Use when you need full context before making updates.',
|
||||
execute: executeMemoryRead,
|
||||
schema: {
|
||||
target: 'string (required) — which file to read: user, soul, identity, or memory',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
enum: ['user', 'soul', 'identity', 'memory'],
|
||||
description: 'Which memory file to read in full',
|
||||
},
|
||||
},
|
||||
required: ['target'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getConfig } from '../config/config.js';
|
||||
|
||||
export function getMemoryTruncateLength(): number {
|
||||
try {
|
||||
const cfg = getConfig().getConfig();
|
||||
const raw = Number(cfg.memory_options?.truncate_length ?? 1000);
|
||||
if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return 1000;
|
||||
}
|
||||
|
||||
export function sanitizeMemoryText(
|
||||
input: any,
|
||||
options?: { trim?: boolean; truncateLength?: number }
|
||||
): string {
|
||||
if (input == null) return '';
|
||||
let text = '';
|
||||
try {
|
||||
text = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
} catch {
|
||||
text = String(input);
|
||||
}
|
||||
|
||||
text = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
||||
|
||||
const truncateLen = Number.isFinite(Number(options?.truncateLength))
|
||||
? Math.max(32, Math.floor(Number(options?.truncateLength)))
|
||||
: getMemoryTruncateLength();
|
||||
if (text.length > truncateLen) text = text.slice(0, truncateLen) + '\n...[truncated]';
|
||||
|
||||
return options?.trim === false ? text : text.trim();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
import { loadMemory, updateMemory } from '../config/soul-loader.js';
|
||||
import { queryFactRecords } from '../gateway/fact-store.js';
|
||||
import { sanitizeMemoryText } from './memory-utils.js';
|
||||
|
||||
// MEMORY_WRITE: model appends or replaces a bullet in memory.md
|
||||
export async function executeMemoryWrite(args: { fact: string; action?: 'append' | 'replace_all' | 'upsert'; key?: string; reference?: string; source_tool?: string; source_output?: string; actor?: 'agent' | 'user' | 'system' }): Promise<ToolResult> {
|
||||
if (!args.fact?.trim()) return { success: false, error: 'fact is required' };
|
||||
const action = args.action ?? 'append';
|
||||
|
||||
try {
|
||||
const fact = sanitizeMemoryText(args.fact.trim());
|
||||
const actor = args.actor || 'agent';
|
||||
const reference = args.reference ? sanitizeMemoryText(args.reference) : undefined;
|
||||
const source_tool = args.source_tool ? sanitizeMemoryText(args.source_tool) : undefined;
|
||||
const source_output = args.source_output ? sanitizeMemoryText(args.source_output) : undefined;
|
||||
const key = args.key ? sanitizeMemoryText(args.key) : undefined;
|
||||
|
||||
// Build bullet with metadata
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(`[${actor}]`);
|
||||
if (key) metaParts.push(`[key=${key}]`);
|
||||
if (reference) metaParts.push(`[ref=${reference}]`);
|
||||
if (source_tool) metaParts.push(`[src=${source_tool}]`);
|
||||
const meta = metaParts.join('');
|
||||
const bullet = `- ${meta} ${fact}`;
|
||||
|
||||
if (action === 'replace_all') {
|
||||
updateMemory(`# Memory\n\n${bullet}\n`);
|
||||
} else if (action === 'upsert') {
|
||||
const current = loadMemory();
|
||||
const lines = current ? current.split(/\r?\n/) : [];
|
||||
const hasHeader = lines.some(l => /^#\s*memory\b/i.test(l.trim()));
|
||||
const keyPattern = key ? new RegExp(`\\[key=${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`) : null;
|
||||
const filtered = lines.filter(line => {
|
||||
const t = line.trim();
|
||||
if (!t) return true;
|
||||
if (/^#\s*memory\b/i.test(t)) return true;
|
||||
if (!t.startsWith('-')) return true;
|
||||
if (keyPattern && keyPattern.test(t)) return false;
|
||||
return true;
|
||||
});
|
||||
const out = [];
|
||||
if (hasHeader) out.push(...filtered);
|
||||
else out.push('# Memory', '', ...filtered.filter(l => l.trim() !== '# Memory'));
|
||||
if (out.length > 0 && out[out.length - 1].trim() !== '') out.push('');
|
||||
out.push(bullet);
|
||||
out.push('');
|
||||
updateMemory(out.join('\n'));
|
||||
} else {
|
||||
const current = loadMemory();
|
||||
// Remove placeholder line if present
|
||||
const cleaned = current.replace(/- First run: no facts stored yet\.|\n?/, '').trim();
|
||||
const bullets = cleaned ? `${cleaned}\n${bullet}\n` : `# Memory\n\n${bullet}\n`;
|
||||
updateMemory(bullets);
|
||||
}
|
||||
|
||||
return { success: true, stdout: `Memory updated: ${fact}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Memory write failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const memoryWriteTool = {
|
||||
name: 'memory_write',
|
||||
description: 'Persist a fact to long-term memory (survives restarts)',
|
||||
execute: executeMemoryWrite,
|
||||
schema: {
|
||||
fact: 'string (required) - The fact to remember (e.g. "User prefers Python 3.12")',
|
||||
action: 'string (optional) - "append" (default) adds a new bullet, "upsert" replaces bullet with same key, "replace_all" clears and rewrites',
|
||||
key: 'string (optional) - unique key for upsert (e.g., "fact:us-attorney-general")',
|
||||
reference: 'string (optional) - job id or session reference to associate with this fact',
|
||||
source_tool: 'string (optional) - tool that produced this fact (e.g., web_search)',
|
||||
source_output: 'string (optional) - raw tool output or snippet',
|
||||
actor: 'string (optional) - who added the fact: agent|user|system'
|
||||
},
|
||||
};
|
||||
|
||||
// MEMORY_SEARCH: semantic lookup over typed memory facts
|
||||
export async function executeMemorySearch(args: { query: string; session_id?: string; max?: number }): Promise<ToolResult> {
|
||||
const query = String(args?.query || '').trim();
|
||||
if (!query) return { success: false, error: 'query is required' };
|
||||
|
||||
try {
|
||||
const sessionId = String(args?.session_id || '').trim() || undefined;
|
||||
const maxRaw = Number(args?.max ?? 5);
|
||||
const max = Number.isFinite(maxRaw) ? Math.min(Math.max(Math.floor(maxRaw), 1), 25) : 5;
|
||||
|
||||
const matches = queryFactRecords({
|
||||
query,
|
||||
session_id: sessionId,
|
||||
includeGlobal: true,
|
||||
max,
|
||||
includeStale: false,
|
||||
});
|
||||
|
||||
const results = matches.map((m) => ({
|
||||
key: m.key,
|
||||
value: m.value,
|
||||
scope: m.scope,
|
||||
session_id: m.session_id,
|
||||
type: m.type,
|
||||
confidence: m.confidence,
|
||||
source_tool: m.source_tool,
|
||||
source_url: m.source_url,
|
||||
updated_at: m.updated_at,
|
||||
verified_at: m.verified_at,
|
||||
expires_at: m.expires_at,
|
||||
actor: m.actor,
|
||||
}));
|
||||
|
||||
const stdout = results.length
|
||||
? results.map((r, i) => `${i + 1}. [${r.key}] ${r.value}`).join('\n')
|
||||
: 'No memory matches found.';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout,
|
||||
data: {
|
||||
query,
|
||||
session_id: sessionId,
|
||||
count: results.length,
|
||||
results,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Memory search failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const memorySearchTool = {
|
||||
name: 'memory_search',
|
||||
description: 'Search long-term memory for relevant facts',
|
||||
execute: executeMemorySearch,
|
||||
schema: {
|
||||
query: 'string (required) - what to look for',
|
||||
session_id: 'string (optional) - narrow to session scope',
|
||||
max: 'number (optional, default 5) - max results',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* persona.ts — Personality Growth & Memory Flush Tools
|
||||
*
|
||||
* Three tools:
|
||||
* - persona_update: surgically update SOUL.md, USER.md, IDENTITY.md in workspace
|
||||
* - memory_flush: write end-of-session memory before context compresses (called internally)
|
||||
* - persona_read: read a persona file so the AI can inspect before editing
|
||||
*
|
||||
* These tools let SmallClaw grow its personality, build knowledge of its user,
|
||||
* and preserve that knowledge across sessions and context resets.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
// ─── Allowed persona files ────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_PERSONA_FILES = new Set([
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
'IDENTITY.md',
|
||||
'MEMORY.md',
|
||||
'AGENTS.md',
|
||||
'TOOLS.md',
|
||||
]);
|
||||
|
||||
function getWorkspacePath(): string {
|
||||
return getConfig().getWorkspacePath();
|
||||
}
|
||||
|
||||
function resolvePersonaFile(filename: string): string | null {
|
||||
const clean = path.basename(filename.trim());
|
||||
if (!ALLOWED_PERSONA_FILES.has(clean)) return null;
|
||||
return path.join(getWorkspacePath(), clean);
|
||||
}
|
||||
|
||||
// ─── persona_read ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PersonaReadArgs {
|
||||
file: string; // one of SOUL.md, USER.md, IDENTITY.md, MEMORY.md, etc.
|
||||
}
|
||||
|
||||
export async function executePersonaRead(args: PersonaReadArgs): Promise<ToolResult> {
|
||||
if (!args?.file?.trim()) {
|
||||
return { success: false, error: `file is required. Allowed: ${[...ALLOWED_PERSONA_FILES].join(', ')}` };
|
||||
}
|
||||
const absPath = resolvePersonaFile(args.file);
|
||||
if (!absPath) {
|
||||
return { success: false, error: `Not an editable persona file: "${args.file}". Allowed: ${[...ALLOWED_PERSONA_FILES].join(', ')}` };
|
||||
}
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return { success: false, error: `File not found: ${args.file}` };
|
||||
}
|
||||
const content = fs.readFileSync(absPath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const numbered = lines.map((line, i) => `${String(i + 1).padStart(4)} | ${line}`).join('\n');
|
||||
return {
|
||||
success: true,
|
||||
data: { file: args.file, lines: lines.length, size: content.length, content: numbered },
|
||||
};
|
||||
}
|
||||
|
||||
export const personaReadTool = {
|
||||
name: 'persona_read',
|
||||
description:
|
||||
'Read a workspace persona file (SOUL.md, USER.md, IDENTITY.md, MEMORY.md, etc.) with line numbers. ' +
|
||||
'Always read before editing so you can make surgical changes.',
|
||||
execute: executePersonaRead,
|
||||
schema: {
|
||||
file: `string (required) — one of: ${[...ALLOWED_PERSONA_FILES].join(', ')}`,
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
required: ['file'],
|
||||
properties: {
|
||||
file: { type: 'string', description: `Persona file to read: ${[...ALLOWED_PERSONA_FILES].join(', ')}` },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── persona_update ───────────────────────────────────────────────────────────
|
||||
|
||||
export type PersonaUpdateMode =
|
||||
| 'append_section' // Add a new section at the end
|
||||
| 'upsert_line' // Find a line by key and replace it, or append if not found
|
||||
| 'replace_section' // Replace everything between two headings
|
||||
| 'full_rewrite'; // Replace the entire file (use sparingly)
|
||||
|
||||
export interface PersonaUpdateArgs {
|
||||
file: string; // SOUL.md, USER.md, etc.
|
||||
mode: PersonaUpdateMode;
|
||||
content: string; // new content to insert/replace with
|
||||
section_heading?: string; // for replace_section: heading to target (e.g. "## Notes")
|
||||
key?: string; // for upsert_line: substring to match existing line
|
||||
reason?: string; // why this update (logged to daily memory)
|
||||
}
|
||||
|
||||
export async function executePersonaUpdate(args: PersonaUpdateArgs): Promise<ToolResult> {
|
||||
if (!args?.file?.trim()) {
|
||||
return { success: false, error: 'file is required' };
|
||||
}
|
||||
if (!args?.mode?.trim()) {
|
||||
return { success: false, error: 'mode is required: append_section | upsert_line | replace_section | full_rewrite' };
|
||||
}
|
||||
if (!args?.content?.trim() && args.mode !== 'replace_section') {
|
||||
return { success: false, error: 'content is required' };
|
||||
}
|
||||
|
||||
const absPath = resolvePersonaFile(args.file);
|
||||
if (!absPath) {
|
||||
return { success: false, error: `Not an editable persona file: "${args.file}". Allowed: ${[...ALLOWED_PERSONA_FILES].join(', ')}` };
|
||||
}
|
||||
|
||||
let existing = '';
|
||||
if (fs.existsSync(absPath)) {
|
||||
existing = fs.readFileSync(absPath, 'utf-8');
|
||||
}
|
||||
|
||||
let newContent: string;
|
||||
|
||||
switch (args.mode) {
|
||||
case 'append_section': {
|
||||
// Append a new block at the end of the file
|
||||
const sep = existing.trimEnd() ? '\n\n' : '';
|
||||
newContent = existing.trimEnd() + sep + args.content.trim() + '\n';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'upsert_line': {
|
||||
// Find a line containing the key and replace it, or append
|
||||
if (!args.key?.trim()) {
|
||||
return { success: false, error: 'key is required for upsert_line mode' };
|
||||
}
|
||||
const lines = existing.split('\n');
|
||||
const keyLower = args.key.toLowerCase();
|
||||
const matchIdx = lines.findIndex(l => l.toLowerCase().includes(keyLower));
|
||||
if (matchIdx >= 0) {
|
||||
lines[matchIdx] = args.content.trim();
|
||||
newContent = lines.join('\n');
|
||||
} else {
|
||||
// Not found — append
|
||||
newContent = existing.trimEnd() + '\n' + args.content.trim() + '\n';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'replace_section': {
|
||||
// Replace content between two headings
|
||||
if (!args.section_heading?.trim()) {
|
||||
return { success: false, error: 'section_heading is required for replace_section mode' };
|
||||
}
|
||||
const heading = args.section_heading.trim();
|
||||
const lines = existing.split('\n');
|
||||
const startIdx = lines.findIndex(l => l.trim() === heading || l.trim().startsWith(heading));
|
||||
if (startIdx < 0) {
|
||||
// Section not found — append it as a new section
|
||||
const sep = existing.trimEnd() ? '\n\n' : '';
|
||||
newContent = existing.trimEnd() + sep + heading + '\n\n' + (args.content?.trim() || '') + '\n';
|
||||
} else {
|
||||
// Find the next heading of same or higher level
|
||||
const headingLevel = (heading.match(/^#+/) || [''])[0].length;
|
||||
let endIdx = lines.length;
|
||||
for (let i = startIdx + 1; i < lines.length; i++) {
|
||||
const m = lines[i].match(/^(#+)\s/);
|
||||
if (m && m[1].length <= headingLevel) {
|
||||
endIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const before = lines.slice(0, startIdx + 1).join('\n');
|
||||
const after = lines.slice(endIdx).join('\n');
|
||||
const mid = '\n\n' + (args.content?.trim() || '') + '\n\n';
|
||||
newContent = before + mid + (after ? after : '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'full_rewrite': {
|
||||
newContent = args.content.trim() + '\n';
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, error: `Unknown mode: "${args.mode}". Use: append_section | upsert_line | replace_section | full_rewrite` };
|
||||
}
|
||||
|
||||
// Write atomically
|
||||
const tmp = `${absPath}.tmp-${Date.now()}`;
|
||||
fs.writeFileSync(tmp, newContent, 'utf-8');
|
||||
fs.renameSync(tmp, absPath);
|
||||
|
||||
// Log the update to today's daily memory
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const memDir = path.join(getWorkspacePath(), 'memory');
|
||||
fs.mkdirSync(memDir, { recursive: true });
|
||||
const logPath = path.join(memDir, `${today}.md`);
|
||||
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
|
||||
const reason = args.reason ? ` — ${args.reason}` : '';
|
||||
fs.appendFileSync(logPath, `[${ts}] **persona_update** ${args.file} (${args.mode})${reason}\n`);
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: `${args.file} updated (${args.mode}).${args.reason ? ' Reason: ' + args.reason : ''}`,
|
||||
data: { file: args.file, mode: args.mode, chars_written: newContent.length },
|
||||
};
|
||||
}
|
||||
|
||||
export const personaUpdateTool = {
|
||||
name: 'persona_update',
|
||||
description:
|
||||
'Update a workspace personality file (SOUL.md, USER.md, IDENTITY.md, MEMORY.md). ' +
|
||||
'Use this to grow your personality, record user preferences, and keep your model of the user current. ' +
|
||||
'ALWAYS use persona_read first to see the current content. ' +
|
||||
'Prefer upsert_line for single facts, append_section for new topics, replace_section for updating existing sections.',
|
||||
execute: executePersonaUpdate,
|
||||
schema: {
|
||||
file: `string (required) — file to update: ${[...ALLOWED_PERSONA_FILES].join(', ')}`,
|
||||
mode: 'string (required) — append_section | upsert_line | replace_section | full_rewrite',
|
||||
content: 'string (required) — new content to insert or replace with',
|
||||
section_heading: 'string (for replace_section) — heading to target, e.g. "## Notes"',
|
||||
key: 'string (for upsert_line) — substring to find the target line',
|
||||
reason: 'string (optional) — brief note about why this update is being made',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
required: ['file', 'mode', 'content'],
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
mode: { type: 'string', enum: ['append_section', 'upsert_line', 'replace_section', 'full_rewrite'] },
|
||||
content: { type: 'string' },
|
||||
section_heading: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── memory_flush (internal — called by server-v2 when context is getting long) ──
|
||||
|
||||
export interface MemoryFlushResult {
|
||||
triggered: boolean;
|
||||
reason: string;
|
||||
messageInjected?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a memory flush should fire based on history length.
|
||||
* OpenClaw triggers this at ~70% context utilization.
|
||||
* For SmallClaw with 8K context, trigger at 25+ messages.
|
||||
*/
|
||||
export function shouldTriggerMemoryFlush(historyLength: number, maxMessages: number = 30): boolean {
|
||||
return historyLength >= Math.floor(maxMessages * 0.8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the silent memory flush system message.
|
||||
* This is injected into the next turn when context pressure is detected.
|
||||
* The model should write durable notes and reply with NO_REPLY if nothing meaningful to write.
|
||||
*/
|
||||
export function buildMemoryFlushPrompt(): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return [
|
||||
'[SYSTEM: Context window is getting long. Before this session compacts, do the following NOW:]',
|
||||
'1. Use memory_write to persist any new facts, preferences, or decisions learned this session',
|
||||
'2. Use persona_update to update USER.md with anything new you learned about your human',
|
||||
'3. Write a brief session note to memory/' + today + '.md using the write tool',
|
||||
'4. If you updated SOUL.md, note what changed',
|
||||
'',
|
||||
'After writing, reply with just: NO_REPLY',
|
||||
'Only send a real reply if there is something important the user needs to know.',
|
||||
'[/SYSTEM]',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { execFile } from 'child_process';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
// ─── Engine paths ──────────────────────────────────────────────────────────────
|
||||
const PYTHON_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'pptx_gen.py');
|
||||
|
||||
// ─── Paths ──────────────────────────────────────────────────────────────────────
|
||||
const SKIN_DIR = path.join(__dirname, '..', '..', 'ppt', 'skin');
|
||||
const TEMPLATE_DIR = path.join(__dirname, '..', '..', 'ppt', 'template');
|
||||
const LEGACY_SKIN_DIR = path.join(__dirname, '..', '..', 'assets', 'pptx_template');
|
||||
|
||||
// Resolve skin directory: prefer ppt/skin, fall back to legacy assets/pptx_template
|
||||
const ACTIVE_SKIN_DIR = fs.existsSync(SKIN_DIR) ? SKIN_DIR
|
||||
: fs.existsSync(LEGACY_SKIN_DIR) ? LEGACY_SKIN_DIR
|
||||
: SKIN_DIR;
|
||||
|
||||
const SKIN_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
|
||||
const SKIN_NAMES = fs.existsSync(ACTIVE_SKIN_DIR)
|
||||
? fs.readdirSync(ACTIVE_SKIN_DIR)
|
||||
.filter(f => SKIN_EXTENSIONS.includes(path.extname(f).toLowerCase()))
|
||||
.map(f => path.basename(f, path.extname(f)))
|
||||
: [];
|
||||
|
||||
// Load template configs
|
||||
interface TemplateConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
font: string;
|
||||
colors: { title: string; subtitle: string; body: string; accent: string; background: string };
|
||||
titleSlide: { titleSize: number; subtitleSize: number; align: string };
|
||||
contentSlide: { titleSize: number; bodySize: number; bulletColor: string; underlineAccent: boolean };
|
||||
sectionSlide: { fillColor: string; titleColor: string; titleSize: number };
|
||||
darkSkin: string[];
|
||||
}
|
||||
|
||||
const TEMPLATE_CONFIGS: Record<string, TemplateConfig> = {};
|
||||
if (fs.existsSync(TEMPLATE_DIR)) {
|
||||
for (const f of fs.readdirSync(TEMPLATE_DIR).filter(f => f.endsWith('.json'))) {
|
||||
try {
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(TEMPLATE_DIR, f), 'utf-8'));
|
||||
TEMPLATE_CONFIGS[cfg.name.toLowerCase()] = cfg;
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
}
|
||||
const TEMPLATE_NAMES = Object.keys(TEMPLATE_CONFIGS);
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Repair malformed JSON: close truncated brackets, strip trailing garbage. */
|
||||
function repairJson(input: string): string {
|
||||
let s = input.trim();
|
||||
// Close open strings
|
||||
let inStr = false, escaped = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (ch === '\\' && inStr) { escaped = true; continue; }
|
||||
if (ch === '"' && !escaped) { inStr = !inStr; }
|
||||
}
|
||||
if (inStr) s += '"';
|
||||
// Count unmatched brackets outside strings
|
||||
let curly = 0, square = 0;
|
||||
inStr = false; escaped = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (ch === '\\' && inStr) { escaped = true; continue; }
|
||||
if (ch === '"' && !escaped) { inStr = !inStr; continue; }
|
||||
if (inStr) continue;
|
||||
if (ch === '{') curly++;
|
||||
else if (ch === '}') curly--;
|
||||
else if (ch === '[') square++;
|
||||
else if (ch === ']') square--;
|
||||
}
|
||||
while (square > 0) { s += ']'; square--; }
|
||||
while (curly > 0) { s += '}'; curly--; }
|
||||
// Try as-is first
|
||||
try { JSON.parse(s); return s; } catch {}
|
||||
// Trailing garbage: find the last closing bracket that yields valid JSON
|
||||
for (let end = s.length; end > 1; end--) {
|
||||
const candidate = s.slice(0, end).trimEnd();
|
||||
if (candidate.endsWith('}') || candidate.endsWith(']')) {
|
||||
try { JSON.parse(candidate); return candidate; } catch {}
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Generate PPTX using python-pptx via the scripts/pptx_gen.py script. */
|
||||
async function generateWithPython(spec: PresentationSpec, workspacePath: string): Promise<ToolResult> {
|
||||
const tmpSpecPath = path.join(workspacePath, `.pptx_spec_${Date.now()}.json`);
|
||||
try {
|
||||
// Write spec to temp file
|
||||
fs.writeFileSync(tmpSpecPath, JSON.stringify(spec), 'utf-8');
|
||||
|
||||
const result = await new Promise<{ success: boolean; path?: string; folder?: string; filename?: string; slides?: number; warnings?: string[]; stdout?: string; error?: string; download_url?: string; preview_url?: string }>((resolve, reject) => {
|
||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
|
||||
execFile(pythonCmd, [PYTHON_SCRIPT, tmpSpecPath, workspacePath], {
|
||||
timeout: 60_000,
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
windowsHide: true,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
}, (err, stdout, stderr) => {
|
||||
if (stderr) {
|
||||
console.error(`[pptx] Python stderr: ${stderr.slice(0, 800)}`);
|
||||
}
|
||||
if (err) {
|
||||
console.error(`[pptx] Python process failed: ${err.message}`);
|
||||
reject(new Error(`Python PPTX engine failed: ${err.message}\n${stderr?.slice(0, 500) || ''}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
resolve(parsed);
|
||||
} catch {
|
||||
console.error(`[pptx] Invalid JSON from Python. stdout: ${(stdout || '').slice(0, 400)}`);
|
||||
reject(new Error(`Python PPTX engine returned invalid JSON: ${(stdout || '').slice(0, 300)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[pptx] Python engine reported failure: ${result.error}`);
|
||||
return { success: false, error: result.error || 'Unknown Python PPTX error' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: result.stdout || `Presentation created: ${result.folder}/${result.filename} (${result.slides} slides, engine: python-pptx)`,
|
||||
data: {
|
||||
filename: result.filename,
|
||||
slides: result.slides,
|
||||
path: result.path,
|
||||
folder: result.folder,
|
||||
warnings: result.warnings || [],
|
||||
downloadUrl: result.download_url,
|
||||
previewUrl: result.preview_url,
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error(`[pptx] generateWithPython error: ${e.message}`);
|
||||
return { success: false, error: `Python PPTX engine error: ${e.message}` };
|
||||
} finally {
|
||||
// Clean up temp spec file
|
||||
try { fs.unlinkSync(tmpSpecPath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Spec types ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SlideSpec {
|
||||
type?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
bullets?: string[];
|
||||
bullet_points?: string[];
|
||||
body?: string;
|
||||
content?: string;
|
||||
font_size?: number;
|
||||
image_path?: string;
|
||||
image_url?: string;
|
||||
background?: string;
|
||||
template?: string;
|
||||
layout?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface FontSizes {
|
||||
title?: number;
|
||||
subtitle?: number;
|
||||
slide_title?: number;
|
||||
body?: number;
|
||||
bullets?: number;
|
||||
section?: number;
|
||||
image_title?: number;
|
||||
}
|
||||
|
||||
interface PresentationSpec {
|
||||
filename?: string;
|
||||
title?: string;
|
||||
theme?: string;
|
||||
template?: string;
|
||||
default_skin?: string;
|
||||
font_sizes?: FontSizes;
|
||||
slides: SlideSpec[];
|
||||
}
|
||||
|
||||
// ─── Tool ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const pptxTool: import('./registry.js').Tool = {
|
||||
name: 'create_presentation',
|
||||
description: 'Generate a PowerPoint (.pptx) file with slides. Creates a project folder named after the title. Use image_url on slides to auto-download images into the project folder. Put ALL slides in a single call. NEVER write Python scripts to create PPTX — use this tool instead. Missing images become red placeholders.',
|
||||
schema: {
|
||||
spec: 'JSON object: { filename, title, template, theme, font_sizes, slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, background, template }] }',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
spec: {
|
||||
type: 'object',
|
||||
description: 'Presentation specification',
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Output filename (default: presentation.pptx)' },
|
||||
title: { type: 'string', description: 'Presentation title — used to name the project folder' },
|
||||
template: { type: 'string', description: `Template: ${TEMPLATE_NAMES.join(', ')}` },
|
||||
theme: { type: 'string', description: 'Override: "dark" or "light"' },
|
||||
font_sizes: { type: 'object', description: 'Override default font sizes (pt). Keys: title(36), subtitle(18), slide_title(24), body(16), bullets(16), section(32), image_title(22)' },
|
||||
slides: {
|
||||
type: 'array',
|
||||
description: 'Array of slide specifications',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", or "blank"' },
|
||||
title: { type: 'string', description: 'Slide title text' },
|
||||
subtitle: { type: 'string', description: 'Subtitle (for title slides)' },
|
||||
bullets: { type: 'array', items: { type: 'string' }, description: 'Bullet point texts' },
|
||||
bullet_points: { type: 'array', items: { type: 'string' }, description: 'Bullet point texts (alias for bullets)' },
|
||||
body: { type: 'string', description: 'Body text (alternative to bullets)' },
|
||||
content: { type: 'string', description: 'Body text (alias for body — use either)' },
|
||||
font_size: { type: 'number', description: 'Per-slide body/bullet font size override (pt). E.g. 20 for larger text.' },
|
||||
image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become red placeholders.' },
|
||||
image_url: { type: 'string', description: 'Auto-download this image URL into the project folder. Overrides image_path if both provided. Supports http/https URLs.' },
|
||||
background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` },
|
||||
template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` },
|
||||
notes: { type: 'string', description: 'Speaker notes' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['slides'],
|
||||
},
|
||||
},
|
||||
required: ['spec'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
let spec: PresentationSpec;
|
||||
try {
|
||||
spec = typeof args.spec === 'string' ? JSON.parse(repairJson(args.spec)) : args.spec;
|
||||
} catch (e: any) {
|
||||
return { success: false, error: `Failed to parse spec JSON: ${e.message}` };
|
||||
}
|
||||
if (!spec || !Array.isArray(spec.slides) || spec.slides.length === 0) {
|
||||
return { success: false, error: 'spec.slides must be a non-empty array' };
|
||||
}
|
||||
|
||||
const config = getConfig().getConfig() as any;
|
||||
const workspacePath = args._workspacePath || config.workspace?.path || process.cwd();
|
||||
|
||||
// Inject config defaults into spec so Python engine can use them
|
||||
if (!spec.template && config.ppt?.template) spec.template = config.ppt.template;
|
||||
if (!spec.default_skin && config.ppt?.skin) spec.default_skin = config.ppt.skin;
|
||||
return await generateWithPython(spec, workspacePath);
|
||||
},
|
||||
};
|
||||
|
||||
export const editPptxTool: import('./registry.js').Tool = {
|
||||
name: 'edit_presentation',
|
||||
description: 'Append slides to an existing PowerPoint (.pptx) file. Provide the path to the existing .pptx and the new slides to add. Use image_url on slides to auto-download images.',
|
||||
schema: {
|
||||
path: 'Path to existing .pptx file (relative to workspace or absolute)',
|
||||
spec: 'JSON object: { slides: [{ type, title, subtitle, bullets, body, content, font_size, image_path, image_url, background, template }] }',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to existing .pptx file (relative to workspace or absolute)' },
|
||||
spec: {
|
||||
type: 'object',
|
||||
description: 'Slide specifications for new slides to append',
|
||||
properties: {
|
||||
font_sizes: { type: 'object', description: 'Override default font sizes (pt). Keys: title(36), subtitle(18), slide_title(24), body(16), bullets(16), section(32), image_title(22)' },
|
||||
slides: {
|
||||
type: 'array',
|
||||
description: 'Array of slide specifications to append',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Slide type: "title", "content", "section", "image", or "blank"' },
|
||||
title: { type: 'string', description: 'Slide title text' },
|
||||
subtitle: { type: 'string', description: 'Subtitle (for title slides)' },
|
||||
bullets: { type: 'array', items: { type: 'string' }, description: 'Bullet point texts' },
|
||||
bullet_points: { type: 'array', items: { type: 'string' }, description: 'Bullet point texts (alias for bullets)' },
|
||||
body: { type: 'string', description: 'Body text (alternative to bullets)' },
|
||||
content: { type: 'string', description: 'Body text (alias for body — use either)' },
|
||||
font_size: { type: 'number', description: 'Per-slide body/bullet font size override (pt). E.g. 20 for larger text.' },
|
||||
image_path: { type: 'string', description: 'Image file path relative to project folder (e.g. "photo.jpg"). Missing images become red placeholders.' },
|
||||
image_url: { type: 'string', description: 'Auto-download this image URL into the project folder. Overrides image_path if both provided. Supports http/https URLs.' },
|
||||
background: { type: 'string', description: `Skin name (${SKIN_NAMES.join(', ')}) or image file path (relative to project folder)` },
|
||||
template: { type: 'string', description: `Per-slide template override: ${TEMPLATE_NAMES.join(', ')}` },
|
||||
notes: { type: 'string', description: 'Speaker notes' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['slides'],
|
||||
},
|
||||
},
|
||||
required: ['path', 'spec'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const existingPath = args.path;
|
||||
if (!existingPath) {
|
||||
return { success: false, error: 'path is required — provide the existing .pptx file path' };
|
||||
}
|
||||
|
||||
const config = getConfig().getConfig() as any;
|
||||
const workspacePath = args._workspacePath || config.workspace?.path || process.cwd();
|
||||
|
||||
// Resolve absolute path
|
||||
const absPath = path.isAbsolute(existingPath)
|
||||
? existingPath
|
||||
: path.join(workspacePath, existingPath);
|
||||
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return { success: false, error: `PPTX file not found: ${absPath}` };
|
||||
}
|
||||
|
||||
let spec: PresentationSpec;
|
||||
try {
|
||||
spec = typeof args.spec === 'string' ? JSON.parse(repairJson(args.spec)) : args.spec;
|
||||
} catch (e: any) {
|
||||
return { success: false, error: `Failed to parse spec JSON: ${e.message}` };
|
||||
}
|
||||
if (!spec || !Array.isArray(spec.slides) || spec.slides.length === 0) {
|
||||
return { success: false, error: 'spec.slides must be a non-empty array' };
|
||||
}
|
||||
|
||||
// Pass existing_path to Python engine
|
||||
(spec as any).existing_path = absPath;
|
||||
return await generateWithPython(spec, workspacePath);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
import { shellTool } from './shell.js';
|
||||
import { readTool, writeTool, editTool, listTool, deleteTool, renameTool, copyTool, mkdirTool, statTool, appendTool, applyPatchTool } from './files.js';
|
||||
import { webSearchTool, webFetchTool } from './web.js';
|
||||
import { memorySearchTool, memoryWriteTool } from './memory.js';
|
||||
import { memoryReadTool } from './memory-read.js';
|
||||
import { memoryFileSearchTool } from './memory-file-search.js';
|
||||
import { skillListTool, skillSearchTool, skillInstallTool, skillRemoveTool, skillExecTool } from './skills.js';
|
||||
import { timeNowTool } from './time.js';
|
||||
import { selfUpdateTool } from './self-update.js';
|
||||
import { readSourceTool, listSourceTool } from './source-access.js';
|
||||
import { proposeRepairTool } from './self-repair.js';
|
||||
import { personaReadTool, personaUpdateTool } from './persona.js';
|
||||
import { pptxTool, editPptxTool } from './pptx.js';
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
execute: (args: any) => Promise<ToolResult>;
|
||||
schema: Record<string, string>;
|
||||
// Optional explicit OpenAPI-style JSON schema for native function-call parameters.
|
||||
// When provided, this is used instead of description-based type inference.
|
||||
jsonSchema?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type ToolProfile = 'minimal' | 'coding' | 'web' | 'full';
|
||||
|
||||
const TOOL_PROFILE_TOOL_NAMES: Record<Exclude<ToolProfile, 'full'>, ReadonlySet<string>> = {
|
||||
minimal: new Set([
|
||||
'memory_search',
|
||||
'memory_write',
|
||||
'time_now',
|
||||
]),
|
||||
coding: new Set([
|
||||
'shell',
|
||||
'read',
|
||||
'write',
|
||||
'edit',
|
||||
'list',
|
||||
'delete',
|
||||
'rename',
|
||||
'copy',
|
||||
'mkdir',
|
||||
'stat',
|
||||
'append',
|
||||
'apply_patch',
|
||||
'memory_search',
|
||||
'memory_write',
|
||||
]),
|
||||
web: new Set([
|
||||
'web_search',
|
||||
'web_fetch',
|
||||
'memory_search',
|
||||
'memory_write',
|
||||
]),
|
||||
};
|
||||
|
||||
function isToolProfile(value: string): value is ToolProfile {
|
||||
return value === 'minimal' || value === 'coding' || value === 'web' || value === 'full';
|
||||
}
|
||||
|
||||
const spawnAgentTool: Tool = {
|
||||
name: 'spawn_agent',
|
||||
description: 'Spawn a sub-agent to handle a specific task. Returns the agent\'s result.',
|
||||
schema: {
|
||||
agentId: 'ID of the agent to spawn (from config)',
|
||||
task: 'Task description to give the agent',
|
||||
context: 'Optional extra context to inject',
|
||||
maxSteps: 'Max reactor steps (default 8)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agentId: { type: 'string', description: 'ID of the agent to spawn (from config)' },
|
||||
task: { type: 'string', description: 'Task description to give the agent' },
|
||||
context: { type: 'string', description: 'Optional extra context to inject' },
|
||||
maxSteps: { type: 'number', description: 'Max reactor steps (default 8)' },
|
||||
},
|
||||
required: ['agentId', 'task'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
execute: async (params: any): Promise<ToolResult> => {
|
||||
const { spawnAgent } = await import('../agents/spawner.js');
|
||||
const result = await spawnAgent({
|
||||
agentId: params?.agentId,
|
||||
task: params?.task,
|
||||
context: params?.context,
|
||||
maxSteps: params?.maxSteps,
|
||||
});
|
||||
return {
|
||||
success: result.success,
|
||||
stdout: result.success
|
||||
? `[${result.agentName}] ${result.result}`
|
||||
: `[${result.agentName}] FAILED: ${result.error}`,
|
||||
data: result,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
class ToolRegistry {
|
||||
private tools: Map<string, Tool> = new Map();
|
||||
|
||||
private registerSafe(tool: Tool): void {
|
||||
try {
|
||||
this.register(tool);
|
||||
} catch (err: any) {
|
||||
const label = tool?.name || 'unknown_tool';
|
||||
const message = String(err?.message || err || 'unknown error');
|
||||
console.warn(`[tools] Failed to register "${label}": ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Core filesystem + shell
|
||||
this.registerSafe(shellTool);
|
||||
this.registerSafe(readTool);
|
||||
this.registerSafe(writeTool);
|
||||
this.registerSafe(editTool);
|
||||
this.registerSafe(listTool);
|
||||
this.registerSafe(deleteTool);
|
||||
// Additional filesystem utilities
|
||||
this.registerSafe(renameTool);
|
||||
this.registerSafe(copyTool);
|
||||
this.registerSafe(mkdirTool);
|
||||
this.registerSafe(statTool);
|
||||
this.registerSafe(appendTool);
|
||||
this.registerSafe(applyPatchTool);
|
||||
// Web tools
|
||||
this.registerSafe(webSearchTool);
|
||||
this.registerSafe(webFetchTool);
|
||||
// Memory tools
|
||||
this.registerSafe(memoryWriteTool);
|
||||
this.registerSafe(memorySearchTool);
|
||||
this.registerSafe(memoryReadTool);
|
||||
this.registerSafe(memoryFileSearchTool);
|
||||
// Time tool (system clock — no network)
|
||||
this.registerSafe(timeNowTool);
|
||||
// ClawHub skills tools
|
||||
this.registerSafe(skillListTool);
|
||||
this.registerSafe(skillSearchTool);
|
||||
this.registerSafe(skillInstallTool);
|
||||
this.registerSafe(skillRemoveTool);
|
||||
this.registerSafe(skillExecTool);
|
||||
// Self-update tool
|
||||
this.registerSafe(selfUpdateTool);
|
||||
// Self-repair tools (source read + repair proposal)
|
||||
this.registerSafe(readSourceTool);
|
||||
this.registerSafe(listSourceTool);
|
||||
this.registerSafe(proposeRepairTool);
|
||||
// Persona / memory growth tools
|
||||
this.registerSafe(personaReadTool);
|
||||
this.registerSafe(personaUpdateTool);
|
||||
// PPTX generation tool
|
||||
this.registerSafe(pptxTool);
|
||||
this.registerSafe(editPptxTool);
|
||||
// Multi-agent spawn tool
|
||||
this.registerSafe(spawnAgentTool);
|
||||
}
|
||||
|
||||
register(tool: Tool): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
get(name: string): Tool | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
list(): Tool[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
private listByProfile(profile: ToolProfile = 'full'): Tool[] {
|
||||
if (profile === 'full') return this.list();
|
||||
const toolNames = TOOL_PROFILE_TOOL_NAMES[profile];
|
||||
return this.list().filter((tool) => toolNames.has(tool.name));
|
||||
}
|
||||
|
||||
resolveToolProfile(profile?: string | null): ToolProfile {
|
||||
const normalized = String(profile || '').trim().toLowerCase();
|
||||
return isToolProfile(normalized) ? normalized : 'full';
|
||||
}
|
||||
|
||||
async execute(toolName: string, args: any): Promise<ToolResult> {
|
||||
const tool = this.tools.get(toolName);
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tool not found: ${toolName}. Available tools: ${Array.from(this.tools.keys()).join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await tool.execute(args);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tool execution failed: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getToolSchemas(profile: ToolProfile = 'full'): string {
|
||||
const tools = this.listByProfile(profile);
|
||||
return tools.map(tool => {
|
||||
const schemaStr = Object.entries(tool.schema)
|
||||
.map(([key, desc]) => ` - ${key}: ${desc}`)
|
||||
.join('\n');
|
||||
|
||||
return `${tool.name}: ${tool.description}\n${schemaStr}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
getToolDefinitionsForChat(profile: ToolProfile = 'full'): any[] {
|
||||
const tools = this.listByProfile(profile);
|
||||
const inferParamSchema = (key: string, desc: string): any => {
|
||||
const k = String(key || '').toLowerCase();
|
||||
const d = String(desc || '').toLowerCase();
|
||||
if (/\b(true|false|boolean)\b/.test(d) || /\b(force|strict|recursive|enabled|disabled|stream|dry_run|dry run)\b/.test(k)) {
|
||||
return { type: 'boolean', description: String(desc || '') };
|
||||
}
|
||||
if (
|
||||
/\b(integer|number|count|max|min|limit|timeout|ms|seconds?|minutes?|days?)\b/.test(d)
|
||||
|| /(max|min|count|limit|timeout|num|days|hours|minutes|seconds|retries|offset|line|chars|size|port)$/.test(k)
|
||||
) {
|
||||
return { type: 'number', description: String(desc || '') };
|
||||
}
|
||||
if (/\bjson\b/.test(d) || /(args|params|options|payload|values)_?json$/.test(k)) {
|
||||
return {
|
||||
anyOf: [
|
||||
{ type: 'object' },
|
||||
{ type: 'array' },
|
||||
{ type: 'string' },
|
||||
],
|
||||
description: String(desc || ''),
|
||||
};
|
||||
}
|
||||
return { type: 'string', description: String(desc || '') };
|
||||
};
|
||||
const buildInferredParameters = (tool: Tool): Record<string, any> => {
|
||||
const properties: Record<string, any> = {};
|
||||
for (const [key, desc] of Object.entries(tool.schema || {})) {
|
||||
properties[key] = inferParamSchema(key, String(desc || ''));
|
||||
}
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
additionalProperties: true,
|
||||
};
|
||||
};
|
||||
const normalizeExplicitParameters = (tool: Tool): Record<string, any> | null => {
|
||||
const raw = tool.jsonSchema;
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const normalized: Record<string, any> = { ...raw };
|
||||
if (normalized.type == null) normalized.type = 'object';
|
||||
if (normalized.properties == null) normalized.properties = {};
|
||||
if (normalized.additionalProperties == null) normalized.additionalProperties = true;
|
||||
return normalized;
|
||||
};
|
||||
return tools.map((tool) => {
|
||||
const explicitParameters = normalizeExplicitParameters(tool);
|
||||
const inferredParameters = buildInferredParameters(tool);
|
||||
const parameters = explicitParameters || inferredParameters;
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
isToolEnabled(toolName: string, enabledTools: string[]): boolean {
|
||||
return enabledTools.includes(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let registryInstance: ToolRegistry | null = null;
|
||||
|
||||
export function getToolRegistry(): ToolRegistry {
|
||||
if (!registryInstance) {
|
||||
registryInstance = new ToolRegistry();
|
||||
}
|
||||
return registryInstance;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* self-repair.ts — SmallClaw Self-Repair Tool
|
||||
*
|
||||
* Flow:
|
||||
* 1. AI analyzes an error using read_source + list_source
|
||||
* 2. AI calls propose_repair() with error context + a unified diff patch
|
||||
* 3. The patch is stored in .smallclaw/pending-repairs/<id>.json
|
||||
* 4. A formatted proposal is returned (Telegram sends it to the user)
|
||||
* 5. User replies /approve <id> or /reject <id> in Telegram
|
||||
* 6. On approval: patch is applied to src/, npm run build runs, gateway restarts
|
||||
* 7. On rejection or build failure: patch is discarded/reverted
|
||||
*
|
||||
* The AI CANNOT self-apply patches. The approval gate is enforced here.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
// ─── Paths ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getSmallClawRoot(): string {
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
function getSmallClawDataDir(): string {
|
||||
const projectData = path.join(getSmallClawRoot(), '.smallclaw');
|
||||
const homeData = path.join(os.homedir(), '.smallclaw');
|
||||
return fs.existsSync(projectData) ? projectData : homeData;
|
||||
}
|
||||
|
||||
function getPendingRepairsDir(): string {
|
||||
const dir = path.join(getSmallClawDataDir(), 'pending-repairs');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function getRepairFilePath(id: string): string {
|
||||
return path.join(getPendingRepairsDir(), `${id}.json`);
|
||||
}
|
||||
|
||||
// ─── Repair Record Type ───────────────────────────────────────────────────────
|
||||
|
||||
export interface PendingRepair {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
errorSummary: string;
|
||||
rootCause: string;
|
||||
affectedFile: string; // e.g. "src/gateway/telegram-channel.ts"
|
||||
affectedLines: string; // e.g. "lines 45-52" (human-readable)
|
||||
fixDescription: string; // plain English description of the fix
|
||||
patch: string; // unified diff (git format)
|
||||
status: 'pending' | 'approved' | 'rejected' | 'applied' | 'failed';
|
||||
taskId?: string; // if triggered from a background task
|
||||
buildOutput?: string; // populated after apply attempt
|
||||
}
|
||||
|
||||
// ─── Storage Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
export function savePendingRepair(repair: PendingRepair): void {
|
||||
const filePath = getRepairFilePath(repair.id);
|
||||
fs.writeFileSync(filePath, JSON.stringify(repair, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export function loadPendingRepair(id: string): PendingRepair | null {
|
||||
const filePath = getRepairFilePath(id);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as PendingRepair;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listPendingRepairs(): PendingRepair[] {
|
||||
const dir = getPendingRepairsDir();
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8')) as PendingRepair; }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter((r): r is PendingRepair => r !== null && r.status === 'pending')
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export function deletePendingRepair(id: string): boolean {
|
||||
const filePath = getRepairFilePath(id);
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── propose_repair tool ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ProposeRepairArgs {
|
||||
error_summary: string; // 1-2 sentence error description
|
||||
root_cause: string; // What is the actual bug
|
||||
affected_file: string; // e.g. "gateway/telegram-channel.ts" (relative to src/)
|
||||
affected_lines: string; // e.g. "lines 45-52"
|
||||
fix_description: string; // Plain English: what the fix does
|
||||
patch: string; // Unified diff patch (git format, paths relative to project root)
|
||||
task_id?: string; // Optional: ID of the background task that hit the error
|
||||
}
|
||||
|
||||
export async function executeProposeRepair(args: ProposeRepairArgs): Promise<ToolResult> {
|
||||
// Validate required fields
|
||||
const required: (keyof ProposeRepairArgs)[] = [
|
||||
'error_summary', 'root_cause', 'affected_file', 'fix_description', 'patch',
|
||||
];
|
||||
for (const field of required) {
|
||||
if (!args?.[field]?.toString().trim()) {
|
||||
return { success: false, error: `${field} is required` };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the patch looks like a unified diff
|
||||
const patchText = String(args.patch || '').trim();
|
||||
if (!patchText.includes('---') || !patchText.includes('+++') || !patchText.includes('@@')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'patch must be a valid unified diff (must contain ---, +++, and @@ markers)',
|
||||
};
|
||||
}
|
||||
|
||||
// Dry-run the patch to make sure it applies cleanly before storing
|
||||
const root = getSmallClawRoot();
|
||||
const tmpPatch = path.join(os.tmpdir(), `smallclaw-repair-check-${Date.now()}.patch`);
|
||||
try {
|
||||
fs.writeFileSync(tmpPatch, patchText, 'utf-8');
|
||||
execSync(`git apply --check --whitespace=nowarn "${tmpPatch}"`, {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} catch (checkErr: any) {
|
||||
const details = String(checkErr?.stderr || checkErr?.stdout || checkErr?.message || 'unknown').trim();
|
||||
return {
|
||||
success: false,
|
||||
error: `Patch dry-run failed — it does not apply cleanly to current source:\n${details}\n\nDouble-check the diff context lines match the actual file content.`,
|
||||
};
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpPatch); } catch {}
|
||||
}
|
||||
|
||||
// Generate a short ID for the repair
|
||||
const id = randomUUID().slice(0, 8);
|
||||
|
||||
const repair: PendingRepair = {
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
errorSummary: String(args.error_summary).trim(),
|
||||
rootCause: String(args.root_cause).trim(),
|
||||
affectedFile: `src/${String(args.affected_file).replace(/^src\//, '').trim()}`,
|
||||
affectedLines: String(args.affected_lines || 'unspecified').trim(),
|
||||
fixDescription: String(args.fix_description).trim(),
|
||||
patch: patchText,
|
||||
status: 'pending',
|
||||
taskId: args.task_id ? String(args.task_id).trim() : undefined,
|
||||
};
|
||||
|
||||
savePendingRepair(repair);
|
||||
|
||||
// Format the proposal message (this gets sent to Telegram)
|
||||
const proposal = formatRepairProposal(repair);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { repair_id: id, repair },
|
||||
stdout: proposal,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRepairProposal(repair: PendingRepair): string {
|
||||
const lines = [
|
||||
`🔧 <b>Self-Repair Proposal #${repair.id}</b>`,
|
||||
``,
|
||||
`📍 <b>File:</b> <code>${repair.affectedFile}</code> (${repair.affectedLines})`,
|
||||
``,
|
||||
`❌ <b>Error:</b>`,
|
||||
repair.errorSummary,
|
||||
``,
|
||||
`🔍 <b>Root Cause:</b>`,
|
||||
repair.rootCause,
|
||||
``,
|
||||
`🩹 <b>Proposed Fix:</b>`,
|
||||
repair.fixDescription,
|
||||
``,
|
||||
`<pre>${repair.patch.slice(0, 1500)}${repair.patch.length > 1500 ? '\n...(truncated)' : ''}</pre>`,
|
||||
``,
|
||||
`━━━━━━━━━━━━━━━━━━━━━━━━`,
|
||||
`Reply <b>/approve ${repair.id}</b> to apply this fix, rebuild, and restart.`,
|
||||
`Reply <b>/reject ${repair.id}</b> to discard it.`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export const proposeRepairTool = {
|
||||
name: 'propose_repair',
|
||||
description:
|
||||
'Propose a source code repair after analyzing an error. The patch is stored as pending and ' +
|
||||
'sent to the user over Telegram for approval. The patch is NEVER applied automatically — ' +
|
||||
'the user must reply /approve <id> to trigger the apply + rebuild flow. ' +
|
||||
'IMPORTANT: Always use read_source and list_source FIRST to understand the bug before calling this.',
|
||||
execute: executeProposeRepair,
|
||||
schema: {
|
||||
error_summary: 'string (required) — 1-2 sentence description of the error',
|
||||
root_cause: 'string (required) — technical explanation of what caused the bug',
|
||||
affected_file: 'string (required) — file path relative to src/, e.g. "gateway/telegram-channel.ts"',
|
||||
affected_lines: 'string (required) — human-readable line range, e.g. "lines 45-52"',
|
||||
fix_description: 'string (required) — plain English description of what the fix does',
|
||||
patch: 'string (required) — unified diff patch in git format (paths relative to project root)',
|
||||
task_id: 'string (optional) — ID of the background task that encountered the error',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
required: ['error_summary', 'root_cause', 'affected_file', 'affected_lines', 'fix_description', 'patch'],
|
||||
properties: {
|
||||
error_summary: { type: 'string' },
|
||||
root_cause: { type: 'string' },
|
||||
affected_file: { type: 'string' },
|
||||
affected_lines: { type: 'string' },
|
||||
fix_description: { type: 'string' },
|
||||
patch: { type: 'string' },
|
||||
task_id: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Apply + Build (called by Telegram /approve handler) ─────────────────────
|
||||
|
||||
export interface ApplyRepairResult {
|
||||
success: boolean;
|
||||
repairId: string;
|
||||
message: string;
|
||||
buildOutput?: string;
|
||||
}
|
||||
|
||||
export async function applyApprovedRepair(repairId: string): Promise<ApplyRepairResult> {
|
||||
const repair = loadPendingRepair(repairId);
|
||||
if (!repair) {
|
||||
return { success: false, repairId, message: `No pending repair found with ID: ${repairId}` };
|
||||
}
|
||||
if (repair.status !== 'pending') {
|
||||
return { success: false, repairId, message: `Repair #${repairId} is not pending (status: ${repair.status})` };
|
||||
}
|
||||
|
||||
const root = getSmallClawRoot();
|
||||
const tmpPatch = path.join(os.tmpdir(), `smallclaw-repair-apply-${Date.now()}.patch`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmpPatch, repair.patch, 'utf-8');
|
||||
|
||||
// Step 1: Final check before apply
|
||||
try {
|
||||
execSync(`git apply --check --whitespace=nowarn "${tmpPatch}"`, { cwd: root, stdio: 'pipe' });
|
||||
} catch (checkErr: any) {
|
||||
const details = String(checkErr?.stderr || checkErr?.message || '').slice(0, 500);
|
||||
repair.status = 'failed';
|
||||
repair.buildOutput = `Patch no longer applies cleanly:\n${details}`;
|
||||
savePendingRepair(repair);
|
||||
return {
|
||||
success: false,
|
||||
repairId,
|
||||
message: `❌ Repair #${repairId} — patch no longer applies (source may have changed).\n\n${details}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Apply the patch
|
||||
execSync(`git apply --whitespace=nowarn "${tmpPatch}"`, { cwd: root, stdio: 'pipe' });
|
||||
repair.status = 'approved';
|
||||
savePendingRepair(repair);
|
||||
|
||||
} catch (applyErr: any) {
|
||||
const details = String(applyErr?.stderr || applyErr?.message || '').slice(0, 500);
|
||||
repair.status = 'failed';
|
||||
repair.buildOutput = `Patch apply failed:\n${details}`;
|
||||
savePendingRepair(repair);
|
||||
return { success: false, repairId, message: `❌ Failed to apply patch #${repairId}:\n\n${details}` };
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmpPatch); } catch {}
|
||||
}
|
||||
|
||||
// Step 3: Build
|
||||
let buildOutput = '';
|
||||
try {
|
||||
buildOutput = execSync('npm run build', {
|
||||
cwd: root,
|
||||
encoding: 'utf-8',
|
||||
timeout: 120_000, // 2 min build timeout
|
||||
stdio: 'pipe',
|
||||
});
|
||||
repair.status = 'applied';
|
||||
repair.buildOutput = buildOutput.slice(0, 1000);
|
||||
savePendingRepair(repair);
|
||||
} catch (buildErr: any) {
|
||||
buildOutput = String(buildErr?.stderr || buildErr?.stdout || buildErr?.message || '').slice(0, 800);
|
||||
repair.status = 'failed';
|
||||
repair.buildOutput = buildOutput;
|
||||
savePendingRepair(repair);
|
||||
|
||||
// Revert the patch since build failed
|
||||
const revertPatch = path.join(os.tmpdir(), `smallclaw-repair-revert-${Date.now()}.patch`);
|
||||
try {
|
||||
fs.writeFileSync(revertPatch, repair.patch, 'utf-8');
|
||||
execSync(`git apply --reverse --whitespace=nowarn "${revertPatch}"`, { cwd: root, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Revert also failed — leave a note
|
||||
repair.buildOutput += '\n\n⚠️ Auto-revert also failed. Source may be in a modified state.';
|
||||
savePendingRepair(repair);
|
||||
} finally {
|
||||
try { fs.unlinkSync(revertPatch); } catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
repairId,
|
||||
message: `❌ Patch applied but <b>build failed</b> — patch has been reverted.\n\n<pre>${buildOutput.slice(0, 600)}</pre>`,
|
||||
buildOutput,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 4: Restart gateway (same pattern as self-update.ts)
|
||||
triggerGatewayRestart(root, repairId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
repairId,
|
||||
message: `✅ Repair #${repairId} applied and built successfully!\n\n📍 Fixed: <code>${repair.affectedFile}</code>\n\nGateway is restarting now — I'll be back in a moment.`,
|
||||
buildOutput,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spawns restart detached so the current process can exit cleanly */
|
||||
function triggerGatewayRestart(root: string, repairId: string): void {
|
||||
const isWindows = process.platform === 'win32';
|
||||
try {
|
||||
if (isWindows) {
|
||||
const batPath = path.join(root, 'start-smallclaw.bat');
|
||||
if (fs.existsSync(batPath)) {
|
||||
const child = spawn('cmd.exe', ['/c', batPath], {
|
||||
cwd: root, detached: true, stdio: 'ignore', windowsHide: false,
|
||||
});
|
||||
child.unref();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Cross-platform fallback
|
||||
const child = spawn('npm', ['start'], { cwd: root, detached: true, stdio: 'ignore' });
|
||||
child.unref();
|
||||
} catch (err: any) {
|
||||
console.error(`[self-repair] Restart failed after applying repair #${repairId}:`, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* self-update.ts — SmallClaw Self-Update Tool
|
||||
*
|
||||
* Allows the AI to trigger a self-update of SmallClaw via a Telegram message
|
||||
* or chat command. The tool:
|
||||
* 1. Launches self-update.bat detached (so the current gateway can exit)
|
||||
* 2. Returns a "starting update" message immediately
|
||||
* 3. After the update completes, the restarted gateway sends a Telegram
|
||||
* confirmation message (handled in server-v2.ts startup logic)
|
||||
*
|
||||
* The AI should tell the user "I'm starting the update now — I'll go offline
|
||||
* briefly and message you when I'm back!" before calling this tool.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
// Resolve the SmallClaw root (two levels up from dist/tools/ or src/tools/)
|
||||
function resolveSmallClawRoot(): string {
|
||||
return path.resolve(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
export async function executeSelfUpdate(): Promise<ToolResult> {
|
||||
const root = resolveSmallClawRoot();
|
||||
const batPath = path.join(root, 'self-update.bat');
|
||||
|
||||
if (!fs.existsSync(batPath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `self-update.bat not found at: ${batPath}. Make sure SmallClaw is properly installed.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Write a "pending" marker so the restart knows an update was triggered
|
||||
// (will be replaced by self-update.bat with SUCCESS or FAILED)
|
||||
try {
|
||||
const statusDir = path.join(require('os').homedir(), '.smallclaw');
|
||||
if (!fs.existsSync(statusDir)) fs.mkdirSync(statusDir, { recursive: true });
|
||||
// Don't write yet — self-update.bat will write the final status itself
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
// Spawn detached so this process can exit cleanly while update runs
|
||||
const child = spawn('cmd.exe', ['/c', batPath], {
|
||||
cwd: root,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false, // Show the terminal window so user can see progress
|
||||
});
|
||||
child.unref(); // Don't keep the Node.js event loop alive for this child
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: [
|
||||
'🦞 Self-update initiated!',
|
||||
'',
|
||||
'SmallClaw is now:',
|
||||
' 1. Pulling the latest code',
|
||||
' 2. Rebuilding',
|
||||
' 3. Restarting the gateway',
|
||||
'',
|
||||
'The gateway will go offline briefly (~30-60 seconds).',
|
||||
'You will receive a Telegram message when the update is complete.',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to launch self-update: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const selfUpdateTool = {
|
||||
name: 'self_update',
|
||||
description:
|
||||
'Trigger a SmallClaw self-update. Pulls latest code, rebuilds, and restarts the gateway. ' +
|
||||
'A Telegram message is sent when the update is complete. ' +
|
||||
'IMPORTANT: Before calling this tool, tell the user you are starting the update and will message them when back online.',
|
||||
execute: executeSelfUpdate,
|
||||
schema: {
|
||||
// No arguments needed
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import PTYManager from '../gateway/pty-manager';
|
||||
import path from 'path';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
import { log } from '../security/log-scrubber.js';
|
||||
|
||||
export interface ShellToolArgs {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
// ── Path confinement helper ───────────────────────────────────────────────────
|
||||
// Uses proper path.resolve + path.relative — immune to case, trailing-slash,
|
||||
// and "../" traversal bypasses that defeat simple startsWith() checks.
|
||||
function isPathInsideDir(base: string, target: string): boolean {
|
||||
const resolvedBase = path.resolve(base);
|
||||
const resolvedTarget = path.resolve(target);
|
||||
if (resolvedBase === resolvedTarget) return true;
|
||||
const rel = path.relative(resolvedBase, resolvedTarget);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
// ── Absolute-path detector ────────────────────────────────────────────────────
|
||||
// Catches commands that contain absolute paths outside the workspace even when
|
||||
// cwd is inside it — e.g. `type C:\Windows\System32\config\SAM`
|
||||
function containsOutOfScopeAbsPath(command: string, workspacePath: string): boolean {
|
||||
// Match Windows and POSIX absolute paths embedded in command strings
|
||||
const absPathRe = process.platform === 'win32'
|
||||
? /[A-Za-z]:[/\\][^\s"']+/g
|
||||
: /\/[^\s"']{3,}/g;
|
||||
|
||||
const matches = command.match(absPathRe) || [];
|
||||
for (const match of matches) {
|
||||
try {
|
||||
if (!isPathInsideDir(workspacePath, match)) return true;
|
||||
} catch {
|
||||
// If we can't resolve it, treat as suspicious
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function executeShell(args: ShellToolArgs): Promise<ToolResult> {
|
||||
const config = getConfig().getConfig();
|
||||
const permissions = config.tools.permissions.shell;
|
||||
const workspacePath = path.resolve(config.workspace.path);
|
||||
|
||||
// Determine and resolve working directory
|
||||
const cwd = path.resolve(args.cwd ? args.cwd : workspacePath);
|
||||
|
||||
// ── FIX HIGH-05: use proper path confinement (not startsWith) ──────────────
|
||||
if (permissions.workspace_only) {
|
||||
if (!isPathInsideDir(workspacePath, cwd)) {
|
||||
log.warn('[shell] Blocked: cwd outside workspace:', cwd);
|
||||
return {
|
||||
success: false,
|
||||
error: `Security: Command execution outside workspace is not allowed. Workspace: ${workspacePath}, Requested: ${cwd}`
|
||||
};
|
||||
}
|
||||
|
||||
// Also block commands that reference absolute paths outside workspace
|
||||
if (containsOutOfScopeAbsPath(args.command, workspacePath)) {
|
||||
log.warn('[shell] Blocked: command references path outside workspace:', args.command.slice(0, 120));
|
||||
return {
|
||||
success: false,
|
||||
error: `Security: Command references a path outside the workspace directory.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check config-defined blocked patterns
|
||||
for (const pattern of permissions.blocked_patterns) {
|
||||
if (args.command.includes(pattern)) {
|
||||
log.warn('[shell] Blocked pattern match:', pattern);
|
||||
return {
|
||||
success: false,
|
||||
error: `Security: Command blocked due to dangerous pattern: "${pattern}"`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Hardcoded dangerous command patterns
|
||||
const dangerousCommands: Array<[RegExp, string]> = [
|
||||
[/rm\s+-rf\s+\//, 'rm -rf /'],
|
||||
[/mkfs/, 'filesystem format'],
|
||||
[/dd\s+if=/, 'disk write'],
|
||||
[/>\s*\/dev\//, 'device write'],
|
||||
[/\bsudo\b/, 'privilege escalation'],
|
||||
[/\bsu\s/, 'user switch'],
|
||||
[/chmod\s+777/, 'world-writable permission'],
|
||||
[/\bcurl\b.*\|.*\bbash\b/, 'curl-pipe-bash'],
|
||||
[/\bwget\b.*-O.*\s*-\s*\|/, 'wget-pipe'],
|
||||
];
|
||||
|
||||
for (const [pattern, label] of dangerousCommands) {
|
||||
if (pattern.test(args.command)) {
|
||||
log.warn('[shell] Blocked dangerous command:', label);
|
||||
return {
|
||||
success: false,
|
||||
error: `Security: Potentially destructive command detected (${label}): ${args.command.slice(0, 80)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const pty = PTYManager.getInstance();
|
||||
const output = await pty.runCommand(args.command);
|
||||
return {
|
||||
success: true,
|
||||
stdout: output.trim(),
|
||||
stderr: '',
|
||||
exitCode: 0
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const shellTool = {
|
||||
name: 'shell',
|
||||
description: 'Execute terminal commands in the workspace',
|
||||
execute: executeShell,
|
||||
schema: {
|
||||
command: 'string (required) - The command to execute',
|
||||
cwd: 'string (optional) - Working directory, defaults to workspace'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,553 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ToolResult } from '../types.js';
|
||||
import {
|
||||
listSkillManifests,
|
||||
loadSkillManifest,
|
||||
refreshSkillPack,
|
||||
removeSkillPack,
|
||||
setSkillExecutionEnabled,
|
||||
writeSkillPackFromContent,
|
||||
SkillManifest,
|
||||
} from '../skills/processor.js';
|
||||
import { normalizeSkillId, resolveSkillDir, resolveSkillLockFile } from '../skills/store.js';
|
||||
import { executeShell } from './shell.js';
|
||||
|
||||
export function summarizeSkillForApi(m: SkillManifest): any {
|
||||
return {
|
||||
id: m.id,
|
||||
slug: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
type: m.type,
|
||||
status: m.status,
|
||||
execution_enabled: m.execution_enabled,
|
||||
risk: m.risk,
|
||||
requirements: m.requirements,
|
||||
source: m.source,
|
||||
confirm_gates: m.confirm_gates,
|
||||
templates: m.templates,
|
||||
version: m.version || 'unknown',
|
||||
generated_at: m.generated_at,
|
||||
path: resolveSkillDir(m.id),
|
||||
};
|
||||
}
|
||||
|
||||
function updateLockFromManifest(manifest: SkillManifest): void {
|
||||
try {
|
||||
const lockPath = resolveSkillLockFile();
|
||||
const lockDir = path.dirname(lockPath);
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
let lock: Record<string, any> = {};
|
||||
if (fs.existsSync(lockPath)) {
|
||||
lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
}
|
||||
lock[manifest.id] = {
|
||||
slug: manifest.id,
|
||||
version: manifest.version || 'unknown',
|
||||
installed_at: manifest.source?.installed_at || Date.now(),
|
||||
source_type: manifest.source?.type || 'manual',
|
||||
status: manifest.status,
|
||||
risk_level: manifest.risk?.level || 'low',
|
||||
};
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), 'utf-8');
|
||||
} catch {
|
||||
// best effort only
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromLock(skillId: string): void {
|
||||
try {
|
||||
const lockPath = resolveSkillLockFile();
|
||||
if (!fs.existsSync(lockPath)) return;
|
||||
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
if (lock && typeof lock === 'object' && Object.prototype.hasOwnProperty.call(lock, skillId)) {
|
||||
delete lock[skillId];
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), 'utf-8');
|
||||
}
|
||||
} catch {
|
||||
// best effort only
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeActionId(input: string): string {
|
||||
return String(input || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
const v = String(value ?? '');
|
||||
if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(v)) return v;
|
||||
return `'${v.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function placeholderVariants(raw: string): string[] {
|
||||
const base = String(raw || '').trim();
|
||||
if (!base) return [];
|
||||
const norm = normalizeActionId(base).replace(/_/g, '');
|
||||
const withUnderscore = String(base || '').toLowerCase().replace(/[^a-z0-9]+/g, '_');
|
||||
const withDash = String(base || '').toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
return Array.from(new Set([
|
||||
base,
|
||||
base.toLowerCase(),
|
||||
withUnderscore,
|
||||
withUnderscore.replace(/_/g, ''),
|
||||
withDash,
|
||||
withDash.replace(/-/g, ''),
|
||||
norm,
|
||||
].filter(Boolean)));
|
||||
}
|
||||
|
||||
function pickTemplate(manifest: SkillManifest, action?: string, command?: string): { action: string; label: string; command: string; requires_confirmation: boolean } | null {
|
||||
const templates = Array.isArray(manifest.templates) ? manifest.templates : [];
|
||||
if (!templates.length) return null;
|
||||
|
||||
if (action) {
|
||||
const target = normalizeActionId(action);
|
||||
const found = templates.find((t: any) => {
|
||||
const a = normalizeActionId(String(t?.action || ''));
|
||||
const l = normalizeActionId(String(t?.label || ''));
|
||||
return a === target || l === target;
|
||||
});
|
||||
if (found) return found as any;
|
||||
}
|
||||
|
||||
if (command) {
|
||||
const cmd = String(command || '').trim();
|
||||
const found = templates.find((t: any) => String(t?.command || '').trim() === cmd);
|
||||
if (found) return found as any;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderTemplateCommand(templateCommand: string, params: Record<string, any>): { ok: boolean; command?: string; error?: string; missing?: string[] } {
|
||||
const base = String(templateCommand || '').trim();
|
||||
if (!base) return { ok: false, error: 'Template command is empty' };
|
||||
const input = params && typeof params === 'object' ? params : {};
|
||||
let rendered = base;
|
||||
const missing = new Set<string>();
|
||||
const phs = Array.from(new Set([
|
||||
...Array.from(base.matchAll(/<([^>]+)>/g)).map((m) => String(m[1] || '').trim()),
|
||||
...Array.from(base.matchAll(/\{\{([^}]+)\}\}/g)).map((m) => String(m[1] || '').trim()),
|
||||
].filter(Boolean)));
|
||||
|
||||
for (const ph of phs) {
|
||||
const keys = placeholderVariants(ph);
|
||||
let value: any = undefined;
|
||||
for (const k of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(input, k)) {
|
||||
value = (input as any)[k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (value === undefined || value === null || String(value).trim() === '') {
|
||||
missing.add(ph);
|
||||
continue;
|
||||
}
|
||||
const str = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const safe = shellQuote(str);
|
||||
rendered = rendered.replace(new RegExp(`<${ph.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}>`, 'g'), safe);
|
||||
rendered = rendered.replace(new RegExp(`\\{\\{${ph.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, 'g'), safe);
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
return { ok: false, error: 'Missing template parameters', missing: Array.from(missing) };
|
||||
}
|
||||
if (/<[^>]+>/.test(rendered) || /\{\{[^}]+\}\}/.test(rendered)) {
|
||||
return { ok: false, error: 'Unresolved template placeholders remain' };
|
||||
}
|
||||
return { ok: true, command: rendered.trim() };
|
||||
}
|
||||
|
||||
function hasBlockedShellOperators(command: string): string | null {
|
||||
const c = String(command || '').trim();
|
||||
if (!c) return 'empty_command';
|
||||
if (/[|`]/.test(c)) return 'pipe_or_backtick_not_allowed';
|
||||
if (/&&|\|\|/.test(c)) return 'command_chaining_not_allowed';
|
||||
if (/[<>]/.test(c)) return 'redirection_not_allowed';
|
||||
if (/;\s*/.test(c)) return 'statement_chaining_not_allowed';
|
||||
if (/\$\(/.test(c)) return 'subshell_not_allowed';
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureTemplateShape(template: string, rendered: string): boolean {
|
||||
const esc = String(template || '')
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/<[^>]+>/g, '[\\s\\S]+?')
|
||||
.replace(/\\\{\\\{[^}]+\\\}\\\}/g, '[\\s\\S]+?');
|
||||
try {
|
||||
const re = new RegExp(`^${esc}$`);
|
||||
return re.test(String(rendered || '').trim());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMissing(manifest: SkillManifest): string[] {
|
||||
return [
|
||||
...manifest.requirements.missing_binaries,
|
||||
...manifest.requirements.missing_env,
|
||||
...manifest.requirements.missing_files,
|
||||
];
|
||||
}
|
||||
|
||||
export async function executeSkillList(_args: {}): Promise<ToolResult> {
|
||||
const manifests = listSkillManifests();
|
||||
if (manifests.length === 0) {
|
||||
return { success: true, data: { skills: [] }, stdout: 'No skills installed. Use skill_search to find skills in configured registries.' };
|
||||
}
|
||||
const lines = manifests.map((m) => {
|
||||
const missing = [
|
||||
...m.requirements.missing_binaries,
|
||||
...m.requirements.missing_env,
|
||||
...m.requirements.missing_files,
|
||||
];
|
||||
const missingText = missing.length ? ` missing:${missing.length}` : '';
|
||||
return `- ${m.id} [${m.status}] risk:${m.risk.level}${missingText}`;
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: { skills: manifests.map(summarizeSkillForApi) },
|
||||
stdout: `Installed skills (${manifests.length}):\n${lines.join('\n')}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeSkillSearch(args: { query: string }): Promise<ToolResult> {
|
||||
if (!args.query?.trim()) return { success: false, error: 'query is required' };
|
||||
|
||||
try {
|
||||
const url = `https://clawhub.ai/api/search?q=${encodeURIComponent(args.query)}&limit=8`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'SmallClaw/1.0', Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, error: `Skill registry API returned ${res.status}. Try installing manually: skill_install <slug> confirmed:true` };
|
||||
}
|
||||
|
||||
const data: any = await res.json();
|
||||
const results = Array.isArray(data.results) ? data.results : Array.isArray(data) ? data : [];
|
||||
|
||||
if (results.length === 0) {
|
||||
return { success: true, stdout: `No skills found for: "${args.query}"` };
|
||||
}
|
||||
|
||||
const lines = results.map((r: any) =>
|
||||
`- **${r.slug || r.name}** v${r.version || '?'}: ${r.description || ''}\n Install: skill_install ${r.slug || r.name}`
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: { results },
|
||||
stdout: `Skill registry results for "${args.query}":\n\n${lines.join('\n\n')}`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Skill search failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSkillInstall(args: { slug: string; confirmed?: boolean }): Promise<ToolResult> {
|
||||
if (!args.slug?.trim()) return { success: false, error: 'slug is required' };
|
||||
const slug = normalizeSkillId(args.slug);
|
||||
|
||||
if (!args.confirmed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `CONFIRMATION REQUIRED: About to download and install skill "${slug}" from registry.\n` +
|
||||
`Please review the skill first at https://clawhub.ai/skills/${slug}\n` +
|
||||
`Then call skill_install again with confirmed: true`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawUrl = `https://clawhub.ai/skills/${slug}/SKILL.md`;
|
||||
const res = await fetch(rawUrl, {
|
||||
headers: { 'User-Agent': 'SmallClaw/1.0' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, error: `Skill "${slug}" not found in registry (HTTP ${res.status})` };
|
||||
}
|
||||
|
||||
const content = await res.text();
|
||||
if ((!/skill/i.test(content) && content.length < 50) || !content.trim()) {
|
||||
return { success: false, error: `Downloaded content for "${slug}" looks invalid. Skipping install.` };
|
||||
}
|
||||
|
||||
const manifest = writeSkillPackFromContent({
|
||||
id: slug,
|
||||
skillMdContent: content,
|
||||
sourceType: 'clawhub',
|
||||
sourceUrl: rawUrl,
|
||||
});
|
||||
updateLockFromManifest(manifest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { skill: summarizeSkillForApi(manifest) },
|
||||
stdout: `Skill "${slug}" installed to ${resolveSkillDir(slug)} (${manifest.status}, risk:${manifest.risk.level}).`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Skill install failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSkillUpload(args: { skill_md: string; skill_id?: string; filename?: string }): Promise<ToolResult> {
|
||||
const content = String(args.skill_md || '').trim();
|
||||
if (!content) return { success: false, error: 'skill_md is required' };
|
||||
try {
|
||||
const manifest = writeSkillPackFromContent({
|
||||
id: args.skill_id || args.filename || undefined,
|
||||
skillMdContent: content,
|
||||
sourceType: 'upload',
|
||||
sourceFilename: args.filename || undefined,
|
||||
});
|
||||
updateLockFromManifest(manifest);
|
||||
return {
|
||||
success: true,
|
||||
data: { skill: summarizeSkillForApi(manifest) },
|
||||
stdout: `Skill "${manifest.id}" uploaded (${manifest.status}, risk:${manifest.risk.level}).`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Skill upload failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSkillSetEnabled(args: { slug: string; enabled: boolean }): Promise<ToolResult> {
|
||||
if (!args.slug?.trim()) return { success: false, error: 'slug is required' };
|
||||
const updated = setSkillExecutionEnabled(args.slug, !!args.enabled);
|
||||
if (!updated) return { success: false, error: `Skill "${args.slug}" not found` };
|
||||
updateLockFromManifest(updated);
|
||||
return {
|
||||
success: true,
|
||||
data: { skill: summarizeSkillForApi(updated) },
|
||||
stdout: `Skill "${updated.id}" execution ${updated.execution_enabled ? 'enabled' : 'disabled'} (${updated.status}).`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeSkillInspect(args: { slug: string }): Promise<ToolResult> {
|
||||
if (!args.slug?.trim()) return { success: false, error: 'slug is required' };
|
||||
const m = loadSkillManifest(args.slug);
|
||||
if (!m) return { success: false, error: `Skill "${args.slug}" not found` };
|
||||
return { success: true, data: { skill: summarizeSkillForApi(m) }, stdout: `Skill "${m.id}" loaded.` };
|
||||
}
|
||||
|
||||
export async function executeSkillRescan(args: { slug: string }): Promise<ToolResult> {
|
||||
if (!args.slug?.trim()) return { success: false, error: 'slug is required' };
|
||||
const m = refreshSkillPack(args.slug);
|
||||
if (!m) return { success: false, error: `Skill "${args.slug}" not found` };
|
||||
updateLockFromManifest(m);
|
||||
return {
|
||||
success: true,
|
||||
data: { skill: summarizeSkillForApi(m) },
|
||||
stdout: `Skill "${m.id}" re-scanned (${m.status}, risk:${m.risk.level}).`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeSkillRemove(args: { slug: string }): Promise<ToolResult> {
|
||||
if (!args.slug?.trim()) return { success: false, error: 'slug is required' };
|
||||
const id = normalizeSkillId(args.slug);
|
||||
const removed = removeSkillPack(id);
|
||||
if (!removed) {
|
||||
return { success: false, error: `Skill "${args.slug}" is not installed` };
|
||||
}
|
||||
removeFromLock(id);
|
||||
return { success: true, stdout: `Skill "${id}" removed.` };
|
||||
}
|
||||
|
||||
export async function executeSkillExec(args: {
|
||||
slug: string;
|
||||
action?: string;
|
||||
command?: string;
|
||||
params?: Record<string, any>;
|
||||
confirmed?: boolean;
|
||||
dry_run?: boolean;
|
||||
cwd?: string;
|
||||
}): Promise<ToolResult> {
|
||||
const slug = String(args.slug || '').trim();
|
||||
if (!slug) return { success: false, error: 'slug is required' };
|
||||
const manifest = loadSkillManifest(slug);
|
||||
if (!manifest) return { success: false, error: `Skill "${slug}" not found` };
|
||||
|
||||
if (!manifest.execution_enabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Skill "${manifest.id}" execution is disabled. Enable it first.`,
|
||||
data: { reason: 'execution_disabled', status: manifest.status },
|
||||
};
|
||||
}
|
||||
|
||||
const missing = summarizeMissing(manifest);
|
||||
if (missing.length > 0 || manifest.status === 'needs_setup') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Skill "${manifest.id}" needs setup before execution.`,
|
||||
data: {
|
||||
reason: 'needs_setup',
|
||||
missing,
|
||||
requirements: manifest.requirements,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tpl = pickTemplate(manifest, args.action, args.command);
|
||||
if (!tpl) {
|
||||
const actions = (manifest.templates || []).map((t: any) => String(t?.action || '').trim()).filter(Boolean);
|
||||
return {
|
||||
success: false,
|
||||
error: `No matching template found. Provide action or command from this skill.`,
|
||||
data: { reason: 'template_not_found', available_actions: actions },
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-resolve built-in placeholders before rendering
|
||||
const skillDir = resolveSkillDir(manifest.id);
|
||||
const builtins: Record<string, string> = {
|
||||
skill_dir: skillDir,
|
||||
skill_dir_slash: skillDir.replace(/\\/g, '/'),
|
||||
skill_dir_posix: skillDir.replace(/\\/g, '/'),
|
||||
};
|
||||
|
||||
if (tpl.requires_confirmation && !args.confirmed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `CONFIRMATION REQUIRED: Template "${tpl.action}" requires confirmation. Re-run with confirmed:true.`,
|
||||
data: { reason: 'confirmation_required', action: tpl.action, command: tpl.command },
|
||||
};
|
||||
}
|
||||
|
||||
const rendered = renderTemplateCommand(tpl.command, { ...builtins, ...(args.params || {}) });
|
||||
if (!rendered.ok || !rendered.command) {
|
||||
return {
|
||||
success: false,
|
||||
error: rendered.error || 'Failed to render template command',
|
||||
data: { reason: 'template_render_failed', missing: rendered.missing || [] },
|
||||
};
|
||||
}
|
||||
|
||||
const command = rendered.command;
|
||||
const opErr = hasBlockedShellOperators(command);
|
||||
if (opErr) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Blocked command pattern: ${opErr}`,
|
||||
data: { reason: 'blocked_operator', command },
|
||||
};
|
||||
}
|
||||
|
||||
const firstToken = String(command.split(/\s+/)[0] || '').trim().toLowerCase();
|
||||
const allowedBinaries = manifest.requirements.binaries.length
|
||||
? manifest.requirements.binaries.map((b) => String(b || '').toLowerCase())
|
||||
: [String(tpl.command || '').trim().split(/\s+/)[0]?.toLowerCase()].filter(Boolean) as string[];
|
||||
if (allowedBinaries.length > 0 && !allowedBinaries.includes(firstToken)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Rendered command binary "${firstToken}" is not allowed by skill manifest.`,
|
||||
data: { reason: 'binary_not_allowed', allowed_binaries: allowedBinaries, command },
|
||||
};
|
||||
}
|
||||
|
||||
if (!ensureTemplateShape(tpl.command, command)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Rendered command does not match template shape.',
|
||||
data: { reason: 'template_shape_mismatch', template: tpl.command, command },
|
||||
};
|
||||
}
|
||||
|
||||
if (args.dry_run) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Dry run for ${manifest.id}:${tpl.action}\n${command}`,
|
||||
data: {
|
||||
skill: manifest.id,
|
||||
action: tpl.action,
|
||||
command,
|
||||
requires_confirmation: !!tpl.requires_confirmation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const shellRes = await executeShell({ command, cwd: args.cwd });
|
||||
if (!shellRes.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: shellRes.error || 'Skill command failed',
|
||||
stdout: shellRes.stdout,
|
||||
stderr: shellRes.stderr,
|
||||
exitCode: shellRes.exitCode,
|
||||
data: {
|
||||
skill: manifest.id,
|
||||
action: tpl.action,
|
||||
command,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: shellRes.stdout,
|
||||
stderr: shellRes.stderr,
|
||||
exitCode: shellRes.exitCode,
|
||||
data: {
|
||||
skill: manifest.id,
|
||||
action: tpl.action,
|
||||
command,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const skillListTool = {
|
||||
name: 'skill_list',
|
||||
description: 'List installed skills',
|
||||
execute: executeSkillList,
|
||||
schema: {},
|
||||
};
|
||||
|
||||
export const skillSearchTool = {
|
||||
name: 'skill_search',
|
||||
description: 'Search configured skill registries',
|
||||
execute: executeSkillSearch,
|
||||
schema: {
|
||||
query: 'string (required) - Search query (e.g. "python", "docker", "git")',
|
||||
},
|
||||
};
|
||||
|
||||
export const skillInstallTool = {
|
||||
name: 'skill_install',
|
||||
description: 'Download and install a skill from a configured registry (requires confirmation)',
|
||||
execute: executeSkillInstall,
|
||||
schema: {
|
||||
slug: 'string (required) - Skill slug (e.g. "python-expert")',
|
||||
confirmed: 'boolean (optional) - Must be true to actually install (safety gate)',
|
||||
},
|
||||
};
|
||||
|
||||
export const skillRemoveTool = {
|
||||
name: 'skill_remove',
|
||||
description: 'Remove an installed skill',
|
||||
execute: executeSkillRemove,
|
||||
schema: {
|
||||
slug: 'string (required) - Skill slug to remove',
|
||||
},
|
||||
};
|
||||
|
||||
export const skillExecTool = {
|
||||
name: 'skill_exec',
|
||||
description: 'Execute an installed skill template with strict validation and confirmation gates',
|
||||
execute: executeSkillExec,
|
||||
schema: {
|
||||
slug: 'string (required) - Installed skill ID',
|
||||
action: 'string (optional) - Template action name from skill templates',
|
||||
command: 'string (optional) - Exact template command text if action not provided',
|
||||
params: 'object (optional) - Placeholder arguments for template rendering',
|
||||
confirmed: 'boolean (optional) - Required for sensitive templates',
|
||||
dry_run: 'boolean (optional) - Render/validate only, do not execute',
|
||||
cwd: 'string (optional) - Working directory (defaults to workspace)',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* task-control.ts - Task management tool
|
||||
*
|
||||
* Exposes TaskStore operations as a tool so agents can:
|
||||
* - List tasks (with filtering)
|
||||
* - Get specific task details
|
||||
* - Create new tasks
|
||||
* - Update task status/progress
|
||||
* - Cancel tasks
|
||||
*
|
||||
* Used by BOOT.md and automation workflows.
|
||||
*/
|
||||
|
||||
import { ToolResult } from '../types.js';
|
||||
import {
|
||||
listTasks,
|
||||
createTask,
|
||||
loadTask,
|
||||
saveTask,
|
||||
updateTaskStatus,
|
||||
appendJournal,
|
||||
deleteTask,
|
||||
type TaskRecord,
|
||||
type TaskStatus,
|
||||
} from '../gateway/task-store.js';
|
||||
|
||||
const VALID_STATUSES: TaskStatus[] = [
|
||||
'queued', 'running', 'paused', 'stalled', 'needs_assistance',
|
||||
'complete', 'failed', 'waiting_subagent',
|
||||
];
|
||||
|
||||
export const taskControlTool = {
|
||||
name: 'task_control',
|
||||
description: 'Manage workspace tasks: list, get, create, update, cancel, delete',
|
||||
schema: {
|
||||
action: 'Action: list, get, create, update, cancel, or delete',
|
||||
task_id: 'Task ID for get/update/cancel/delete actions',
|
||||
goal: 'Task goal/description for create action',
|
||||
status: 'Filter by status for list action (e.g. "pending", "running", "done", "failed")',
|
||||
include_all_sessions: 'Include tasks from all sessions (for list)',
|
||||
limit: 'Max results for list action (default 20)',
|
||||
new_status: 'New status for update action',
|
||||
journal_entry: 'Journal entry to append for update action',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['list', 'get', 'create', 'update', 'cancel', 'delete'],
|
||||
description: 'Action to perform',
|
||||
},
|
||||
task_id: {
|
||||
type: 'string',
|
||||
description: 'Task ID for get/update/cancel/delete actions',
|
||||
},
|
||||
goal: {
|
||||
type: 'string',
|
||||
description: 'Task goal/description for create action',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Filter by status for list action',
|
||||
},
|
||||
include_all_sessions: {
|
||||
type: 'boolean',
|
||||
description: 'Include tasks from all sessions (default false)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max results for list action (default 20)',
|
||||
},
|
||||
new_status: {
|
||||
type: 'string',
|
||||
description: 'New status for update action',
|
||||
},
|
||||
journal_entry: {
|
||||
type: 'string',
|
||||
description: 'Journal entry to append for update action',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
try {
|
||||
const {
|
||||
action,
|
||||
task_id,
|
||||
goal,
|
||||
status,
|
||||
include_all_sessions,
|
||||
limit,
|
||||
new_status,
|
||||
journal_entry,
|
||||
} = args || {};
|
||||
|
||||
if (!action) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'action is required. Valid actions: list, get, create, update, cancel, delete',
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedAction = String(action).toLowerCase().trim();
|
||||
|
||||
// LIST tasks
|
||||
if (normalizedAction === 'list') {
|
||||
try {
|
||||
const allTasks = listTasks();
|
||||
let filtered = allTasks;
|
||||
|
||||
if (status) {
|
||||
const statusStr = String(status).toLowerCase().trim();
|
||||
filtered = filtered.filter(t => String(t.status || '').toLowerCase() === statusStr);
|
||||
}
|
||||
|
||||
const maxResults = Math.max(1, Math.min(limit || 20, 100));
|
||||
const results = filtered.slice(0, maxResults);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Listed ${results.length} task(s)`,
|
||||
data: {
|
||||
count: results.length,
|
||||
total_available: filtered.length,
|
||||
tasks: results.map((t: TaskRecord) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
prompt: t.prompt,
|
||||
status: t.status,
|
||||
startedAt: t.startedAt,
|
||||
lastProgressAt: t.lastProgressAt,
|
||||
stepCount: t.journal?.length || 0,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to list tasks: ${err?.message || err}` };
|
||||
}
|
||||
}
|
||||
|
||||
// GET task
|
||||
if (normalizedAction === 'get') {
|
||||
if (!task_id) return { success: false, error: 'task_id is required for get action' };
|
||||
try {
|
||||
const task = loadTask(String(task_id));
|
||||
if (!task) return { success: false, error: `Task not found: ${task_id}` };
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Loaded task: ${task.title}`,
|
||||
data: task,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to get task: ${err?.message || err}` };
|
||||
}
|
||||
}
|
||||
|
||||
// CREATE task
|
||||
if (normalizedAction === 'create') {
|
||||
if (!goal) return { success: false, error: 'goal is required for create action' };
|
||||
try {
|
||||
const task = createTask({
|
||||
title: String(goal).slice(0, 120),
|
||||
prompt: String(goal),
|
||||
sessionId: 'tool-created',
|
||||
channel: 'web',
|
||||
plan: [{ index: 0, description: String(goal), status: 'pending' }],
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Created task: ${task.id}`,
|
||||
data: { id: task.id, title: task.title, status: task.status },
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to create task: ${err?.message || err}` };
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATE task
|
||||
if (normalizedAction === 'update') {
|
||||
if (!task_id) return { success: false, error: 'task_id is required for update action' };
|
||||
try {
|
||||
const task = loadTask(String(task_id));
|
||||
if (!task) return { success: false, error: `Task not found: ${task_id}` };
|
||||
|
||||
if (new_status) {
|
||||
const s = String(new_status) as TaskStatus;
|
||||
if (!VALID_STATUSES.includes(s)) {
|
||||
return { success: false, error: `Invalid status "${new_status}". Valid: ${VALID_STATUSES.join(', ')}` };
|
||||
}
|
||||
task.status = s;
|
||||
task.lastProgressAt = Date.now();
|
||||
}
|
||||
|
||||
if (journal_entry) {
|
||||
appendJournal(task.id, { type: 'status_push', content: String(journal_entry) });
|
||||
}
|
||||
|
||||
saveTask(task);
|
||||
return {
|
||||
success: true,
|
||||
stdout: `Updated task: ${task_id}`,
|
||||
data: { id: task.id, status: task.status, journal_entries: task.journal?.length || 0 },
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to update task: ${err?.message || err}` };
|
||||
}
|
||||
}
|
||||
|
||||
// CANCEL task
|
||||
if (normalizedAction === 'cancel') {
|
||||
if (!task_id) return { success: false, error: 'task_id is required for cancel action' };
|
||||
try {
|
||||
const task = loadTask(String(task_id));
|
||||
if (!task) return { success: false, error: `Task not found: ${task_id}` };
|
||||
updateTaskStatus(String(task_id), 'failed');
|
||||
appendJournal(String(task_id), { type: 'status_push', content: 'Task cancelled by operator.' });
|
||||
return { success: true, stdout: `Cancelled task: ${task_id}`, data: { id: task_id, status: 'failed' } };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to cancel task: ${err?.message || err}` };
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE task
|
||||
if (normalizedAction === 'delete') {
|
||||
if (!task_id) return { success: false, error: 'task_id is required for delete action' };
|
||||
try {
|
||||
const task = loadTask(String(task_id));
|
||||
if (!task) return { success: false, error: `Task not found: ${task_id}` };
|
||||
deleteTask(String(task_id));
|
||||
return { success: true, stdout: `Deleted task: ${task_id}`, data: { id: task_id } };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Failed to delete task: ${err?.message || err}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown action: ${action}. Valid actions: list, get, create, update, cancel, delete`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `task_control error: ${err?.message || err}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
|
||||
// Returns current date/time from the system clock — no network needed
|
||||
export async function executeTimeNow(_args: {}): Promise<ToolResult> {
|
||||
const now = new Date();
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const months = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
const dayName = days[now.getDay()];
|
||||
const monthName = months[now.getMonth()];
|
||||
const date = now.getDate();
|
||||
const year = now.getFullYear();
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
const result = `${dayName}, ${monthName} ${date}, ${year} — ${hours}:${minutes} local time`;
|
||||
return {
|
||||
success: true,
|
||||
stdout: result,
|
||||
data: {
|
||||
iso: now.toISOString(),
|
||||
day: dayName,
|
||||
date: `${year}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(date).padStart(2, '0')}`,
|
||||
time: `${hours}:${minutes}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const timeNowTool = {
|
||||
name: 'time_now',
|
||||
description: 'Get the current date, day of week, and local time from the system clock. Use this for ANY question about what day/date/time it is — never use web_search for this.',
|
||||
execute: executeTimeNow,
|
||||
schema: {},
|
||||
};
|
||||
@@ -0,0 +1,875 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
type SearchResultItem = { title: string; url: string; snippet: string };
|
||||
type StructuredSource = { id: number; tier: 'A' | 'B' | 'C'; title: string; url: string; snippet: string; score: number };
|
||||
type StructuredEvidence = { id: number; source_id: number; excerpt: string; score: number };
|
||||
type StructuredFact = { id: number; claim: string; evidence_ids: number[]; source_ids: number[]; confidence: number };
|
||||
type SearchProvider = 'tavily' | 'google' | 'brave' | 'ddg' | 'ddg_html';
|
||||
type SearchProviderAttempt = {
|
||||
provider: SearchProvider;
|
||||
status: 'success' | 'failed' | 'skipped';
|
||||
reason?: string;
|
||||
duration_ms?: number;
|
||||
result_count?: number;
|
||||
};
|
||||
type SearchDiagnostics = {
|
||||
query: string;
|
||||
preferred_provider: 'tavily' | 'google' | 'brave' | 'ddg';
|
||||
provider_order: Array<'tavily' | 'google' | 'brave' | 'ddg'>;
|
||||
attempted: SearchProviderAttempt[];
|
||||
selected_provider?: SearchProvider;
|
||||
};
|
||||
|
||||
function normalizeGoogleUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
// Standard Google redirect wrapper: /url?q=<real-url>
|
||||
if ((u.hostname.includes('google.') || u.hostname === 'google.com') && u.pathname === '/url') {
|
||||
const q = u.searchParams.get('q');
|
||||
if (q) return decodeURIComponent(q);
|
||||
}
|
||||
return url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function isLowQualityGoogleUrl(url: string): boolean {
|
||||
return /google\.com\/share\.google\?/i.test(url);
|
||||
}
|
||||
|
||||
function isPriceQuery(query: string): boolean {
|
||||
return /price|cost|value|quote|trades?|usd|dollar|eur|gbp|jpy/i.test(query);
|
||||
}
|
||||
|
||||
function isBitcoinQuery(query: string): boolean {
|
||||
return /bitcoin|btc/i.test(query);
|
||||
}
|
||||
|
||||
function isFreshQuery(query: string): boolean {
|
||||
return /\b(current|latest|today|now|right now|as of|recent)\b/i.test(query);
|
||||
}
|
||||
|
||||
function extractUsdPrice(text: string): string | null {
|
||||
const patterns = [
|
||||
/\$\s?([0-9]{1,3}(?:,[0-9]{3})+(?:\.[0-9]+)?)/,
|
||||
/\$\s?([0-9]+(?:\.[0-9]+)?)/,
|
||||
/\b([0-9]{1,3}(?:,[0-9]{3})+(?:\.[0-9]+)?)\s?USD\b/i,
|
||||
/\b([0-9]+(?:\.[0-9]+)?)\s?USD\b/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseUsdNumber(raw: string): number | null {
|
||||
const n = Number(String(raw || '').replace(/,/g, '').trim());
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function detectPriceUnit(text: string): 'ounce' | 'gram' | 'unknown' {
|
||||
const t = String(text || '').toLowerCase();
|
||||
if (/\b(per\s*gram|\/g\b|1g\b|gram\b)\b/.test(t)) return 'gram';
|
||||
if (/\b(per\s*ounce|\/oz\b|ounce\b|oz\b)\b/.test(t)) return 'ounce';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function hasHistoricalPriceCue(text: string): boolean {
|
||||
const t = String(text || '').toLowerCase();
|
||||
return /\b(around|circa|in|from)\s*(19|20)\d{2}\b/.test(t)
|
||||
|| /\b(was worth|years? ago|historical|history)\b/.test(t);
|
||||
}
|
||||
|
||||
function hasFreshPriceCue(text: string): boolean {
|
||||
const t = String(text || '').toLowerCase();
|
||||
return /\b(current|today|live|latest|now|right now|spot)\b/.test(t);
|
||||
}
|
||||
|
||||
function detectPriceAsset(query: string): 'silver' | 'gold' | 'bitcoin' | 'generic' {
|
||||
const q = String(query || '').toLowerCase();
|
||||
if (/\b(silver|xag)\b/.test(q)) return 'silver';
|
||||
if (/\b(gold|xau|comex gold)\b/.test(q)) return 'gold';
|
||||
if (/\b(bitcoin|btc)\b/.test(q)) return 'bitcoin';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
function isPlausibleUsdPrice(asset: 'silver' | 'gold' | 'bitcoin' | 'generic', valuePerOunceOrUnit: number): boolean {
|
||||
if (!Number.isFinite(valuePerOunceOrUnit) || valuePerOunceOrUnit <= 0) return false;
|
||||
if (asset === 'silver') return valuePerOunceOrUnit >= 5 && valuePerOunceOrUnit <= 200;
|
||||
if (asset === 'gold') return valuePerOunceOrUnit >= 300 && valuePerOunceOrUnit <= 10_000;
|
||||
if (asset === 'bitcoin') return valuePerOunceOrUnit >= 1_000 && valuePerOunceOrUnit <= 2_000_000;
|
||||
return valuePerOunceOrUnit >= 0.5 && valuePerOunceOrUnit <= 5_000_000;
|
||||
}
|
||||
|
||||
function buildDirectPriceAnswer(
|
||||
query: string,
|
||||
results: SearchResultItem[]
|
||||
): string {
|
||||
if (!isPriceQuery(query)) return '';
|
||||
|
||||
const asset = detectPriceAsset(query);
|
||||
const candidates: Array<{ value: number; score: number; unit: 'ounce' | 'gram' | 'unknown' }> = [];
|
||||
for (const result of results) {
|
||||
const combined = `${result.title} ${result.snippet}`;
|
||||
const usdRaw = extractUsdPrice(combined);
|
||||
if (!usdRaw) continue;
|
||||
const usd = parseUsdNumber(usdRaw);
|
||||
if (!usd) continue;
|
||||
const unit = detectPriceUnit(combined);
|
||||
const normalized = unit === 'gram' ? (usd * 31.1035) : usd;
|
||||
if (!isPlausibleUsdPrice(asset, normalized)) continue;
|
||||
let score = 0;
|
||||
if (hasFreshPriceCue(combined)) score += 3;
|
||||
if (unit === 'ounce') score += 2;
|
||||
if (unit === 'gram') score += 1;
|
||||
if (hasHistoricalPriceCue(combined)) score -= 6;
|
||||
if (asset !== 'generic' && new RegExp(`\\b${asset}\\b`, 'i').test(combined)) score += 2;
|
||||
candidates.push({ value: normalized, score, unit });
|
||||
}
|
||||
|
||||
if (candidates.length) {
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const best = candidates[0];
|
||||
if (best.score >= 0) {
|
||||
const v = best.value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
if (asset === 'bitcoin') return `Answer: The current Bitcoin price is approximately $${v} USD.`;
|
||||
if (asset === 'silver') return `Answer: The current silver price is approximately $${v} USD per ounce.`;
|
||||
if (asset === 'gold') return `Answer: The current gold price is approximately $${v} USD per ounce.`;
|
||||
return `Answer: The current price is approximately $${v} USD.`;
|
||||
}
|
||||
}
|
||||
|
||||
// When snippets do not include live numeric quotes, still return a compact
|
||||
// actionable answer instead of only raw links.
|
||||
if (isBitcoinQuery(query)) {
|
||||
const financeResult = results.find(r => /google\.com\/finance\/quote\/BTC-USD/i.test(r.url));
|
||||
if (financeResult) {
|
||||
return 'Answer: I found the live BTC-USD quote page on Google Finance. Open https://www.google.com/finance/quote/BTC-USD for the exact real-time value.';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function isEventOutcomeQuery(query: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
return /\b(what happened|outcome|key takeaways|takeaways|summary|recap|latest update|status)\b/.test(q)
|
||||
|| (/\b(hearing|trial|case|investigation|lawsuit|court|testimony)\b/.test(q) && /\b(what|how|why|when|recent|latest)\b/.test(q));
|
||||
}
|
||||
|
||||
function isLowValueResult(r: SearchResultItem): boolean {
|
||||
const text = `${r.title} ${r.url} ${r.snippet}`.toLowerCase();
|
||||
if (/youtube\.com|youtu\.be|podcast|opinion|editorial|letters to the editor|substack|reddit/.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function sourceTier(r: SearchResultItem): 'A' | 'B' | 'C' {
|
||||
const text = `${r.title} ${r.url}`.toLowerCase();
|
||||
if (/\.gov|\.mil|justice\.gov|congress\.gov|house\.gov|senate\.gov|courtlistener|supremecourt/.test(text)) return 'A';
|
||||
if (/apnews|reuters|bloomberg|ft\.com|nytimes|wsj|bbc|pbs|politico|aljazeera|npr|washingtonpost/.test(text)) return 'B';
|
||||
return 'C';
|
||||
}
|
||||
|
||||
function allowsTierCForQuery(query: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
return /\b(opinion|podcast|youtube|video|commentary|analysis only|broader context)\b/.test(q);
|
||||
}
|
||||
|
||||
function applySourceTierPolicy(query: string, ranked: SearchResultItem[]): SearchResultItem[] {
|
||||
if (!isEventOutcomeQuery(query)) return ranked;
|
||||
const enriched = ranked.map(r => ({ r, tier: sourceTier(r) }));
|
||||
const allowC = allowsTierCForQuery(query);
|
||||
const preferred = enriched.filter(x => x.tier === 'A' || x.tier === 'B' || allowC);
|
||||
return (preferred.length ? preferred : enriched.filter(x => x.tier !== 'C')).map(x => x.r);
|
||||
}
|
||||
|
||||
function queryAnchorTokens(query: string): string[] {
|
||||
return query
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(t => t.length >= 4 && !['what', 'when', 'where', 'which', 'latest', 'recent', 'about', 'during'].includes(t))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function relevanceScore(query: string, text: string): number {
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
const anchors = queryAnchorTokens(q);
|
||||
let score = 0;
|
||||
for (const a of anchors) if (t.includes(a)) score += 1;
|
||||
if (/bondi/.test(t) && /epstein/.test(t)) score += 3;
|
||||
if (/hearing|trial|case|committee|judiciary|testif|lawmakers|congress/.test(t)) score += 2;
|
||||
return score;
|
||||
}
|
||||
|
||||
function overlapScore(a: string, b: string): number {
|
||||
const at = new Set(a.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(t => t.length >= 4));
|
||||
const bt = new Set(b.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(t => t.length >= 4));
|
||||
if (!at.size || !bt.size) return 0;
|
||||
let both = 0;
|
||||
for (const t of at) if (bt.has(t)) both++;
|
||||
return both / Math.max(at.size, bt.size);
|
||||
}
|
||||
|
||||
function selectDominantStoryCluster(query: string, ranked: SearchResultItem[]): SearchResultItem[] {
|
||||
if (!isEventOutcomeQuery(query) || ranked.length <= 2) return ranked;
|
||||
const clusters: SearchResultItem[][] = [];
|
||||
const threshold = 0.18;
|
||||
for (const r of ranked) {
|
||||
const text = `${r.title} ${r.snippet}`;
|
||||
let placed = false;
|
||||
for (const c of clusters) {
|
||||
const centroid = `${c[0].title} ${c[0].snippet}`;
|
||||
if (overlapScore(text, centroid) >= threshold) {
|
||||
c.push(r);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) clusters.push([r]);
|
||||
}
|
||||
if (clusters.length <= 1) return ranked;
|
||||
clusters.sort((a, b) => {
|
||||
const sa = a.reduce((s, r) => s + relevanceScore(query, `${r.title} ${r.snippet}`), 0);
|
||||
const sb = b.reduce((s, r) => s + relevanceScore(query, `${r.title} ${r.snippet}`), 0);
|
||||
return sb - sa;
|
||||
});
|
||||
return clusters[0];
|
||||
}
|
||||
|
||||
async function fetchCleanArticle(url: string, maxChars = 5000): Promise<string> {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) SmallClaw/1.0' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const ct = String(res.headers.get('content-type') || '');
|
||||
if (!/text|html|json/i.test(ct)) throw new Error(`Unsupported content-type: ${ct}`);
|
||||
const html = await res.text();
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
|
||||
.replace(/<header[\s\S]*?<\/header>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return text.slice(0, maxChars);
|
||||
}
|
||||
|
||||
function extractEvidenceSentences(query: string, text: string, max = 4): string[] {
|
||||
const sentences = text
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length >= 40 && s.length <= 320);
|
||||
const verbs = /\b(said|stated|argued|clashed|pressed|refused|confirmed|announced|deflected|criticized|questioned|responded)\b/i;
|
||||
const scored = sentences.map(s => {
|
||||
let score = relevanceScore(query, s);
|
||||
if (verbs.test(s)) score += 2;
|
||||
if (/bondi|epstein|attorney general|committee|judiciary|lawmakers/i.test(s)) score += 1.5;
|
||||
return { s, score };
|
||||
}).sort((a, b) => b.score - a.score);
|
||||
return scored.filter(x => x.score >= 2.5).slice(0, max).map(x => x.s);
|
||||
}
|
||||
|
||||
function cleanClaimText(claim: string): string {
|
||||
return String(claim || '')
|
||||
.replace(/\[[0-9]+\]/g, '')
|
||||
.replace(/\(AP Photo[^)]*\)/gi, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 220);
|
||||
}
|
||||
|
||||
async function buildEventOutcomeAnswer(query: string, ranked: SearchResultItem[]): Promise<string> {
|
||||
const filtered = ranked.filter(r => !isLowValueResult(r));
|
||||
const tiered = applySourceTierPolicy(query, filtered);
|
||||
const clustered = selectDominantStoryCluster(query, tiered);
|
||||
const gated = clustered.filter(r => relevanceScore(query, `${r.title} ${r.snippet}`) >= 2);
|
||||
const picked = (gated.length ? gated : clustered).slice(0, 4);
|
||||
if (!picked.length) return '';
|
||||
|
||||
const evidence: Array<{ claim: string; source: number }> = [];
|
||||
for (let i = 0; i < picked.length; i++) {
|
||||
const r = picked[i];
|
||||
const fromSnippet = extractEvidenceSentences(query, r.snippet, 2);
|
||||
for (const c of fromSnippet) evidence.push({ claim: c, source: i + 1 });
|
||||
if (evidence.length >= 8) continue;
|
||||
try {
|
||||
const clean = await fetchCleanArticle(r.url, 4500);
|
||||
const fromPage = extractEvidenceSentences(query, clean, 2);
|
||||
for (const c of fromPage) evidence.push({ claim: c, source: i + 1 });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
const dedup = new Set<string>();
|
||||
const top: Array<{ claim: string; source: number }> = [];
|
||||
for (const e of evidence) {
|
||||
const cleaned = cleanClaimText(e.claim);
|
||||
if (!cleaned || cleaned.length < 20) continue;
|
||||
const k = cleaned.toLowerCase().replace(/[^a-z0-9\s]/g, '').slice(0, 140);
|
||||
if (dedup.has(k)) continue;
|
||||
dedup.add(k);
|
||||
top.push({ claim: cleaned, source: e.source });
|
||||
if (top.length >= 3) break;
|
||||
}
|
||||
|
||||
if (!top.length) return '';
|
||||
const first = top[0];
|
||||
const summaryLine = `Answer: ${first.claim} [${first.source}]`;
|
||||
const bullets = top.slice(1).map(t => `- ${t.claim} [${t.source}]`).join('\n');
|
||||
const sources = picked.slice(0, 3).map((r, i) => `[${i + 1}] ${r.url}`).join(' ');
|
||||
return `${summaryLine}${bullets ? `\n${bullets}` : ''}\nSources: ${sources}`;
|
||||
}
|
||||
|
||||
async function buildStructuredEventBundle(query: string, ranked: SearchResultItem[]): Promise<{
|
||||
answer: string;
|
||||
sources: StructuredSource[];
|
||||
evidence: StructuredEvidence[];
|
||||
facts: StructuredFact[];
|
||||
} | null> {
|
||||
if (!isEventOutcomeQuery(query)) return null;
|
||||
const filtered = ranked.filter(r => !isLowValueResult(r));
|
||||
const tiered = applySourceTierPolicy(query, filtered);
|
||||
const clustered = selectDominantStoryCluster(query, tiered);
|
||||
const pickedRaw = clustered.slice(0, 4);
|
||||
if (!pickedRaw.length) return null;
|
||||
|
||||
const sources: StructuredSource[] = pickedRaw.map((r, i) => ({
|
||||
id: i + 1,
|
||||
tier: sourceTier(r),
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
snippet: r.snippet.slice(0, 500),
|
||||
score: relevanceScore(query, `${r.title} ${r.snippet}`),
|
||||
}));
|
||||
|
||||
let evidenceId = 1;
|
||||
const evidence: StructuredEvidence[] = [];
|
||||
for (const s of sources) {
|
||||
const fromSnippet = extractEvidenceSentences(query, s.snippet, 2);
|
||||
for (const ex of fromSnippet) {
|
||||
evidence.push({ id: evidenceId++, source_id: s.id, excerpt: cleanClaimText(ex), score: relevanceScore(query, ex) + 1 });
|
||||
}
|
||||
if (evidence.length >= 14) continue;
|
||||
try {
|
||||
const clean = await fetchCleanArticle(s.url, 4500);
|
||||
const fromPage = extractEvidenceSentences(query, clean, 2);
|
||||
for (const ex of fromPage) {
|
||||
evidence.push({ id: evidenceId++, source_id: s.id, excerpt: cleanClaimText(ex), score: relevanceScore(query, ex) + 1.5 });
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
const sortedEvidence = evidence
|
||||
.filter(e => e.excerpt.length >= 20)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 10);
|
||||
if (!sortedEvidence.length) return null;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const facts: StructuredFact[] = [];
|
||||
for (const e of sortedEvidence) {
|
||||
const key = e.excerpt.toLowerCase().replace(/[^a-z0-9\s]/g, '').slice(0, 140);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
facts.push({
|
||||
id: facts.length + 1,
|
||||
claim: e.excerpt,
|
||||
evidence_ids: [e.id],
|
||||
source_ids: [e.source_id],
|
||||
confidence: Math.max(0.5, Math.min(0.95, e.score / 8)),
|
||||
});
|
||||
if (facts.length >= 4) break;
|
||||
}
|
||||
if (!facts.length) return null;
|
||||
|
||||
const lead = facts[0];
|
||||
const bullets = facts.slice(1, 4).map(f => `- ${f.claim} [${f.source_ids[0]}]`).join('\n');
|
||||
const sourceLine = sources.slice(0, 3).map(s => `[${s.id}] ${s.url}`).join(' ');
|
||||
const answer = `Answer: ${lead.claim} [${lead.source_ids[0]}]${bullets ? `\n${bullets}` : ''}\nSources: ${sourceLine}`;
|
||||
return { answer, sources, evidence: sortedEvidence, facts };
|
||||
}
|
||||
|
||||
async function augmentEventContract(query: string, res: ToolResult): Promise<ToolResult> {
|
||||
const ranked = (res.data?.results || []) as SearchResultItem[];
|
||||
if (!isEventOutcomeQuery(query) || !ranked.length) return res;
|
||||
const bundle = await buildStructuredEventBundle(query, ranked);
|
||||
if (!bundle) return res;
|
||||
const summaryText = ranked.map((r: SearchResultItem, i: number) => `[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 400)}`).join('\n\n');
|
||||
res.data = {
|
||||
...(res.data || {}),
|
||||
answer: bundle.answer,
|
||||
sources: bundle.sources,
|
||||
evidence: bundle.evidence,
|
||||
facts: bundle.facts,
|
||||
};
|
||||
res.stdout = `${bundle.answer}\n\n${summaryText}`;
|
||||
return res;
|
||||
}
|
||||
|
||||
function domainTrustScore(url: string): number {
|
||||
try {
|
||||
const h = new URL(url).hostname.toLowerCase();
|
||||
if (h.endsWith('.gov') || h.endsWith('.mil')) return 4;
|
||||
if (h.endsWith('.edu') || h.includes('justice.gov') || h.includes('sec.gov') || h.includes('federalreserve.gov')) return 3.5;
|
||||
if (h.includes('reuters.com') || h.includes('apnews.com') || h.includes('bloomberg.com') || h.includes('ft.com')) return 3;
|
||||
if (h.includes('wikipedia.org') || h.includes('ballotpedia.org')) return 2;
|
||||
if (h.includes('youtube.com') || h.includes('tiktok.com')) return 0.5;
|
||||
return 1.5;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function rankResults(query: string, results: SearchResultItem[]) {
|
||||
const q = query.toLowerCase();
|
||||
const freshness = /\b(current|latest|today|now|as of|recent)\b/.test(q);
|
||||
return [...results]
|
||||
.map(r => {
|
||||
const t = domainTrustScore(r.url);
|
||||
const text = `${r.title} ${r.snippet}`.toLowerCase();
|
||||
let rel = 0;
|
||||
const tokens = q.replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(x => x.length >= 4);
|
||||
for (const tok of tokens) if (text.includes(tok)) rel += 1;
|
||||
return { r, score: t * (freshness ? 2 : 1) + rel * 0.4 };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(x => x.r);
|
||||
}
|
||||
|
||||
// ── Load optional API keys from ~/.smallclaw/config.json ─────────────────────
|
||||
function getSearchConfig(): {
|
||||
preferred: 'tavily' | 'google' | 'brave' | 'ddg';
|
||||
tavilyKey?: string;
|
||||
googleKey?: string;
|
||||
googleCx?: string;
|
||||
braveKey?: string;
|
||||
} {
|
||||
try {
|
||||
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
|
||||
const cfg = fs.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
|
||||
if (fs.existsSync(cfg)) {
|
||||
const data = JSON.parse(fs.readFileSync(cfg, 'utf-8'));
|
||||
const preferredRaw = String(data.search?.preferred_provider || 'ddg').toLowerCase();
|
||||
const preferred = (['tavily', 'google', 'brave', 'ddg'].includes(preferredRaw) ? preferredRaw : 'ddg') as 'tavily' | 'google' | 'brave' | 'ddg';
|
||||
return {
|
||||
preferred,
|
||||
tavilyKey: data.search?.tavily_api_key,
|
||||
googleKey: data.search?.google_api_key,
|
||||
googleCx: data.search?.google_cx,
|
||||
braveKey: data.search?.brave_api_key,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return { preferred: 'ddg' };
|
||||
}
|
||||
// ── Google Custom Search API ─────────────────────────────────────────────---
|
||||
async function searchGoogle(query: string, limit: number, apiKey: string, cx: string): Promise<ToolResult> {
|
||||
const url = `https://www.googleapis.com/customsearch/v1?q=${encodeURIComponent(query)}&key=${apiKey}&cx=${cx}&num=${limit}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) throw new Error(`Google HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
const results = (data.items || []).map((r: any) => ({
|
||||
title: r.title || '',
|
||||
url: normalizeGoogleUrl(r.link || ''),
|
||||
snippet: r.snippet || '',
|
||||
}));
|
||||
const ranked = rankResults(query, results);
|
||||
|
||||
// Guard: some CSE configurations return mostly share.google wrappers that
|
||||
// are not reliable search hits for factual QA. Trigger provider fallback.
|
||||
if (results.length > 0) {
|
||||
const lowQuality = results.filter((r: { url: string }) => isLowQualityGoogleUrl(r.url)).length;
|
||||
if (lowQuality / results.length >= 0.5) {
|
||||
throw new Error('Google CSE returned mostly low-quality share links; falling back to other providers.');
|
||||
}
|
||||
}
|
||||
|
||||
const answer = buildDirectPriceAnswer(query, ranked);
|
||||
return {
|
||||
success: true,
|
||||
data: { query, results: ranked, answer: answer || undefined },
|
||||
stdout: (answer ? `${answer}\n\n` : '') + ranked.map((r: any, i: number) => `[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 400)}`).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tavily (best for AI agents, free 1k/mo) ───────────────────────────────────
|
||||
async function searchTavily(query: string, limit: number, apiKey: string): Promise<ToolResult> {
|
||||
const res = await fetch('https://api.tavily.com/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: apiKey,
|
||||
query,
|
||||
max_results: limit,
|
||||
search_depth: 'basic',
|
||||
// Provider "answer" strings can be stale/inconsistent for freshness queries.
|
||||
// We synthesize from snippets instead of trusting this shortcut.
|
||||
include_answer: !isFreshQuery(query),
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Tavily HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
const results = (data.results || []).map((r: any) => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
snippet: r.content || '',
|
||||
}));
|
||||
const ranked = rankResults(query, results);
|
||||
|
||||
// Use deterministic local extraction only (e.g., prices) to avoid stale provider summaries.
|
||||
const answer = buildDirectPriceAnswer(query, ranked);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { query, results: ranked, answer: data.answer },
|
||||
stdout: (answer ? `${answer}\n\n` : '') + ranked.map((r: any, i: number) =>
|
||||
`[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 400)}`
|
||||
).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Brave Search API (free 2k/mo) ─────────────────────────────────────────────
|
||||
async function searchBrave(query: string, limit: number, apiKey: string): Promise<ToolResult> {
|
||||
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${limit}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json', 'X-Subscription-Token': apiKey },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Brave HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
const results = (data.web?.results || []).map((r: any) => ({
|
||||
title: r.title || '',
|
||||
url: r.url || '',
|
||||
snippet: r.description || '',
|
||||
}));
|
||||
const ranked = rankResults(query, results);
|
||||
const answer = buildDirectPriceAnswer(query, ranked);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { query, results: ranked, answer: answer || undefined },
|
||||
stdout: (answer ? `${answer}\n\n` : '') + ranked.map((r: any, i: number) =>
|
||||
`[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet}`
|
||||
).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── DuckDuckGo JSON endpoint (no key, more stable than HTML scrape) ───────────
|
||||
async function searchDDG(query: string, limit: number): Promise<ToolResult> {
|
||||
// DDG instant answer API — gives structured results without scraping HTML
|
||||
const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_redirect=1&no_html=1&skip_disambig=1`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'SmallClaw/1.0' },
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`DDG JSON HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
const results: Array<{ title: string; url: string; snippet: string }> = [];
|
||||
|
||||
// Abstract (direct answer)
|
||||
if (data.AbstractText) {
|
||||
results.push({
|
||||
title: data.Heading || query,
|
||||
url: data.AbstractURL || '',
|
||||
snippet: data.AbstractText,
|
||||
});
|
||||
}
|
||||
|
||||
// Related topics
|
||||
for (const topic of (data.RelatedTopics || [])) {
|
||||
if (results.length >= limit) break;
|
||||
if (topic.Text && topic.FirstURL) {
|
||||
results.push({ title: topic.Text.slice(0, 80), url: topic.FirstURL, snippet: topic.Text });
|
||||
} else if (topic.Topics) {
|
||||
for (const sub of topic.Topics) {
|
||||
if (results.length >= limit) break;
|
||||
if (sub.Text && sub.FirstURL) {
|
||||
results.push({ title: sub.Text.slice(0, 80), url: sub.FirstURL, snippet: sub.Text });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Results array
|
||||
for (const r of (data.Results || [])) {
|
||||
if (results.length >= limit) break;
|
||||
results.push({ title: r.Text || '', url: r.FirstURL || '', snippet: r.Text || '' });
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
// Fall back to HTML scraper if JSON gave nothing
|
||||
return searchDDGHtml(query, limit);
|
||||
}
|
||||
const ranked = rankResults(query, results);
|
||||
const answer = buildDirectPriceAnswer(query, ranked);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { query, results: ranked, answer: answer || undefined },
|
||||
stdout: (answer ? `${answer}\n\n` : '') + ranked.map((r, i) =>
|
||||
`[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 400)}`
|
||||
).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── DDG HTML scraper (last resort fallback) ───────────────────────────────────
|
||||
async function searchDDGHtml(query: string, limit: number): Promise<ToolResult> {
|
||||
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) SmallClaw/1.0' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) return { success: false, error: `DDG HTML HTTP ${res.status}` };
|
||||
|
||||
const html = await res.text();
|
||||
const results: Array<{ title: string; url: string; snippet: string }> = [];
|
||||
|
||||
const re = /<a class="result__a" href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<a class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
||||
let m;
|
||||
while ((m = re.exec(html)) !== null && results.length < limit) {
|
||||
const href = m[1];
|
||||
const realUrl = href.startsWith('/l/?') || href.startsWith('//duckduckgo.com/l/?')
|
||||
? decodeURIComponent(href.replace(/.*uddg=/, ''))
|
||||
: href;
|
||||
results.push({
|
||||
title: m[2].trim(),
|
||||
url: realUrl,
|
||||
snippet: m[3].replace(/<[^>]+>/g, '').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return { success: false, error: 'No search results found. DDG may have changed its markup.' };
|
||||
}
|
||||
const ranked = rankResults(query, results);
|
||||
const answer = buildDirectPriceAnswer(query, ranked);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { query, results: ranked, answer: answer || undefined },
|
||||
stdout: (answer ? `${answer}\n\n` : '') + ranked.map((r, i) => `[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet}`).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main web_search tool ──────────────────────────────────────────────────────
|
||||
export async function executeWebSearch(args: { query: string; max_results?: number }): Promise<ToolResult> {
|
||||
if (!args.query?.trim()) return { success: false, error: 'query is required' };
|
||||
let limit = Math.min(args.max_results ?? 5, 10);
|
||||
if (isPriceQuery(args.query)) limit = Math.max(limit, 5);
|
||||
|
||||
const cfg = getSearchConfig();
|
||||
const candidates: Array<'tavily' | 'google' | 'brave' | 'ddg'> = ['tavily', 'google', 'brave', 'ddg'];
|
||||
const providerOrder = [cfg.preferred, ...candidates.filter(p => p !== cfg.preferred)];
|
||||
const diagnostics: SearchDiagnostics = {
|
||||
query: args.query,
|
||||
preferred_provider: cfg.preferred,
|
||||
provider_order: providerOrder,
|
||||
attempted: [],
|
||||
};
|
||||
|
||||
let lastErr = null;
|
||||
for (const provider of providerOrder) {
|
||||
if (provider === 'tavily' && !cfg.tavilyKey) {
|
||||
diagnostics.attempted.push({ provider, status: 'skipped', reason: 'missing_tavily_api_key' });
|
||||
continue;
|
||||
}
|
||||
if (provider === 'google' && (!cfg.googleKey || !cfg.googleCx)) {
|
||||
diagnostics.attempted.push({ provider, status: 'skipped', reason: !cfg.googleKey ? 'missing_google_api_key' : 'missing_google_cx' });
|
||||
continue;
|
||||
}
|
||||
if (provider === 'brave' && !cfg.braveKey) {
|
||||
diagnostics.attempted.push({ provider, status: 'skipped', reason: 'missing_brave_api_key' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
if (provider === 'tavily') {
|
||||
const res = await searchTavily(args.query, limit, cfg.tavilyKey as string);
|
||||
await augmentEventContract(args.query, res);
|
||||
const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0;
|
||||
diagnostics.attempted.push({
|
||||
provider,
|
||||
status: 'success',
|
||||
duration_ms: Date.now() - started,
|
||||
result_count: resultCount,
|
||||
});
|
||||
diagnostics.selected_provider = 'tavily';
|
||||
res.data = { ...(res.data || {}), provider: 'tavily', search_diagnostics: diagnostics };
|
||||
return res;
|
||||
}
|
||||
if (provider === 'google') {
|
||||
const res = await searchGoogle(args.query, limit, cfg.googleKey as string, cfg.googleCx as string);
|
||||
await augmentEventContract(args.query, res);
|
||||
const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0;
|
||||
diagnostics.attempted.push({
|
||||
provider,
|
||||
status: 'success',
|
||||
duration_ms: Date.now() - started,
|
||||
result_count: resultCount,
|
||||
});
|
||||
diagnostics.selected_provider = 'google';
|
||||
res.data = { ...(res.data || {}), provider: 'google', search_diagnostics: diagnostics };
|
||||
return res;
|
||||
}
|
||||
if (provider === 'brave') {
|
||||
const res = await searchBrave(args.query, limit, cfg.braveKey as string);
|
||||
await augmentEventContract(args.query, res);
|
||||
const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0;
|
||||
diagnostics.attempted.push({
|
||||
provider,
|
||||
status: 'success',
|
||||
duration_ms: Date.now() - started,
|
||||
result_count: resultCount,
|
||||
});
|
||||
diagnostics.selected_provider = 'brave';
|
||||
res.data = { ...(res.data || {}), provider: 'brave', search_diagnostics: diagnostics };
|
||||
return res;
|
||||
}
|
||||
if (provider === 'ddg') {
|
||||
const res = await searchDDG(args.query, limit);
|
||||
await augmentEventContract(args.query, res);
|
||||
const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0;
|
||||
diagnostics.attempted.push({
|
||||
provider,
|
||||
status: 'success',
|
||||
duration_ms: Date.now() - started,
|
||||
result_count: resultCount,
|
||||
});
|
||||
diagnostics.selected_provider = 'ddg';
|
||||
res.data = { ...(res.data || {}), provider: 'ddg', search_diagnostics: diagnostics };
|
||||
return res;
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
diagnostics.attempted.push({
|
||||
provider,
|
||||
status: 'failed',
|
||||
reason: (err as any)?.message || String(err),
|
||||
duration_ms: Date.now() - started,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback if ddg path threw and wasn't already successful
|
||||
const fallbackStarted = Date.now();
|
||||
try {
|
||||
const res = await searchDDGHtml(args.query, limit);
|
||||
const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0;
|
||||
diagnostics.attempted.push({
|
||||
provider: 'ddg_html',
|
||||
status: 'success',
|
||||
duration_ms: Date.now() - fallbackStarted,
|
||||
result_count: resultCount,
|
||||
});
|
||||
diagnostics.selected_provider = 'ddg_html';
|
||||
res.data = { ...(res.data || {}), provider: 'ddg_html', search_diagnostics: diagnostics };
|
||||
return res;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
diagnostics.attempted.push({
|
||||
provider: 'ddg_html',
|
||||
status: 'failed',
|
||||
reason: (err as any)?.message || String(err),
|
||||
duration_ms: Date.now() - fallbackStarted,
|
||||
});
|
||||
}
|
||||
let errMsg = 'unknown error';
|
||||
if (lastErr) {
|
||||
if (typeof lastErr === 'object' && 'message' in lastErr) errMsg = (lastErr as any).message;
|
||||
else errMsg = String(lastErr);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `All search providers failed: ${errMsg}`,
|
||||
data: { query: args.query, search_diagnostics: diagnostics },
|
||||
};
|
||||
}
|
||||
|
||||
// ── web_fetch: fetch a URL and return clean text ──────────────────────────────
|
||||
export async function executeWebFetch(args: { url: string; max_chars?: number }): Promise<ToolResult> {
|
||||
if (!args.url?.trim()) return { success: false, error: 'url is required' };
|
||||
const maxChars = args.max_chars ?? 10_000;
|
||||
|
||||
try {
|
||||
const res = await fetch(args.url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) SmallClaw/1.0' },
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) return { success: false, error: `HTTP ${res.status} from ${args.url}` };
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
if (!contentType.includes('text') && !contentType.includes('json')) {
|
||||
return { success: false, error: `Non-text content-type: ${contentType}` };
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
let text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
|
||||
.replace(/<header[\s\S]*?<\/header>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
if (text.length > maxChars) text = text.slice(0, maxChars) + '\n\n[...truncated]';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { url: args.url, length: text.length },
|
||||
stdout: text,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Fetch failed: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export const webSearchTool = {
|
||||
name: 'web_search',
|
||||
description: 'Search the web. Uses Tavily or Brave API if configured in ~/.smallclaw/config.json (search.tavily_api_key / search.brave_api_key), otherwise falls back to DuckDuckGo (no key needed).',
|
||||
execute: executeWebSearch,
|
||||
schema: {
|
||||
query: 'string (required) - Search query',
|
||||
max_results: 'number (optional, default 5) - Max results to return',
|
||||
},
|
||||
};
|
||||
|
||||
export const webFetchTool = {
|
||||
name: 'web_fetch',
|
||||
description: 'Fetch and extract the text content of any URL. Good for reading articles, docs, or pages found via web_search.',
|
||||
execute: executeWebFetch,
|
||||
schema: {
|
||||
url: 'string (required) - Full URL to fetch (include https://)',
|
||||
max_chars: 'number (optional, default 10000) - Max characters to return',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user