This commit is contained in:
kim
2026-04-29 11:45:59 +09:00
commit 3e8974a8eb
277 changed files with 70351 additions and 0 deletions
+685
View File
@@ -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',
}
};
+150
View File
@@ -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,
},
};
+74
View File
@@ -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);
}
+80
View File
@@ -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,
},
};
+35
View File
@@ -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();
}
+140
View File
@@ -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',
},
};
+279
View File
@@ -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');
}
+339
View File
@@ -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);
},
};
+288
View File
@@ -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;
}
+358
View File
@@ -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);
}
}
+93
View File
@@ -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,
},
};
+134
View File
@@ -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'
}
};
+553
View File
@@ -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)',
},
};
+212
View File
@@ -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,
},
};
+246
View File
@@ -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}` };
}
},
};
+35
View File
@@ -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: {},
};
+875
View File
@@ -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(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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',
},
};