import { createToolRuntime, type ToolRuntime, type ToolRuntimeAdapter, type ToolRuntimeContext, type ToolRuntimeExecutionResult, type ToolRuntimeInvocation, type ToolRuntimePreflightResult, } from './tool-runtime'; import type { GatewayToolDefinition } from '@electron/providers/BaseProvider'; import type { SkillCapability } from './skill-capability-parser'; import type { ToolRegistryCapabilityInput, ToolRegistryEntry } from './tool-registry'; import type { AttachedFileMeta, ToolArtifact, ToolCallPayload } from '@runtime/shared/chat-model'; type NodeFsDirentLike = { name: string; isFile(): boolean; }; type NodeFsLike = { existsSync(path: string): boolean; readdirSync(path: string, options: { withFileTypes: true }): NodeFsDirentLike[]; }; type NodePathLike = { join(...parts: string[]): string; }; type NodeChildProcessLike = { spawn( command: string, args?: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv; windowsHide?: boolean; signal?: AbortSignal; } ): { stdout: { on(event: 'data', listener: (chunk: unknown) => void): void; }; stderr: { on(event: 'data', listener: (chunk: unknown) => void): void; }; on(event: 'error', listener: (error: Error) => void): void; on(event: 'close', listener: (code: number | null) => void): void; }; }; function getBuiltinNodeModule(moduleName: string): T | null { const getter = typeof process !== 'undefined' ? (process as NodeJS.Process & { getBuiltinModule?: (name: string) => unknown; }).getBuiltinModule : undefined; if (typeof getter !== 'function') { return null; } try { return getter(moduleName) as T; } catch { return null; } } const nodeFs = getBuiltinNodeModule('fs'); const nodePath = getBuiltinNodeModule('path'); const nodeChildProcess = getBuiltinNodeModule('child_process'); function joinPath(...parts: string[]): string { if (nodePath) { return nodePath.join(...parts); } return parts .filter(Boolean) .join('/') .replace(/[\\/]+/g, '/'); } type SpreadsheetToolInput = { prompt?: string; skillKey?: string; intent?: string; attachments?: Array<{ fileName?: string; mimeType?: string; fileSize?: number; filePath?: string; source?: AttachedFileMeta['source']; }>; filePaths?: string[]; reuseHistoryAttachment?: boolean; }; type PythonCommandCandidate = { command: string; args: string[]; }; type SpreadsheetAnalysisReport = { filePath: string; fileName: string; report: Record; }; type GenericSkillInput = { prompt?: string; query?: string; q?: string; command?: string; args?: string[] | string; skillKey?: string; capabilityKey?: string; attachments?: SpreadsheetToolInput['attachments']; filePaths?: string[]; reuseHistoryAttachment?: boolean; count?: number; maxResults?: number; depth?: string; searchDepth?: string; timeRange?: string; freshness?: string; includeDomains?: string[] | string; excludeDomains?: string[] | string; topic?: string; country?: string; searchLang?: string; uiLang?: string; safesearch?: string; safeSearch?: string; includeAnswer?: boolean; includeRawContent?: boolean | string; }; type SpreadsheetCapabilityResolution = { capability: SkillCapability; scriptPath?: string; }; type DocumentAnalysisReport = { filePath: string; fileName: string; kind: 'pdf' | 'docx' | 'pptx'; engine: string; summary: string; metadata: Record; preview: Array>; }; type SkillExecutionConfig = { apiKey?: string; env: Record; }; type SearchProvider = 'brave' | 'tavily' | 'clawhub'; type SearchExecutionInput = GenericSkillInput & { provider?: SearchProvider; query: string; }; type SearchResultItem = { title: string; url: string; snippet?: string; score?: number; source?: string; age?: string; publishedAt?: string; slug?: string; version?: string; downloads?: number; stars?: number; installCommand?: string; }; type SearchExecutionReport = { provider: SearchProvider; engine: string; query: string; resultCount: number; answer?: string; responseTimeMs?: number; results: SearchResultItem[]; metadata: Record; }; const PYTHON_COMMAND_CANDIDATES: PythonCommandCandidate[] = process.platform === 'win32' ? [ { command: 'python', args: [] }, { command: 'py', args: ['-3'] }, { command: 'python3', args: [] }, ] : [ { command: 'python3', args: [] }, { command: 'python', args: [] }, ]; const SPREADSHEET_FALLBACK_EXTENSIONS = ['.xls', '.xlsx', '.xlsm', '.csv', '.tsv']; const DOCUMENT_ANALYSIS_EXTENSIONS = ['.pdf', '.docx', '.pptx']; const BRAVE_DEFAULT_RESULT_COUNT = 5; const TAVILY_DEFAULT_RESULT_COUNT = 5; const BRAVE_TIME_RANGE_TO_FRESHNESS: Record = { day: 'pd', week: 'pw', month: 'pm', year: 'py', }; const TAVILY_FRESHNESS_TO_TIME_RANGE: Record = { pd: 'day', pw: 'week', pm: 'month', py: 'year', }; function dedupeStrings(values: Array): string[] { return Array.from( new Set( values .map((value) => String(value || '').trim()) .filter(Boolean), ), ); } function normalizeLookup(value: string | undefined | null): string { return String(value || '').trim().toLowerCase(); } function ensureRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function getTrimmedString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function coerceInteger( value: unknown, fallback: number, min: number, max: number, ): number { const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseInt(value.trim(), 10) : NaN; if (!Number.isFinite(parsed)) { return fallback; } return Math.min(max, Math.max(min, Math.trunc(parsed))); } function coerceBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') { return value; } if (typeof value !== 'string') { return undefined; } const normalized = value.trim().toLowerCase(); if (['true', '1', 'yes', 'on'].includes(normalized)) { return true; } if (['false', '0', 'no', 'off'].includes(normalized)) { return false; } return undefined; } function coerceStringList(value: unknown): string[] { if (Array.isArray(value)) { return dedupeStrings(value.map((item) => (typeof item === 'string' ? item : String(item || '')))); } if (typeof value === 'string') { return dedupeStrings(value.split(',')); } return []; } function normalizeSearchQuery(input: GenericSkillInput & Record): string { return ( getTrimmedString(input.query) || getTrimmedString(input.q) || getTrimmedString(input.prompt) || '' ); } function getUrlHostname(value: string | undefined): string | undefined { if (!value) { return undefined; } try { return new URL(value).hostname; } catch { return undefined; } } function extractExplicitUrl(value: unknown): string | undefined { const direct = getTrimmedString(value); if (direct && /^https?:\/\//i.test(direct)) { return direct; } if (!direct) { return undefined; } const match = direct.match(/https?:\/\/[^\s)]+/i); return match?.[0]; } function getInputCommandName(input: GenericSkillInput & Record): string | undefined { return getTrimmedString(input.command); } function getInputArgs(input: GenericSkillInput & Record): string[] { if (Array.isArray(input.args)) { return dedupeStrings(input.args.map((item) => (typeof item === 'string' ? item : String(item || '')))); } if (typeof input.args === 'string') { return dedupeStrings(input.args.split(/\s+/)); } return []; } function toAttachmentReference(value: AttachedFileMeta | SpreadsheetToolInput['attachments'][number]): AttachedFileMeta { return { fileName: value?.fileName || value?.filePath?.split(/[\\/]/).pop() || 'attachment', mimeType: value?.mimeType || 'application/octet-stream', fileSize: value?.fileSize || 0, preview: null, filePath: value?.filePath, source: value?.source || 'message-ref', }; } function extractFilePaths(input: SpreadsheetToolInput | undefined): AttachedFileMeta[] { const attachments = (input?.attachments || []) .map((attachment) => toAttachmentReference(attachment)) .filter((attachment) => attachment.filePath); const fromPaths = (input?.filePaths || []) .map((filePath) => String(filePath || '').trim()) .filter(Boolean) .map((filePath) => toAttachmentReference({ filePath })); const deduped = new Map(); for (const attachment of [...attachments, ...fromPaths]) { const key = normalizeLookup(attachment.filePath || attachment.fileName); if (!key) { continue; } if (!deduped.has(key)) { deduped.set(key, attachment); } } return Array.from(deduped.values()).filter((attachment) => attachment.filePath); } function resolveInvocationAttachments( input: SpreadsheetToolInput | GenericSkillInput | undefined, context?: ToolRuntimeContext, ): AttachedFileMeta[] { const direct = extractFilePaths(input as SpreadsheetToolInput | undefined); const fromContext = (context?.files || []).filter((attachment) => attachment.filePath); const deduped = new Map(); for (const attachment of [...direct, ...fromContext]) { const key = normalizeLookup(attachment.filePath || attachment.fileName); if (!key) { continue; } if (!deduped.has(key)) { deduped.set(key, attachment); } } return Array.from(deduped.values()); } function buildSpreadsheetToolSummary(reports: SpreadsheetAnalysisReport[]): string { if (reports.length === 0) { return 'Spreadsheet analysis completed with no report output.'; } const parts = reports.map(({ fileName, report }) => { const structure = ensureRecord(report.structure); const sheetNames = Object.keys(structure); const totalRows = Object.values(structure).reduce((sum, sheetValue) => { const shape = ensureRecord(ensureRecord(sheetValue).shape); const rows = typeof shape.rows === 'number' ? shape.rows : 0; return sum + rows; }, 0); return `${fileName}: ${sheetNames.length} sheet(s), ${totalRows} total row(s)`; }); return `Spreadsheet analysis completed. ${parts.join('; ')}`; } function getFileExtension(filePath: string): string { const match = filePath.toLowerCase().match(/\.[^./\\]+$/); return match ? match[0] : ''; } function isDocumentAnalysisExtension(filePath: string): boolean { return DOCUMENT_ANALYSIS_EXTENSIONS.includes(getFileExtension(filePath)); } function decodeXmlEntities(value: string): string { return value .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '\'') .replace(/'/g, '\'') .replace(/&/g, '&'); } function normalizePlainText(value: string): string { return decodeXmlEntities(value) .replace(/\s+/g, ' ') .trim(); } function truncateText(value: string, maxLength = 280): string { const normalized = normalizePlainText(value); if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, maxLength - 3).trim()}...`; } function countWords(value: string): number { const normalized = normalizePlainText(value); if (!normalized) { return 0; } return normalized.split(/\s+/).filter(Boolean).length; } function toRecordArray(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => item !== null && typeof item === 'object' && !Array.isArray(item)) : []; } function collectXmlTagText(xml: string, tagName: string): string[] { const pattern = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'gi'); return Array.from(xml.matchAll(pattern)) .map((match) => normalizePlainText(match[1] || '')) .filter(Boolean); } function extractXmlScalar(xml: string | null, tagNames: string[]): string | undefined { if (!xml) { return undefined; } for (const tagName of tagNames) { const pattern = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'); const match = xml.match(pattern); const value = normalizePlainText(match?.[1] || ''); if (value) { return value; } } return undefined; } function extractXmlInteger(xml: string | null, tagNames: string[]): number | undefined { const scalar = extractXmlScalar(xml, tagNames); if (!scalar) { return undefined; } const parsed = Number.parseInt(scalar, 10); return Number.isFinite(parsed) ? parsed : undefined; } function buildSkillScriptPath(baseDir: string): string { const separator = baseDir.includes('\\') ? '\\' : '/'; const normalizedBaseDir = baseDir.replace(/[\\/]+$/, ''); return `${normalizedBaseDir}${separator}scripts${separator}xlsx_reader.py`; } function isSpreadsheetCapability(capability: SkillCapability): boolean { if (normalizeLookup(capability.renderHints?.skillType) === 'spreadsheet') { return true; } const haystack = [ capability.skillKey, capability.slug, capability.name, capability.description, capability.category, ...capability.operationHints, ...capability.triggerHints, ...capability.inputExtensions, ].map((value) => normalizeLookup(value)); if (capability.inputExtensions.some((extension) => ['.xls', '.xlsx', '.xlsm', '.csv', '.tsv', '.ods'].includes(normalizeLookup(extension)) )) { return true; } return haystack.some((value) => value === 'xlsx' || value === 'minimax-xlsx' || value.includes('spreadsheet') || value.includes('excel') || value.includes('worksheet') || value.includes('table file') ); } function findCapabilityByToolName( capabilities: SkillCapability[], requestedSkillKey?: string, ): SkillCapability | null { const normalizedRequested = normalizeLookup(requestedSkillKey); if (!normalizedRequested) { return null; } return capabilities.find((capability) => normalizeLookup(capability.skillKey) === normalizedRequested || normalizeLookup(capability.slug) === normalizedRequested ) ?? null; } function findSpreadsheetCapability( capabilities: SkillCapability[], requestedSkillKey?: string, ): SpreadsheetCapabilityResolution | null { const spreadsheetCapabilities = capabilities.filter(isSpreadsheetCapability); const normalizedRequested = normalizeLookup(requestedSkillKey); const orderedCapabilities = normalizedRequested ? [ ...spreadsheetCapabilities.filter((capability) => normalizeLookup(capability.skillKey) === normalizedRequested), ...spreadsheetCapabilities.filter((capability) => normalizeLookup(capability.slug) === normalizedRequested), ...spreadsheetCapabilities.filter((capability) => normalizeLookup(capability.skillKey) !== normalizedRequested && normalizeLookup(capability.slug) !== normalizedRequested), ] : spreadsheetCapabilities; for (const capability of orderedCapabilities) { return { capability, scriptPath: capability.baseDir ? buildSkillScriptPath(capability.baseDir) : undefined, }; } return null; } function getSpreadsheetExtension(filePath: string): string { return getFileExtension(filePath); } function supportsNodeSpreadsheetFallback(filePath: string): boolean { return SPREADSHEET_FALLBACK_EXTENSIONS.includes(getSpreadsheetExtension(filePath)); } function isMissingPythonRuntimeError(error: Error | null): boolean { if (!error) { return false; } const message = error.message.toLowerCase(); return [ 'enoent', 'not found', 'cannot find', 'spawn', 'no usable python command', 'pyenv', 'no global/local python version has been set yet', ].some((pattern) => message.includes(pattern)); } function isUnsupportedPythonSpreadsheetError(error: Error | null): boolean { if (!error) { return false; } const message = error.message.toLowerCase(); return message.includes('legacy binary format not supported') || message.includes('unsupported file format') || message.includes('.xls is a legacy binary format'); } async function runPythonJsonScript(scriptPath: string, filePath: string, signal?: AbortSignal): Promise> { const { spawn } = await import('child_process'); let lastError: Error | null = null; for (const candidate of PYTHON_COMMAND_CANDIDATES) { try { const result = await new Promise>((resolve, reject) => { const child = spawn(candidate.command, [...candidate.args, scriptPath, filePath, '--json'], { windowsHide: true, signal, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', (error) => { reject(error); }); child.on('close', (code) => { if (code !== 0) { reject(new Error(stderr.trim() || stdout.trim() || `Python exited with code ${code ?? 'unknown'}`)); return; } try { resolve(JSON.parse(stdout) as Record); } catch (error) { reject(new Error(`Failed to parse spreadsheet analysis JSON: ${error instanceof Error ? error.message : String(error)}`)); } }); }); return result; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); const message = lastError.message.toLowerCase(); if ( message.includes('enoent') || message.includes('not found') || message.includes('cannot find') || message.includes('spawn') ) { continue; } break; } } throw lastError || new Error('No usable Python command was found for spreadsheet analysis.'); } function toSerializableSpreadsheetValue(value: unknown): unknown { if (value instanceof Date) { return value.toISOString(); } if (typeof value === 'number' && !Number.isFinite(value)) { return null; } return value ?? null; } function detectSpreadsheetValueType(value: unknown): string { if (value === null || value === undefined || value === '') { return 'null'; } if (value instanceof Date) { return 'date'; } if (Array.isArray(value)) { return 'array'; } switch (typeof value) { case 'number': return 'number'; case 'boolean': return 'boolean'; case 'string': return 'string'; case 'object': return 'object'; default: return typeof value; } } function normalizeSpreadsheetRows(rows: Array>, columns: string[]): Array> { return rows.map((row) => { const normalized: Record = {}; for (const column of columns) { normalized[column] = toSerializableSpreadsheetValue(row[column]); } return normalized; }); } function getSpreadsheetColumns(rows: Array>): string[] { const seen = new Set(); const columns: string[] = []; for (const row of rows) { for (const key of Object.keys(row)) { const normalizedKey = String(key || '').trim(); if (!normalizedKey || seen.has(normalizedKey)) { continue; } seen.add(normalizedKey); columns.push(normalizedKey); } } return columns; } function computeSpreadsheetNullColumns( rows: Array>, columns: string[], ): Record { const result: Record = {}; const rowCount = rows.length; for (const column of columns) { let count = 0; for (const row of rows) { const value = row[column]; if (value === null || value === undefined || value === '') { count += 1; } } if (count > 0) { result[column] = { count, pct: Number(((count / Math.max(rowCount, 1)) * 100).toFixed(1)), }; } } return result; } function computeSpreadsheetDtypes( rows: Array>, columns: string[], ): Record { const result: Record = {}; for (const column of columns) { const types = new Set(); for (const row of rows) { const type = detectSpreadsheetValueType(row[column]); if (type !== 'null') { types.add(type); } } if (types.size === 0) { result[column] = 'null'; continue; } if (types.size === 1) { result[column] = Array.from(types)[0] || 'unknown'; continue; } result[column] = 'mixed'; } return result; } function getNumericColumnValues( rows: Array>, column: string, ): number[] { return rows .map((row) => row[column]) .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); } function quantile(sorted: number[], percentile: number): number { if (sorted.length === 0) { return NaN; } if (sorted.length === 1) { return sorted[0] || 0; } const index = (sorted.length - 1) * percentile; const lower = Math.floor(index); const upper = Math.ceil(index); const lowerValue = sorted[lower] ?? sorted[0] ?? 0; const upperValue = sorted[upper] ?? sorted[sorted.length - 1] ?? lowerValue; if (lower === upper) { return lowerValue; } return lowerValue + (upperValue - lowerValue) * (index - lower); } function computeSpreadsheetStats( rows: Array>, columns: string[], ): Record> { const stats: Record> = {}; for (const column of columns) { const values = getNumericColumnValues(rows, column); if (values.length === 0) { continue; } const sorted = [...values].sort((left, right) => left - right); const total = sorted.reduce((sum, value) => sum + value, 0); const mean = total / sorted.length; const variance = sorted.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / sorted.length; stats[column] = { count: sorted.length, mean: Number(mean.toFixed(4)), std: Number(Math.sqrt(variance).toFixed(4)), min: sorted[0] ?? 0, '25%': Number(quantile(sorted, 0.25).toFixed(4)), '50%': Number(quantile(sorted, 0.5).toFixed(4)), '75%': Number(quantile(sorted, 0.75).toFixed(4)), max: sorted[sorted.length - 1] ?? 0, }; } return stats; } function computeSpreadsheetQuality( rows: Array>, columns: string[], ): Array> { const findings: Array> = []; const rowCount = rows.length; for (const column of columns) { const nullCount = rows.reduce((count, row) => { const value = row[column]; return value === null || value === undefined || value === '' ? count + 1 : count; }, 0); if (nullCount > 0) { findings.push({ type: 'null_values', column, count: nullCount, pct: Number(((nullCount / Math.max(rowCount, 1)) * 100).toFixed(1)), note: `Column '${column}' has ${nullCount} null value(s).`, }); } } const normalizedRows = normalizeSpreadsheetRows(rows, columns); const duplicates = normalizedRows.length - new Set(normalizedRows.map((row) => JSON.stringify(row))).size; if (duplicates > 0) { findings.push({ type: 'duplicate_rows', count: duplicates, note: `${duplicates} fully duplicate row(s) found.`, }); } for (const column of columns) { const types = new Set( rows .map((row) => detectSpreadsheetValueType(row[column])) .filter((type) => type !== 'null'), ); if (types.size > 1) { findings.push({ type: 'mixed_type', column, types: Array.from(types), note: `Column '${column}' contains mixed types: ${Array.from(types).join(', ')}.`, }); } const numericValues = getNumericColumnValues(rows, column); if (numericValues.length < 4) { continue; } const sorted = [...numericValues].sort((left, right) => left - right); const q1 = quantile(sorted, 0.25); const q3 = quantile(sorted, 0.75); const iqr = q3 - q1; if (iqr === 0) { continue; } const lower = q1 - (1.5 * iqr); const upper = q3 + (1.5 * iqr); const outlierCount = numericValues.filter((value) => value < lower || value > upper).length; if (outlierCount > 0) { findings.push({ type: 'outliers_iqr', column, count: outlierCount, note: `Column '${column}' has ${outlierCount} potential outlier(s) outside [${lower.toFixed(2)}, ${upper.toFixed(2)}].`, }); } } return findings; } async function readSpreadsheetWithNode(filePath: string): Promise> { const imported = await import('xlsx'); const XLSX = ('readFile' in imported ? imported : imported.default) as typeof import('xlsx'); const workbook = XLSX.readFile(filePath, { cellDates: true, raw: true, dense: false, }); const structure: Record = {}; const quality: Record = {}; const stats: Record = {}; let totalRows = 0; for (const sheetName of workbook.SheetNames) { const worksheet = workbook.Sheets[sheetName]; if (!worksheet) { continue; } const rows = XLSX.utils.sheet_to_json>(worksheet, { defval: null, raw: true, }); const columns = getSpreadsheetColumns(rows); const preview = normalizeSpreadsheetRows(rows.slice(0, 5), columns); const dtypes = computeSpreadsheetDtypes(rows, columns); const nullColumns = computeSpreadsheetNullColumns(rows, columns); const sheetStats = computeSpreadsheetStats(rows, columns); const sheetQuality = computeSpreadsheetQuality(rows, columns); totalRows += rows.length; structure[sheetName] = { shape: { rows: rows.length, cols: columns.length, }, columns, dtypes, null_columns: nullColumns, preview, }; quality[sheetName] = sheetQuality; stats[sheetName] = sheetStats; } return { file: filePath, engine: 'node-xlsx', workbook: { sheetNames: workbook.SheetNames, totalRows, }, structure, quality, stats, }; } async function runSpreadsheetAnalysis( filePath: string, options: { scriptPath?: string; signal?: AbortSignal; }, ): Promise> { const canUseNodeFallback = supportsNodeSpreadsheetFallback(filePath); const mustUseNodeFallback = getSpreadsheetExtension(filePath) === '.xls' || !options.scriptPath; if (mustUseNodeFallback && canUseNodeFallback) { return readSpreadsheetWithNode(filePath); } let pythonError: Error | null = null; if (options.scriptPath) { try { const report = await runPythonJsonScript(options.scriptPath, filePath, options.signal); return { ...report, engine: 'python-xlsx_reader', }; } catch (error) { pythonError = error instanceof Error ? error : new Error(String(error)); } } if ( canUseNodeFallback && ( !options.scriptPath || isMissingPythonRuntimeError(pythonError) || isUnsupportedPythonSpreadsheetError(pythonError) ) ) { return readSpreadsheetWithNode(filePath); } throw pythonError || new Error('Spreadsheet analysis runtime is unavailable.'); } function buildSpreadsheetArtifacts(attachments: AttachedFileMeta[]): ToolArtifact[] { return attachments.map((attachment) => ({ kind: 'file', name: attachment.fileName, filePath: attachment.filePath, mimeType: attachment.mimeType, preview: attachment.preview, metadata: { source: attachment.source, fileSize: attachment.fileSize, }, })); } function isDocumentCapability(capability: SkillCapability): boolean { if (isSpreadsheetCapability(capability)) { return false; } const haystack = [ capability.skillKey, capability.slug, capability.name, capability.description, capability.category, ...capability.operationHints, ...capability.triggerHints, ...capability.inputExtensions, ].map((value) => normalizeLookup(value)); if (capability.inputExtensions.some((extension) => DOCUMENT_ANALYSIS_EXTENSIONS.includes(normalizeLookup(extension)))) { return true; } return haystack.some((value) => value === 'pdf' || value === 'docx' || value === 'pptx' || value.includes('pdf') || value.includes('word document') || value.includes('powerpoint') || value.includes('presentation') ); } function findDocumentCapabilityByToolName( capabilities: SkillCapability[], requestedSkillKey?: string, ): SkillCapability | null { const capability = findCapabilityByToolName(capabilities, requestedSkillKey); return capability && isDocumentCapability(capability) ? capability : null; } function filterCapabilityCompatibleAttachments( capability: SkillCapability, attachments: AttachedFileMeta[], ): AttachedFileMeta[] { const declaredExtensions = capability.inputExtensions.map((extension) => normalizeLookup(extension)); if (declaredExtensions.length === 0) { return attachments; } return attachments.filter((attachment) => { const extension = getFileExtension(attachment.filePath || attachment.fileName || ''); return Boolean(extension) && declaredExtensions.includes(extension); }); } function filterDocumentAnalysisAttachments(attachments: AttachedFileMeta[]): AttachedFileMeta[] { return attachments.filter((attachment) => isDocumentAnalysisExtension(attachment.filePath || attachment.fileName || '')); } async function loadZipArchive(filePath: string) { const fs = (await import('fs-extra')).default; const JSZip = (await import('jszip')).default; const buffer = await fs.readFile(filePath); return JSZip.loadAsync(buffer); } async function readZipEntryText(zip: { file(name: string): { async(type: 'text'): Promise } | null }, entryName: string): Promise { const entry = zip.file(entryName); if (!entry) { return null; } return entry.async('text'); } function extractDocxParagraphs(documentXml: string): Array<{ text: string; headingLevel?: number }> { const paragraphs: Array<{ text: string; headingLevel?: number }> = []; const paragraphPattern = //gi; for (const match of documentXml.matchAll(paragraphPattern)) { const block = match[0] || ''; const text = collectXmlTagText(block, 'w:t').join(' ').trim(); if (!text) { continue; } const headingMatch = block.match(/]*w:val="Heading([1-9])"/i) || block.match(/]*val="Heading([1-9])"/i); paragraphs.push({ text, headingLevel: headingMatch?.[1] ? Number.parseInt(headingMatch[1], 10) : undefined, }); } return paragraphs; } async function analyzeDocxWithNode(filePath: string): Promise { const zip = await loadZipArchive(filePath); const documentXml = await readZipEntryText(zip, 'word/document.xml'); if (!documentXml) { throw new Error('word/document.xml not found in DOCX archive.'); } const coreXml = await readZipEntryText(zip, 'docProps/core.xml'); const appXml = await readZipEntryText(zip, 'docProps/app.xml'); const commentsXml = await readZipEntryText(zip, 'word/comments.xml'); const tableCount = Array.from(documentXml.matchAll(//gi, ' '); const paragraphs = extractDocxParagraphs(bodyXml); const headings = paragraphs.filter((paragraph) => typeof paragraph.headingLevel === 'number'); const paragraphPreview = paragraphs.slice(0, 6).map((paragraph, index) => ({ paragraph: index + 1, text: truncateText(paragraph.text), headingLevel: paragraph.headingLevel, })); const wordCount = paragraphs.reduce((sum, paragraph) => sum + countWords(paragraph.text), 0); const pageCount = extractXmlInteger(appXml, ['Pages']); const title = extractXmlScalar(coreXml, ['dc:title', 'title']); const author = extractXmlScalar(coreXml, ['dc:creator', 'creator']); const subject = extractXmlScalar(coreXml, ['dc:subject', 'subject']); const commentCount = commentsXml ? Array.from(commentsXml.matchAll(/ = { title, author, subject, pageCount, paragraphCount: paragraphs.length, headingCount: headings.length, tableCount, commentCount, wordCount, }; return { filePath, fileName: filePath.split(/[\\/]/).pop() || filePath, kind: 'docx', engine: 'node-openxml', summary: `${filePath.split(/[\\/]/).pop() || filePath}: ${paragraphs.length} paragraph(s), ${tableCount} table(s)`, metadata, preview: paragraphPreview, }; } async function analyzePptxWithNode(filePath: string): Promise { const zip = await loadZipArchive(filePath); const presentationXml = await readZipEntryText(zip, 'ppt/presentation.xml'); const slideEntries = zip .file(/^ppt\/slides\/slide\d+\.xml$/) .sort((left, right) => { const leftNumber = Number.parseInt(left.name.match(/slide(\d+)\.xml$/)?.[1] || '0', 10); const rightNumber = Number.parseInt(right.name.match(/slide(\d+)\.xml$/)?.[1] || '0', 10); return leftNumber - rightNumber; }); const slidePreview: Array> = []; let textBlockCount = 0; let wordCount = 0; for (const entry of slideEntries) { const slideXml = await entry.async('text'); const texts = collectXmlTagText(slideXml, 'a:t'); textBlockCount += texts.length; const combinedText = texts.join(' ').trim(); wordCount += countWords(combinedText); if (slidePreview.length < 6 && combinedText) { const slideNumber = Number.parseInt(entry.name.match(/slide(\d+)\.xml$/)?.[1] || '0', 10); slidePreview.push({ slide: slideNumber || slidePreview.length + 1, text: truncateText(combinedText, 320), }); } } const hiddenSlideCount = presentationXml ? Array.from(presentationXml.matchAll(/]*show="0"/gi)).length : 0; const title = slidePreview[0] && typeof slidePreview[0].text === 'string' ? slidePreview[0].text : undefined; const metadata: Record = { title, slideCount: slideEntries.length, hiddenSlideCount, textBlockCount, wordCount, }; return { filePath, fileName: filePath.split(/[\\/]/).pop() || filePath, kind: 'pptx', engine: 'node-openxml', summary: `${filePath.split(/[\\/]/).pop() || filePath}: ${slideEntries.length} slide(s)`, metadata, preview: slidePreview, }; } async function analyzePdfWithNode(filePath: string): Promise { const fs = (await import('fs-extra')).default; const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); const buffer = await fs.readFile(filePath); const loadingTask = (pdfjs as { getDocument: (input: Record) => { promise: Promise; destroy?: () => Promise } }).getDocument({ data: new Uint8Array(buffer), disableFontFace: true, isEvalSupported: false, useWorkerFetch: false, }); const document = await loadingTask.promise; const metadataResult = typeof document.getMetadata === 'function' ? await document.getMetadata().catch(() => null) : null; const preview: Array> = []; let textItemCount = 0; let wordCount = 0; for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { const page = await document.getPage(pageNumber); const textContent = await page.getTextContent(); const text = (Array.isArray(textContent.items) ? textContent.items : []) .map((item: { str?: string }) => (typeof item?.str === 'string' ? item.str : '')) .join(' ') .replace(/\s+/g, ' ') .trim(); textItemCount += Array.isArray(textContent.items) ? textContent.items.length : 0; wordCount += countWords(text); if (preview.length < 4 && text) { preview.push({ page: pageNumber, text: truncateText(text, 320), }); } } const info = metadataResult && typeof metadataResult === 'object' && 'info' in metadataResult ? ensureRecord((metadataResult as { info?: unknown }).info) : {}; const metadata: Record = { title: typeof info.Title === 'string' ? info.Title : undefined, author: typeof info.Author === 'string' ? info.Author : undefined, pageCount: document.numPages, textItemCount, wordCount, }; if (typeof document.destroy === 'function') { await document.destroy(); } if (typeof loadingTask.destroy === 'function') { await loadingTask.destroy().catch(() => undefined); } return { filePath, fileName: filePath.split(/[\\/]/).pop() || filePath, kind: 'pdf', engine: 'node-pdfjs', summary: `${filePath.split(/[\\/]/).pop() || filePath}: ${document.numPages} page(s)`, metadata, preview, }; } async function runDocumentAnalysis(filePath: string): Promise { const extension = getFileExtension(filePath); switch (extension) { case '.docx': return analyzeDocxWithNode(filePath); case '.pptx': return analyzePptxWithNode(filePath); case '.pdf': return analyzePdfWithNode(filePath); default: throw new Error(`Unsupported document attachment: ${filePath}`); } } function buildDocumentAnalysisArtifacts(attachments: AttachedFileMeta[]): ToolArtifact[] { return buildSpreadsheetArtifacts(attachments); } function buildDocumentStructuredResult( capability: SkillCapability, reports: DocumentAnalysisReport[], ): Record { return { skillKey: capability.skillKey, fileCount: reports.length, kinds: dedupeStrings(reports.map((report) => report.kind)), reports: reports.map((report) => ({ fileName: report.fileName, filePath: report.filePath, kind: report.kind, engine: report.engine, summary: report.summary, metadata: report.metadata, preview: report.preview, })), }; } function buildDocumentAnalysisSummary(reports: DocumentAnalysisReport[]): string { if (reports.length === 0) { return 'Document analysis completed with no report output.'; } return `Document analysis completed. ${reports.map((report) => report.summary).join('; ')}`; } async function loadSkillExecutionConfig(skillKey: string): Promise { try { const { getSkillConfig } = await import('@electron/utils/skill-config'); const config = await getSkillConfig(skillKey); const env = Object.fromEntries( Object.entries(config?.env || {}) .map(([key, value]) => [String(key || '').trim(), String(value || '').trim()]) .filter(([key, value]) => Boolean(key) && Boolean(value)), ); const apiKey = getTrimmedString(config?.apiKey); return { apiKey, env, }; } catch { return { env: {}, }; } } function getConfiguredEnvValue(config: SkillExecutionConfig, envName: string): string | undefined { const fromConfig = getTrimmedString(config.env[envName]); if (fromConfig) { return fromConfig; } return getTrimmedString(process.env[envName]); } function hasConfiguredAuth(capability: SkillCapability, config: SkillExecutionConfig): boolean { if (config.apiKey) { return true; } return capability.requiredEnvVars.some((envName) => Boolean(getConfiguredEnvValue(config, envName))); } function isSearchCapability(capability: SkillCapability): boolean { if (isSpreadsheetCapability(capability) || isDocumentCapability(capability)) { return false; } if (normalizeLookup(capability.renderHints?.skillType) === 'search') { return true; } if (normalizeLookup(capability.category) === 'search') { return true; } return capability.allowedTools.some((toolName) => normalizeLookup(toolName).includes('search')) || capability.operationHints.some((operation) => ['search', 'research', 'crawl', 'extract'].includes(normalizeLookup(operation))); } function isBrowserCapability(capability: SkillCapability): boolean { if (isSpreadsheetCapability(capability) || isDocumentCapability(capability) || isSearchCapability(capability)) { return false; } if (normalizeLookup(capability.renderHints?.skillType) === 'browser') { return true; } const haystack = [ capability.skillKey, capability.slug, capability.name, capability.description, capability.category, ...capability.allowedTools, ...capability.operationHints, ...capability.triggerHints, ].map((value) => normalizeLookup(value)); return haystack.some((value) => value === 'browser.open_url' || value.includes('browser') || value.includes('open url') || value.includes('open webpage') || value.includes('visit url') ); } type CommandProvider = 'clawhub-search' | 'generic-command'; type CommandExecutionPlan = { provider: CommandProvider; displayCommand: string; command: string; args: string[]; cwd?: string; }; function isCommandCapability(capability: SkillCapability): boolean { if ( isSpreadsheetCapability(capability) || isDocumentCapability(capability) || isSearchCapability(capability) || isBrowserCapability(capability) ) { return false; } return Boolean(resolveCommandProvider(capability)); } function listCommandScriptEntrypoints(baseDir: string | undefined): string[] { if (!baseDir || !nodeFs) { return []; } const scriptsDir = joinPath(baseDir, 'scripts'); if (!nodeFs.existsSync(scriptsDir)) { return []; } const supportedExtensions = new Set(['.js', '.cjs', '.mjs', '.py', '.ps1', '.cmd', '.bat', '.sh']); const entries: string[] = []; for (const entry of nodeFs.readdirSync(scriptsDir, { withFileTypes: true })) { if (!entry.isFile()) { continue; } const lowerName = entry.name.toLowerCase(); if (lowerName.startsWith('_') || lowerName === '__init__.py') { continue; } const extension = getFileExtension(entry.name); if (!supportedExtensions.has(extension)) { continue; } entries.push(joinPath(scriptsDir, entry.name)); } return entries; } function tokenizeCommandLine(commandLine: string): string[] { const tokens: string[] = []; let current = ''; let quote: '"' | '\'' | null = null; let escaping = false; for (const char of commandLine.trim()) { if (escaping) { current += char; escaping = false; continue; } if (char === '\\') { escaping = true; continue; } if (quote) { if (char === quote) { quote = null; } else { current += char; } continue; } if (char === '"' || char === '\'') { quote = char; continue; } if (/\s/.test(char)) { if (current) { tokens.push(current); current = ''; } continue; } current += char; } if (current) { tokens.push(current); } return tokens; } function looksLikeQueryPlaceholder(token: string): boolean { const normalized = normalizeLookup(token) .replace(/^["']|["']$/g, '') .replace(/^[<{[(]+|[>})\]]+$/g, ''); return normalized === 'query' || normalized === 'your query' || normalized === 'search query' || normalized === 'your search query' || normalized === 'topic' || normalized === 'task' || normalized === 'package' || normalized === 'name' || normalized === 'slug'; } function applyCommandTemplateInputs( template: string, query: string, inputArgs: string[], ): { command: string; args: string[]; displayCommand: string } | null { const tokens = tokenizeCommandLine(template); if (tokens.length === 0) { return null; } const hydratedTokens = [...tokens]; let replacedPlaceholder = false; for (let index = 0; index < hydratedTokens.length; index += 1) { if (looksLikeQueryPlaceholder(hydratedTokens[index] || '')) { hydratedTokens[index] = query; replacedPlaceholder = true; } } if (query && !replacedPlaceholder) { hydratedTokens.push(query); } if (inputArgs.length > 0) { hydratedTokens.push(...inputArgs); } const [command, ...args] = hydratedTokens; return { command, args, displayCommand: hydratedTokens.join(' '), }; } function maybeResolveRelativeCommand(command: string, baseDir: string | undefined): string { if (!baseDir) { return command; } if (command.startsWith('./') || command.startsWith('.\\')) { return joinPath(baseDir, command.slice(2)); } if (command.startsWith('scripts/') || command.startsWith('scripts\\')) { return joinPath(baseDir, command); } return command; } function buildCommandPlanFromTokens( capability: SkillCapability, command: string, args: string[], displayCommand: string, ): CommandExecutionPlan | null { const resolvedCommand = maybeResolveRelativeCommand(command, capability.baseDir); const extension = getFileExtension(resolvedCommand); if (extension === '.js' || extension === '.cjs' || extension === '.mjs') { return { provider: 'generic-command', command: process.execPath, args: [resolvedCommand, ...args], cwd: capability.baseDir, displayCommand, }; } if (extension === '.py') { const candidate = PYTHON_COMMAND_CANDIDATES[0]; return { provider: 'generic-command', command: candidate?.command || 'python', args: [...(candidate?.args || []), resolvedCommand, ...args], cwd: capability.baseDir, displayCommand, }; } if (extension === '.ps1') { return { provider: 'generic-command', command: 'powershell', args: ['-ExecutionPolicy', 'Bypass', '-File', resolvedCommand, ...args], cwd: capability.baseDir, displayCommand, }; } if (extension === '.sh') { return { provider: 'generic-command', command: 'bash', args: [resolvedCommand, ...args], cwd: capability.baseDir, displayCommand, }; } if (extension === '.cmd' || extension === '.bat') { return { provider: 'generic-command', command: resolvedCommand, args, cwd: capability.baseDir, displayCommand, }; } if (normalizeLookup(resolvedCommand) === 'node') { return { provider: 'generic-command', command: process.execPath, args, cwd: capability.baseDir, displayCommand, }; } return { provider: 'generic-command', command: resolvedCommand, args, cwd: capability.baseDir, displayCommand, }; } function resolveSearchProvider(capability: SkillCapability): SearchProvider | null { const haystack = [ capability.skillKey, capability.slug, capability.name, capability.description, ...capability.allowedTools, ...capability.requiredEnvVars, ...capability.triggerHints, ] .map((value) => normalizeLookup(value)) .join(' '); if (haystack.includes('tavily') || haystack.includes('tvly')) { return 'tavily'; } if (haystack.includes('brave')) { return 'brave'; } return null; } function findSearchExecutionCapability( capabilities: SkillCapability[], requestedSkillKey?: string, ): { capability: SkillCapability; provider: SearchProvider } | null { const capability = findCapabilityByToolName(capabilities, requestedSkillKey); if (!capability || !isSearchCapability(capability)) { return null; } const provider = resolveSearchProvider(capability); return provider ? { capability, provider } : null; } function resolveBrowserProvider(capability: SkillCapability): 'browser.open_url' | null { if (!isBrowserCapability(capability)) { return null; } const haystack = [ capability.skillKey, capability.slug, ...capability.allowedTools, ...capability.triggerHints, ] .map((value) => normalizeLookup(value)) .join(' '); return haystack.includes('browser.open_url') || haystack.includes('open url') || haystack.includes('browser') ? 'browser.open_url' : null; } function findBrowserExecutionCapability( capabilities: SkillCapability[], requestedSkillKey?: string, ): { capability: SkillCapability; provider: 'browser.open_url' } | null { const capability = findCapabilityByToolName(capabilities, requestedSkillKey); if (!capability) { return null; } const provider = resolveBrowserProvider(capability); return provider ? { capability, provider } : null; } function resolveCommandProvider(capability: SkillCapability): CommandProvider | null { const haystack = [ capability.skillKey, capability.slug, capability.name, capability.description, ...capability.allowedTools, ...capability.triggerHints, ] .map((value) => normalizeLookup(value)) .join(' '); if ( haystack.includes('find-skills') || haystack.includes('npx skills find') || haystack.includes('skills cli') ) { return 'clawhub-search'; } if ((capability.commandExamples?.length ?? 0) > 0 || listCommandScriptEntrypoints(capability.baseDir).length > 0) { return 'generic-command'; } return null; } function findCommandExecutionCapability( capabilities: SkillCapability[], requestedSkillKey?: string, ): { capability: SkillCapability; provider: CommandProvider } | null { const capability = findCapabilityByToolName(capabilities, requestedSkillKey); if (!capability) { return null; } const provider = resolveCommandProvider(capability); return provider ? { capability, provider } : null; } function resolveSearchApiKey( provider: SearchProvider, capability: SkillCapability, config: SkillExecutionConfig, ): string | undefined { if (config.apiKey) { return config.apiKey; } const envCandidates = provider === 'brave' ? dedupeStrings([...capability.requiredEnvVars, 'BRAVE_SEARCH_API_KEY']) : dedupeStrings([...capability.requiredEnvVars, 'TAVILY_API_KEY']); for (const envName of envCandidates) { const value = getConfiguredEnvValue(config, envName); if (value) { return value; } } return undefined; } function getMissingSearchCredentialNames( provider: SearchProvider, capability: SkillCapability, ): string[] { const defaults = provider === 'brave' ? ['BRAVE_SEARCH_API_KEY'] : ['TAVILY_API_KEY']; return dedupeStrings([...capability.requiredEnvVars, ...defaults]); } function normalizeBraveFreshness(input: SearchExecutionInput): string | undefined { const freshness = getTrimmedString(input.freshness); if (freshness) { return freshness; } const timeRange = normalizeLookup(input.timeRange); return BRAVE_TIME_RANGE_TO_FRESHNESS[timeRange]; } function normalizeTavilyTimeRange(input: SearchExecutionInput): string | undefined { const timeRange = getTrimmedString(input.timeRange); if (timeRange) { return timeRange; } const freshness = normalizeLookup(input.freshness); return TAVILY_FRESHNESS_TO_TIME_RANGE[freshness]; } function applyQueryDomainFilters( query: string, includeDomains: string[], excludeDomains: string[], ): string { const parts = [query.trim()]; if (includeDomains.length === 1) { parts.push(`site:${includeDomains[0]}`); } else if (includeDomains.length > 1) { parts.push(`(${includeDomains.map((domain) => `site:${domain}`).join(' OR ')})`); } for (const domain of excludeDomains) { parts.push(`-site:${domain}`); } return parts.filter(Boolean).join(' ').trim(); } async function parseJsonResponse(response: Response): Promise> { const text = await response.text(); if (!text.trim()) { return {}; } try { return JSON.parse(text) as Record; } catch { throw new Error(`Failed to parse JSON response (HTTP ${response.status}).`); } } async function throwSearchResponseError(response: Response, providerLabel: string): Promise { const payload = await parseJsonResponse(response).catch(() => ({})); const message = getTrimmedString(payload.error) || getTrimmedString(payload.message) || getTrimmedString(payload.detail) || `HTTP ${response.status}`; throw new Error(`${providerLabel} search failed: ${message}`); } async function executeBraveSearch( capability: SkillCapability, input: SearchExecutionInput, apiKey: string, signal?: AbortSignal, ): Promise { const count = coerceInteger(input.count ?? input.maxResults, BRAVE_DEFAULT_RESULT_COUNT, 1, 20); const includeDomains = coerceStringList(input.includeDomains); const excludeDomains = coerceStringList(input.excludeDomains); const params = new URLSearchParams({ q: applyQueryDomainFilters(input.query, includeDomains, excludeDomains), count: String(count), }); const country = getTrimmedString(input.country); const searchLang = getTrimmedString(input.searchLang); const uiLang = getTrimmedString(input.uiLang); const safeSearch = getTrimmedString(input.safesearch) || getTrimmedString(input.safeSearch); const freshness = normalizeBraveFreshness(input); if (country) { params.set('country', country); } if (searchLang) { params.set('search_lang', searchLang); } if (uiLang) { params.set('ui_lang', uiLang); } if (safeSearch) { params.set('safesearch', safeSearch); } if (freshness) { params.set('freshness', freshness); } const response = await fetch(`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, { method: 'GET', headers: { Accept: 'application/json', 'X-Subscription-Token': apiKey, }, signal, }); if (!response.ok) { await throwSearchResponseError(response, capability.name); } const payload = await parseJsonResponse(response); const web = ensureRecord(payload.web); const results = toRecordArray(web.results) .slice(0, count) .map((item) => ({ title: getTrimmedString(item.title) || getTrimmedString(item.url) || 'Untitled result', url: getTrimmedString(item.url) || '', snippet: getTrimmedString(item.description) || (Array.isArray(item.extra_snippets) ? getTrimmedString(item.extra_snippets.find((value) => typeof value === 'string')) : undefined), source: getTrimmedString(ensureRecord(item.profile).name) || getTrimmedString(item.age) || getUrlHostname(getTrimmedString(item.url)), age: getTrimmedString(item.age), publishedAt: getTrimmedString(item.page_age), })) .filter((item) => item.url); return { provider: 'brave', engine: 'brave-search-api', query: getTrimmedString(ensureRecord(payload.query).original) || input.query, resultCount: results.length, results, metadata: { skillKey: capability.skillKey, count, freshness, country, searchLang, uiLang, moreResultsAvailable: ensureRecord(payload.query).more_results_available === true, familyFriendly: web.family_friendly === true, }, }; } async function executeTavilySearch( capability: SkillCapability, input: SearchExecutionInput, apiKey: string, signal?: AbortSignal, ): Promise { const maxResults = coerceInteger(input.maxResults ?? input.count, TAVILY_DEFAULT_RESULT_COUNT, 1, 20); const searchDepth = getTrimmedString(input.searchDepth) || getTrimmedString(input.depth); const topic = getTrimmedString(input.topic); const timeRange = normalizeTavilyTimeRange(input); const includeDomains = coerceStringList(input.includeDomains); const excludeDomains = coerceStringList(input.excludeDomains); const includeAnswer = coerceBoolean(input.includeAnswer); const includeRawContent = typeof input.includeRawContent === 'string' ? getTrimmedString(input.includeRawContent) : coerceBoolean(input.includeRawContent); const body: Record = { query: input.query, max_results: maxResults, }; if (searchDepth) { body.search_depth = searchDepth; } if (topic) { body.topic = topic; } if (timeRange) { body.time_range = timeRange; } if (includeDomains.length > 0) { body.include_domains = includeDomains; } if (excludeDomains.length > 0) { body.exclude_domains = excludeDomains; } if (includeAnswer !== undefined) { body.include_answer = includeAnswer; } if (includeRawContent !== undefined) { body.include_raw_content = includeRawContent; } const response = await fetch('https://api.tavily.com/search', { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), signal, }); if (!response.ok) { await throwSearchResponseError(response, capability.name); } const payload = await parseJsonResponse(response); const results = toRecordArray(payload.results) .slice(0, maxResults) .map((item) => ({ title: getTrimmedString(item.title) || getTrimmedString(item.url) || 'Untitled result', url: getTrimmedString(item.url) || '', snippet: getTrimmedString(item.content) || getTrimmedString(item.raw_content) || getTrimmedString(item.snippet), score: typeof item.score === 'number' && Number.isFinite(item.score) ? Number(item.score.toFixed(4)) : undefined, source: getUrlHostname(getTrimmedString(item.url)), publishedAt: getTrimmedString(item.published_date), })) .filter((item) => item.url); const responseTime = typeof payload.response_time === 'number' && Number.isFinite(payload.response_time) ? payload.response_time : undefined; return { provider: 'tavily', engine: 'tavily-search-api', query: getTrimmedString(payload.query) || input.query, resultCount: results.length, answer: getTrimmedString(payload.answer), responseTimeMs: typeof responseTime === 'number' ? Math.round(responseTime * 1000) : undefined, results, metadata: { skillKey: capability.skillKey, maxResults, searchDepth, topic, timeRange, includeDomains, excludeDomains, }, }; } function buildSearchArtifacts(report: SearchExecutionReport): ToolArtifact[] { return report.results.slice(0, 6).map((result, index) => ({ kind: 'url', name: result.title || `Result ${index + 1}`, label: result.title || `Result ${index + 1}`, description: result.snippet || result.source || report.provider, uri: result.url, metadata: { provider: report.provider, score: result.score, source: result.source, age: result.age, publishedAt: result.publishedAt, }, })); } function buildSearchStructuredResult( capability: SkillCapability, report: SearchExecutionReport, ): Record { return { skillKey: capability.skillKey, provider: report.provider, engine: report.engine, query: report.query, resultCount: report.resultCount, answer: report.answer, responseTimeMs: report.responseTimeMs, ...report.metadata, results: report.results, }; } function buildSearchSummary( capability: SkillCapability, report: SearchExecutionReport, ): string { const answerSuffix = report.answer ? ' Included an answer summary.' : ''; return `Search completed with ${capability.name}. Found ${report.resultCount} result(s) for "${report.query}".${answerSuffix}`; } async function executeBrowserOpen( url: string, signal?: AbortSignal, ): Promise> { const { openUrlInBrowser } = await import('@electron/service/browser-open-service'); return openUrlInBrowser(url, { signal }) as Promise>; } async function executeClawHubSearch( capability: SkillCapability, query: string, limit: number, ): Promise { const { ClawHubService } = await import('./clawhub'); const service = new ClawHubService(); const results = await service.search({ query, limit }); const normalizedResults: SearchResultItem[] = results.map((item) => { const slug = getTrimmedString(item.slug) || ''; const skillUrl = slug ? `https://skills.sh/${slug.replace('@', '/')}` : ''; return { title: getTrimmedString(item.name) || slug || 'Skill result', url: skillUrl, snippet: getTrimmedString(item.description), source: 'skills.sh', slug, version: getTrimmedString(item.version), downloads: typeof item.downloads === 'number' && Number.isFinite(item.downloads) ? item.downloads : undefined, stars: typeof item.stars === 'number' && Number.isFinite(item.stars) ? item.stars : undefined, installCommand: slug ? `npx skills add ${slug} -g -y` : undefined, }; }); return { provider: 'clawhub', engine: 'clawhub-search', query, resultCount: normalizedResults.length, results: normalizedResults, metadata: { skillKey: capability.skillKey, provider: 'clawhub', limit, command: 'npx skills find', }, }; } function buildSkillCommandEnv( capability: SkillCapability, config: SkillExecutionConfig, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, }; for (const [key, value] of Object.entries(config.env)) { if (value) { env[key] = value; } } if (config.apiKey) { for (const envName of capability.requiredEnvVars) { if (!env[envName]) { env[envName] = config.apiKey; } } } return env; } function selectGenericCommandPlan( capability: SkillCapability, input: GenericSkillInput & Record, ): CommandExecutionPlan | null { const explicitCommand = getInputCommandName(input); const inputArgs = getInputArgs(input); const query = normalizeSearchQuery(input); if (capability.commandExamples?.length) { const templates = explicitCommand ? capability.commandExamples.filter((template) => normalizeLookup(template).includes(normalizeLookup(explicitCommand)) ) : capability.commandExamples; for (const template of templates) { const hydrated = applyCommandTemplateInputs(template, query, inputArgs); if (!hydrated) { continue; } const plan = buildCommandPlanFromTokens( capability, hydrated.command, hydrated.args, hydrated.displayCommand, ); if (plan) { return plan; } } } const scriptCandidates = listCommandScriptEntrypoints(capability.baseDir); const matchedScripts = explicitCommand ? scriptCandidates.filter((filePath) => normalizeLookup(filePath.split(/[\\/]/).pop()).includes(normalizeLookup(explicitCommand)) ) : scriptCandidates; const selectedScript = matchedScripts.length === 1 ? matchedScripts[0] : matchedScripts.find((filePath) => { const baseName = normalizeLookup(filePath.split(/[\\/]/).pop()); return baseName.includes(normalizeLookup(capability.slug)) || baseName.includes(normalizeLookup(capability.skillKey)); }); if (selectedScript) { return buildCommandPlanFromTokens( capability, selectedScript, query ? [query, ...inputArgs] : inputArgs, `${selectedScript}${query ? ` ${query}` : ''}${inputArgs.length ? ` ${inputArgs.join(' ')}` : ''}`.trim(), ); } return null; } async function runGenericCommandPlan( plan: CommandExecutionPlan, env: NodeJS.ProcessEnv, signal?: AbortSignal, ): Promise<{ stdout: string; stderr: string }> { if (!nodeChildProcess) { throw new Error('Node child_process runtime is unavailable for command-style skill execution.'); } return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { const child = nodeChildProcess.spawn(plan.command, plan.args, { cwd: plan.cwd, env, windowsHide: true, signal, }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('error', (error) => { reject(error); }); child.on('close', (code) => { if (code !== 0) { reject(new Error(stderr.trim() || stdout.trim() || `Command exited with code ${code ?? 'unknown'}`)); return; } resolve({ stdout: stdout.trim(), stderr: stderr.trim() }); }); }); } function parseCommandOutput(stdout: string): unknown { const trimmed = stdout.trim(); if (!trimmed) { return null; } if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { return JSON.parse(trimmed); } catch { return trimmed; } } return trimmed; } function buildUnsupportedSkillSummary(capability: SkillCapability): string { const details = [ capability.category ? `category=${capability.category}` : null, capability.allowedTools.length > 0 ? `allowedTools=${capability.allowedTools.join(',')}` : null, capability.inputExtensions.length > 0 ? `inputs=${capability.inputExtensions.join(',')}` : null, ].filter(Boolean); return [ `Skill ${capability.name} is enabled, but direct chat execution is not implemented yet.`, details.length > 0 ? `(${details.join('; ')})` : null, ].filter(Boolean).join(' '); } function createBrowserOpenAdapter(): ToolRuntimeAdapter { return { toolName: 'browser.open_url', async preflight(invocation: ToolRuntimeInvocation): Promise { const input = ensureRecord(invocation.input); const url = typeof input.url === 'string' ? input.url.trim() : ''; const isValidUrl = /^https?:\/\//i.test(url); if (!isValidUrl) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: 'browser.open_url requires a valid http/https URL.', error: { code: 'missing_required_url', message: 'browser.open_url requires a valid http/https URL.', }, }; } return { ok: true, status: 'ready', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { url }, summary: `Open ${url} in the managed browser.`, }; }, async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { const input = ensureRecord(invocation.input); const url = typeof input.url === 'string' ? input.url.trim() : ''; const result = await executeBrowserOpen(url, context.signal); return { ok: true, status: 'completed', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { url }, summary: `Opened ${result.pageUrl}${result.title ? ` (${result.title})` : ''}`, raw: result, renderHints: { card: 'browser-step', preferredView: 'summary', skillType: 'browser', }, durationMs: 0, }; }, }; } function createBrowserSkillAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { return { toolName: '__browser_skill__', matchesTool(toolName: string): boolean { return Boolean(findBrowserExecutionCapability(capabilities, toolName)); }, async preflight(invocation: ToolRuntimeInvocation): Promise { const resolved = findBrowserExecutionCapability(capabilities, invocation.toolName); if (!resolved) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `No installed browser skill runtime is available for ${invocation.toolName}.`, error: { code: 'missing_skill_runtime', message: `No installed browser skill runtime is available for ${invocation.toolName}.`, }, }; } const input = ensureRecord(invocation.input); const url = extractExplicitUrl(input.url) || extractExplicitUrl(input.prompt); if (!url) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${resolved.capability.name} requires an explicit http/https URL.`, error: { code: 'missing_required_url', message: `Skill ${resolved.capability.name} requires an explicit http/https URL.`, }, missing: ['url'], metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, }, }; } return { ok: true, status: 'ready', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...input, skillKey: resolved.capability.skillKey, url, }, summary: `Open ${url} with ${resolved.capability.name}.`, metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, }, }; }, async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { const resolved = findBrowserExecutionCapability(capabilities, invocation.toolName); const input = ensureRecord(invocation.input); const url = extractExplicitUrl(input.url) || extractExplicitUrl(input.prompt) || ''; if (!resolved || !url) { return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: invocation.input, summary: `No installed browser skill runtime is available for ${invocation.toolName}.`, error: { code: resolved ? 'missing_required_url' : 'missing_skill_runtime', message: resolved ? `Skill ${invocation.toolName} requires an explicit http/https URL.` : `No installed browser skill runtime is available for ${invocation.toolName}.`, }, durationMs: 0, }; } const result = await executeBrowserOpen(url, context.signal); return { ok: true, status: 'completed', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...input, skillKey: resolved.capability.skillKey, url, }, summary: `Opened ${result.pageUrl || url}${result.title ? ` (${result.title})` : ''} with ${resolved.capability.name}.`, raw: { skillKey: resolved.capability.skillKey, ...result, }, renderHints: resolved.capability.renderHints || { card: 'browser-step', preferredView: 'summary', skillType: 'browser', }, skillType: 'browser', durationMs: 0, }; }, }; } function createSkillsInstallAdapter(): ToolRuntimeAdapter { return { toolName: 'skills.install', async preflight(invocation: ToolRuntimeInvocation): Promise { const input = ensureRecord(invocation.input); const kind = typeof input.kind === 'string' ? input.kind : ''; if (kind === 'github-url' && typeof input.url === 'string' && input.url.trim()) { return { ok: true, status: 'ready', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { kind: 'github-url', url: input.url.trim(), force: input.force === true, }, summary: `Install a skill from ${input.url.trim()}.`, }; } if (kind === 'marketplace' && typeof input.slug === 'string' && input.slug.trim()) { return { ok: true, status: 'ready', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { kind: 'marketplace', slug: input.slug.trim(), force: input.force === true, }, summary: `Install marketplace skill ${input.slug.trim()}.`, }; } return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: 'skills.install requires a marketplace slug or GitHub URL.', error: { code: 'missing_required_input', message: 'skills.install requires a marketplace slug or GitHub URL.', }, }; }, async execute(invocation: ToolRuntimeInvocation): Promise { const { handleSkillsInstall } = await import('./handlers/skills'); const input = invocation.input as ToolCallPayload['input'] & { kind: 'marketplace' | 'github-url'; slug?: string; url?: string; force?: boolean; }; const result = await handleSkillsInstall(input); return { ok: true, status: 'completed', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: input, summary: `Installed skill ${result.slug}.`, raw: result, renderHints: { card: 'skill-install', preferredView: 'summary', skillType: 'skill-install', }, durationMs: 0, }; }, }; } function createSpreadsheetAnalysisAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { return { toolName: 'minimax-xlsx', matchesTool(toolName: string): boolean { const normalized = normalizeLookup(toolName); return normalized === 'xlsx' || normalized === 'minimax-xlsx' || normalized === 'spreadsheet.analysis'; }, async preflight(invocation: ToolRuntimeInvocation): Promise { const input = (invocation.input || {}) as SpreadsheetToolInput; const attachments = resolveInvocationAttachments(input); if (attachments.length === 0) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: 'Spreadsheet analysis requires at least one spreadsheet attachment.', error: { code: 'missing_required_attachment', message: 'Spreadsheet analysis requires at least one spreadsheet attachment.', }, missing: ['attachment'], }; } const resolved = findSpreadsheetCapability(capabilities, input.skillKey || invocation.toolName); if (!resolved) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: 'No installed spreadsheet analysis skill is available.', error: { code: 'missing_skill_runtime', message: 'No installed spreadsheet analysis skill is available.', }, }; } return { ok: true, status: 'ready', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...input, skillKey: resolved.capability.skillKey, attachments, filePaths: attachments .map((attachment) => attachment.filePath) .filter((filePath): filePath is string => Boolean(filePath)), }, summary: `Analyze ${attachments.length} spreadsheet attachment(s) with ${resolved.capability.name}.`, metadata: { skillKey: resolved.capability.skillKey, scriptPath: resolved.scriptPath, }, }; }, async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { const normalizedInput = invocation.input as SpreadsheetToolInput & { attachments: AttachedFileMeta[] }; const attachments = resolveInvocationAttachments(normalizedInput, context); const resolved = findSpreadsheetCapability(capabilities, normalizedInput.skillKey || invocation.toolName); if (!resolved) { return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput, summary: 'Spreadsheet skill runtime is unavailable.', error: { code: 'missing_skill_runtime', message: 'Spreadsheet skill runtime is unavailable.', }, durationMs: 0, }; } const reports: SpreadsheetAnalysisReport[] = []; for (const attachment of attachments) { const filePath = attachment.filePath; if (!filePath) { continue; } const report = await runSpreadsheetAnalysis(filePath, { scriptPath: resolved.scriptPath, signal: context.signal, }); reports.push({ filePath, fileName: attachment.fileName || filePath.split(/[\\/]/).pop() || filePath, report, }); } const summary = buildSpreadsheetToolSummary(reports); return { ok: true, status: 'completed', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput, summary, raw: { prompt: normalizedInput.prompt, skillKey: resolved.capability.skillKey, reports, }, artifacts: buildSpreadsheetArtifacts(attachments), skillType: 'spreadsheet', renderHints: resolved.capability.renderHints || { card: 'document-analysis', preferredView: 'table', skillType: 'spreadsheet', }, durationMs: 0, }; }, }; } function createDocumentAnalysisAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { return { toolName: '__document_skill__', matchesTool(toolName: string): boolean { return Boolean(findDocumentCapabilityByToolName(capabilities, toolName)); }, async preflight(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { const capability = findDocumentCapabilityByToolName(capabilities, invocation.toolName); if (!capability) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `No installed document-analysis skill is available for ${invocation.toolName}.`, error: { code: 'missing_skill_runtime', message: `No installed document-analysis skill is available for ${invocation.toolName}.`, }, }; } const input = ensureRecord(invocation.input) as GenericSkillInput & Record; const attachments = resolveInvocationAttachments(input, context); const compatibleAttachments = filterCapabilityCompatibleAttachments(capability, attachments); if (compatibleAttachments.length === 0) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${capability.name} requires at least one compatible attachment (${capability.inputExtensions.join(', ')}).`, error: { code: 'missing_required_attachment', message: `Skill ${capability.name} requires at least one compatible attachment (${capability.inputExtensions.join(', ')}).`, }, missing: ['attachment'], metadata: { skillKey: capability.skillKey, category: capability.category, inputExtensions: capability.inputExtensions, }, }; } const supportedAttachments = filterDocumentAnalysisAttachments(compatibleAttachments); if (supportedAttachments.length === 0) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${capability.name} can see the attachment, but the current runtime only supports .pdf, .docx, and .pptx execution in chat.`, error: { code: 'unsupported_attachment_type', message: `Skill ${capability.name} can see the attachment, but the current runtime only supports .pdf, .docx, and .pptx execution in chat.`, }, metadata: { skillKey: capability.skillKey, category: capability.category, inputExtensions: capability.inputExtensions, }, }; } return { ok: true, status: 'ready', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...input, skillKey: capability.skillKey, attachments: supportedAttachments, filePaths: supportedAttachments .map((attachment) => attachment.filePath) .filter((filePath): filePath is string => Boolean(filePath)), }, summary: `Analyze ${supportedAttachments.length} document attachment(s) with ${capability.name}.`, metadata: { skillKey: capability.skillKey, category: capability.category, inputExtensions: capability.inputExtensions, }, }; }, async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { const capability = findDocumentCapabilityByToolName(capabilities, invocation.toolName); const normalizedInput = invocation.input as GenericSkillInput & { attachments?: AttachedFileMeta[] }; const attachments = filterDocumentAnalysisAttachments(resolveInvocationAttachments(normalizedInput, context)); if (!capability) { return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput, summary: `No installed document-analysis skill is available for ${invocation.toolName}.`, error: { code: 'missing_skill_runtime', message: `No installed document-analysis skill is available for ${invocation.toolName}.`, }, durationMs: 0, }; } const reports: DocumentAnalysisReport[] = []; for (const attachment of attachments) { if (!attachment.filePath) { continue; } const report = await runDocumentAnalysis(attachment.filePath); reports.push({ ...report, fileName: attachment.fileName || report.fileName, filePath: attachment.filePath, }); } const summary = buildDocumentAnalysisSummary(reports); return { ok: true, status: 'completed', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...normalizedInput, skillKey: capability.skillKey, attachments, }, summary, raw: buildDocumentStructuredResult(capability, reports), artifacts: buildDocumentAnalysisArtifacts(attachments), skillType: 'document', renderHints: capability.renderHints || { card: 'document-analysis', preferredView: 'summary', skillType: 'document', }, durationMs: 0, }; }, }; } function createSearchExecutionAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { return { toolName: '__search_skill__', matchesTool(toolName: string): boolean { return Boolean(findSearchExecutionCapability(capabilities, toolName)); }, async preflight(invocation: ToolRuntimeInvocation): Promise { const resolved = findSearchExecutionCapability(capabilities, invocation.toolName); if (!resolved) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `No installed search skill runtime is available for ${invocation.toolName}.`, error: { code: 'missing_skill_runtime', message: `No installed search skill runtime is available for ${invocation.toolName}.`, }, }; } const input = ensureRecord(invocation.input) as SearchExecutionInput & Record; const query = normalizeSearchQuery(input); if (!query) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${resolved.capability.name} requires a search query.`, error: { code: 'missing_required_input', message: `Skill ${resolved.capability.name} requires a search query.`, }, missing: ['query'], metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, }, }; } const config = await loadSkillExecutionConfig(resolved.capability.skillKey); const apiKey = resolveSearchApiKey(resolved.provider, resolved.capability, config); if (!apiKey) { const missing = getMissingSearchCredentialNames(resolved.provider, resolved.capability); return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${resolved.capability.name} requires configured credentials before it can search: ${missing.join(', ')}.`, error: { code: 'missing_required_env', message: `Skill ${resolved.capability.name} requires configured credentials before it can search: ${missing.join(', ')}.`, }, missing, metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, requiredEnvVars: missing, }, }; } return { ok: true, status: 'ready', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...input, skillKey: resolved.capability.skillKey, provider: resolved.provider, query, }, summary: `Search for "${query}" with ${resolved.capability.name}.`, metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, }, }; }, async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { const resolved = findSearchExecutionCapability(capabilities, invocation.toolName); const normalizedInput = invocation.input as SearchExecutionInput; if (!resolved) { return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput, summary: `No installed search skill runtime is available for ${invocation.toolName}.`, error: { code: 'missing_skill_runtime', message: `No installed search skill runtime is available for ${invocation.toolName}.`, }, durationMs: 0, }; } const config = await loadSkillExecutionConfig(resolved.capability.skillKey); const apiKey = resolveSearchApiKey(resolved.provider, resolved.capability, config); if (!apiKey) { const missing = getMissingSearchCredentialNames(resolved.provider, resolved.capability); return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput, summary: `Skill ${resolved.capability.name} requires configured credentials before it can search: ${missing.join(', ')}.`, error: { code: 'missing_required_env', message: `Skill ${resolved.capability.name} requires configured credentials before it can search: ${missing.join(', ')}.`, }, durationMs: 0, }; } const report = resolved.provider === 'brave' ? await executeBraveSearch(resolved.capability, normalizedInput, apiKey, context.signal) : await executeTavilySearch(resolved.capability, normalizedInput, apiKey, context.signal); const summary = buildSearchSummary(resolved.capability, report); return { ok: true, status: 'completed', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput, summary, raw: buildSearchStructuredResult(resolved.capability, report), artifacts: buildSearchArtifacts(report), skillType: 'search', renderHints: resolved.capability.renderHints || { card: 'search-results', preferredView: 'summary', skillType: 'search', }, durationMs: 0, }; }, }; } function createCommandExecutionAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { return { toolName: '__command_skill__', matchesTool(toolName: string): boolean { return Boolean(findCommandExecutionCapability(capabilities, toolName)); }, async preflight(invocation: ToolRuntimeInvocation): Promise { const resolved = findCommandExecutionCapability(capabilities, invocation.toolName); if (!resolved) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `No installed command skill runtime is available for ${invocation.toolName}.`, error: { code: 'missing_skill_runtime', message: `No installed command skill runtime is available for ${invocation.toolName}.`, }, }; } const input = ensureRecord(invocation.input) as GenericSkillInput & Record; const query = normalizeSearchQuery(input); if (resolved.provider === 'clawhub-search' && !query) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${resolved.capability.name} requires a query or task description.`, error: { code: 'missing_required_input', message: `Skill ${resolved.capability.name} requires a query or task description.`, }, missing: ['query'], metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, }, }; } const config = await loadSkillExecutionConfig(resolved.capability.skillKey); const missingEnvVars = resolved.capability.requiredEnvVars.filter((envName) => !getConfiguredEnvValue(config, envName)); if (missingEnvVars.length > 0) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${resolved.capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, error: { code: 'missing_required_env', message: `Skill ${resolved.capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, }, missing: missingEnvVars, metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, requiredEnvVars: resolved.capability.requiredEnvVars, }, }; } if (resolved.capability.requiresAuth && !hasConfiguredAuth(resolved.capability, config)) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${resolved.capability.name} requires user authorization or account configuration before it can run in chat.`, error: { code: 'user_authorization_required', message: `Skill ${resolved.capability.name} requires user authorization or account configuration before it can run in chat.`, }, metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, requiresAuth: true, }, }; } const commandPlan = resolved.provider === 'generic-command' ? selectGenericCommandPlan(resolved.capability, input) : null; if (resolved.provider === 'generic-command' && !commandPlan) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${resolved.capability.name} does not expose a runnable command template or script entrypoint for chat execution yet.`, error: { code: 'skill_runtime_not_implemented', message: `Skill ${resolved.capability.name} does not expose a runnable command template or script entrypoint for chat execution yet.`, }, metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, commandExamples: resolved.capability.commandExamples, }, }; } return { ok: true, status: 'ready', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...input, skillKey: resolved.capability.skillKey, query, command: getInputCommandName(input), args: getInputArgs(input), }, summary: resolved.provider === 'clawhub-search' ? `Run ${resolved.capability.name} for "${query}".` : `Run ${resolved.capability.name}${commandPlan ? ` via ${commandPlan.displayCommand}` : ''}.`, metadata: { skillKey: resolved.capability.skillKey, provider: resolved.provider, commandPlan: commandPlan?.displayCommand, }, }; }, async execute(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { const resolved = findCommandExecutionCapability(capabilities, invocation.toolName); const input = ensureRecord(invocation.input) as GenericSkillInput & Record; const query = normalizeSearchQuery(input); if (!resolved || (resolved.provider === 'clawhub-search' && !query)) { return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: invocation.input, summary: resolved ? `Skill ${resolved.capability.name} requires a query or task description.` : `No installed command skill runtime is available for ${invocation.toolName}.`, error: { code: resolved ? 'missing_required_input' : 'missing_skill_runtime', message: resolved ? `Skill ${resolved.capability.name} requires a query or task description.` : `No installed command skill runtime is available for ${invocation.toolName}.`, }, durationMs: 0, }; } const config = await loadSkillExecutionConfig(resolved.capability.skillKey); const missingEnvVars = resolved.capability.requiredEnvVars.filter((envName) => !getConfiguredEnvValue(config, envName)); if (missingEnvVars.length > 0) { return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: invocation.input, summary: `Skill ${resolved.capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, error: { code: 'missing_required_env', message: `Skill ${resolved.capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, }, durationMs: 0, }; } switch (resolved.provider) { case 'clawhub-search': { const report = await executeClawHubSearch( resolved.capability, query, coerceInteger((input as GenericSkillInput).count ?? (input as GenericSkillInput).maxResults, 5, 1, 10), ); return { ok: true, status: 'completed', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...input, skillKey: resolved.capability.skillKey, query, }, summary: `Found ${report.resultCount} matching skill result(s) for "${query}".`, raw: buildSearchStructuredResult(resolved.capability, report), artifacts: buildSearchArtifacts(report), logs: [`npx skills find ${query}`], skillType: 'command', renderHints: { ...(resolved.capability.renderHints || {}), card: 'search-results', preferredView: 'summary', skillType: 'command', }, durationMs: 0, }; } case 'generic-command': { const commandPlan = selectGenericCommandPlan(resolved.capability, input); if (!commandPlan) { return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: invocation.input, summary: `Skill ${resolved.capability.name} does not expose a runnable command template or script entrypoint for chat execution yet.`, error: { code: 'skill_runtime_not_implemented', message: `Skill ${resolved.capability.name} does not expose a runnable command template or script entrypoint for chat execution yet.`, }, durationMs: 0, }; } const env = buildSkillCommandEnv(resolved.capability, config); const output = await runGenericCommandPlan(commandPlan, env, context.signal); const parsedOutput = parseCommandOutput(output.stdout); const summary = typeof parsedOutput === 'string' ? `Command completed via ${commandPlan.displayCommand}.` : `Command completed via ${commandPlan.displayCommand}.`; return { ok: true, status: 'completed', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: { ...input, skillKey: resolved.capability.skillKey, query, command: getInputCommandName(input), args: getInputArgs(input), }, summary, raw: Array.isArray(parsedOutput) ? { skillKey: resolved.capability.skillKey, command: commandPlan.displayCommand, results: parsedOutput, stderr: output.stderr || undefined, } : (parsedOutput && typeof parsedOutput === 'object' && !Array.isArray(parsedOutput)) ? { skillKey: resolved.capability.skillKey, command: commandPlan.displayCommand, ...parsedOutput, stderr: output.stderr || undefined, } : { skillKey: resolved.capability.skillKey, command: commandPlan.displayCommand, stdout: parsedOutput, stderr: output.stderr || undefined, }, logs: [commandPlan.displayCommand, ...(output.stderr ? [output.stderr] : [])], skillType: 'command', renderHints: { ...(resolved.capability.renderHints || {}), card: resolved.capability.renderHints?.card || 'command-output', preferredView: resolved.capability.renderHints?.preferredView || 'log', skillType: 'command', }, durationMs: 0, }; } default: return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: invocation.input, summary: buildUnsupportedSkillSummary(resolved.capability), error: { code: 'skill_runtime_not_implemented', message: buildUnsupportedSkillSummary(resolved.capability), }, durationMs: 0, }; } }, }; } function createGenericSkillAdapter(capabilities: SkillCapability[]): ToolRuntimeAdapter { return { toolName: '__generic_skill__', matchesTool(toolName: string): boolean { const capability = findCapabilityByToolName(capabilities, toolName); return Boolean( capability && !isSpreadsheetCapability(capability) && !isDocumentCapability(capability) && !findSearchExecutionCapability(capabilities, toolName) && !findBrowserExecutionCapability(capabilities, toolName) && !findCommandExecutionCapability(capabilities, toolName), ); }, async preflight(invocation: ToolRuntimeInvocation, context: ToolRuntimeContext): Promise { const capability = findCapabilityByToolName(capabilities, invocation.toolName); if ( !capability || isSpreadsheetCapability(capability) || isDocumentCapability(capability) || findSearchExecutionCapability(capabilities, invocation.toolName) || findBrowserExecutionCapability(capabilities, invocation.toolName) || findCommandExecutionCapability(capabilities, invocation.toolName) ) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `No installed skill runtime is available for ${invocation.toolName}.`, error: { code: 'missing_skill_runtime', message: `No installed skill runtime is available for ${invocation.toolName}.`, }, }; } const input = ensureRecord(invocation.input) as GenericSkillInput & Record; const attachments = resolveInvocationAttachments(input, context); const config = await loadSkillExecutionConfig(capability.skillKey); if (capability.inputExtensions.length > 0 && attachments.length === 0 && (context.files?.length ?? 0) === 0) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${capability.name} requires at least one supported attachment (${capability.inputExtensions.join(', ')}).`, error: { code: 'missing_required_attachment', message: `Skill ${capability.name} requires at least one supported attachment (${capability.inputExtensions.join(', ')}).`, }, missing: ['attachment'], metadata: { skillKey: capability.skillKey, category: capability.category, }, }; } const missingEnvVars = capability.requiredEnvVars.filter((envName) => !getConfiguredEnvValue(config, envName)); if (missingEnvVars.length > 0) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, error: { code: 'missing_required_env', message: `Skill ${capability.name} requires configured environment variables before it can run: ${missingEnvVars.join(', ')}.`, }, missing: missingEnvVars, metadata: { skillKey: capability.skillKey, category: capability.category, requiredEnvVars: capability.requiredEnvVars, }, }; } if (capability.requiresAuth && !hasConfiguredAuth(capability, config)) { return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: `Skill ${capability.name} requires user authorization or account configuration before it can run in chat.`, error: { code: 'user_authorization_required', message: `Skill ${capability.name} requires user authorization or account configuration before it can run in chat.`, }, metadata: { skillKey: capability.skillKey, category: capability.category, requiresAuth: true, }, }; } return { ok: false, status: 'blocked', toolCallId: invocation.toolCallId, toolName: invocation.toolName, summary: buildUnsupportedSkillSummary(capability), error: { code: 'skill_runtime_not_implemented', message: buildUnsupportedSkillSummary(capability), }, metadata: { skillKey: capability.skillKey, category: capability.category, allowedTools: capability.allowedTools, inputExtensions: capability.inputExtensions, }, }; }, async execute(invocation: ToolRuntimeInvocation): Promise { const capability = findCapabilityByToolName(capabilities, invocation.toolName); const summary = capability ? buildUnsupportedSkillSummary(capability) : `No installed skill runtime is available for ${invocation.toolName}.`; return { ok: false, status: 'error', toolCallId: invocation.toolCallId, toolName: invocation.toolName, normalizedInput: invocation.input, summary, error: { code: capability ? 'skill_runtime_not_implemented' : 'missing_skill_runtime', message: summary, }, durationMs: 0, }; }, }; } export function mapSkillCapabilitiesToRegistryInputs(capabilities: SkillCapability[]): ToolRegistryCapabilityInput[] { return capabilities.map((capability) => ({ capabilityKey: capability.skillKey, toolName: capability.skillKey, skillKey: capability.skillKey, slug: capability.slug, name: capability.name, displayName: capability.name, description: capability.description, kind: 'skill', aliases: dedupeStrings([ capability.skillKey, capability.slug, capability.name, ...capability.triggerHints, ]), inputKinds: capability.inputExtensions.length > 0 ? ['text', 'file', 'attachment'] : ['text'], outputKinds: ['text', 'json', 'artifacts'], triggerHints: capability.triggerHints, supportedFileTypes: capability.inputExtensions, requiresFiles: capability.inputExtensions.length > 0, enabled: capability.enabled, source: capability.source, metadata: { category: capability.category, version: capability.version, baseDir: capability.baseDir, manifestPath: capability.manifestPath, allowedTools: capability.allowedTools, operationHints: capability.operationHints, requiredEnvVars: capability.requiredEnvVars, requiresAuth: capability.requiresAuth, plannerSummary: capability.plannerSummary, renderHints: capability.renderHints, }, })); } export function createGatewayToolDefinitions(registry: ToolRegistryEntry[]): GatewayToolDefinition[] { return registry.map((entry) => { const fileItems = entry.requiresFiles ? { attachments: { type: 'array', items: { type: 'object', properties: { fileName: { type: 'string' }, mimeType: { type: 'string' }, filePath: { type: 'string' }, }, required: ['filePath'], additionalProperties: true, }, }, filePaths: { type: 'array', items: { type: 'string' }, }, } : {}; return { name: entry.toolName, description: entry.description || entry.displayName, inputSchema: { type: 'object', properties: { prompt: { type: 'string' }, query: { type: 'string' }, url: { type: 'string' }, slug: { type: 'string' }, kind: { type: 'string' }, skillKey: { type: 'string' }, count: { type: 'integer' }, maxResults: { type: 'integer' }, depth: { type: 'string' }, searchDepth: { type: 'string' }, timeRange: { type: 'string' }, freshness: { type: 'string' }, includeDomains: { anyOf: [ { type: 'string' }, { type: 'array', items: { type: 'string' } }, ], }, excludeDomains: { anyOf: [ { type: 'string' }, { type: 'array', items: { type: 'string' } }, ], }, topic: { type: 'string' }, country: { type: 'string' }, searchLang: { type: 'string' }, uiLang: { type: 'string' }, includeAnswer: { type: 'boolean' }, includeRawContent: { anyOf: [ { type: 'boolean' }, { type: 'string' }, ], }, ...fileItems, }, additionalProperties: true, }, }; }); } export function createChatToolRuntime(capabilities: SkillCapability[]): ToolRuntime { return createToolRuntime([ createBrowserOpenAdapter(), createBrowserSkillAdapter(capabilities), createSkillsInstallAdapter(), createSpreadsheetAnalysisAdapter(capabilities), createDocumentAnalysisAdapter(capabilities), createSearchExecutionAdapter(capabilities), createCommandExecutionAdapter(capabilities), createGenericSkillAdapter(capabilities), ]); }