Files
zn-ai/electron/gateway/chat-tooling.ts
DEV_DSW 4c61e93c3e Add unit tests for skill capabilities, skill planner, and UV setup
- Implement tests for random ID generation, ensuring preference for crypto.randomUUID.
- Create tests for runtime context capabilities, validating the injection of enabled skill capabilities.
- Add tests for skill capability parsing, including classification and command example extraction.
- Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements.
- Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
2026-04-24 17:02:59 +08:00

3572 lines
110 KiB
TypeScript

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<T>(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<NodeFsLike>('fs');
const nodePath = getBuiltinNodeModule<NodePathLike>('path');
const nodeChildProcess = getBuiltinNodeModule<NodeChildProcessLike>('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<string, unknown>;
};
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<string, unknown>;
preview: Array<Record<string, unknown>>;
};
type SkillExecutionConfig = {
apiKey?: string;
env: Record<string, string>;
};
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<string, unknown>;
};
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<string, string> = {
day: 'pd',
week: 'pw',
month: 'pm',
year: 'py',
};
const TAVILY_FRESHNESS_TO_TIME_RANGE: Record<string, string> = {
pd: 'day',
pw: 'week',
pm: 'month',
py: 'year',
};
function dedupeStrings(values: Array<string | undefined | null>): 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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
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, unknown>): 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, unknown>): string | undefined {
return getTrimmedString(input.command);
}
function getInputArgs(input: GenericSkillInput & Record<string, unknown>): 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<string, AttachedFileMeta>();
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<string, AttachedFileMeta>();
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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, '\'')
.replace(/&apos;/g, '\'')
.replace(/&amp;/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<string, unknown>[] {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> => 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<Record<string, unknown>> {
const { spawn } = await import('child_process');
let lastError: Error | null = null;
for (const candidate of PYTHON_COMMAND_CANDIDATES) {
try {
const result = await new Promise<Record<string, unknown>>((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<string, unknown>);
} 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<Record<string, unknown>>, columns: string[]): Array<Record<string, unknown>> {
return rows.map((row) => {
const normalized: Record<string, unknown> = {};
for (const column of columns) {
normalized[column] = toSerializableSpreadsheetValue(row[column]);
}
return normalized;
});
}
function getSpreadsheetColumns(rows: Array<Record<string, unknown>>): string[] {
const seen = new Set<string>();
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<Record<string, unknown>>,
columns: string[],
): Record<string, { count: number; pct: number }> {
const result: Record<string, { count: number; pct: number }> = {};
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<Record<string, unknown>>,
columns: string[],
): Record<string, string> {
const result: Record<string, string> = {};
for (const column of columns) {
const types = new Set<string>();
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<Record<string, unknown>>,
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<Record<string, unknown>>,
columns: string[],
): Record<string, Record<string, number>> {
const stats: Record<string, Record<string, number>> = {};
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<Record<string, unknown>>,
columns: string[],
): Array<Record<string, unknown>> {
const findings: Array<Record<string, unknown>> = [];
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<Record<string, unknown>> {
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<string, unknown> = {};
const quality: Record<string, unknown> = {};
const stats: Record<string, unknown> = {};
let totalRows = 0;
for (const sheetName of workbook.SheetNames) {
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) {
continue;
}
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(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<Record<string, unknown>> {
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<string> } | null }, entryName: string): Promise<string | null> {
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 = /<w:p\b[\s\S]*?<\/w:p>/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:pStyle\b[^>]*w:val="Heading([1-9])"/i)
|| block.match(/<w:pStyle\b[^>]*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<DocumentAnalysisReport> {
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(/<w:tbl\b/gi)).length;
const bodyXml = documentXml.replace(/<w:tbl\b[\s\S]*?<\/w:tbl>/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(/<w:comment\b/gi)).length : 0;
const metadata: Record<string, unknown> = {
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<DocumentAnalysisReport> {
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<Record<string, unknown>> = [];
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(/<p:sldId\b[^>]*show="0"/gi)).length
: 0;
const title = slidePreview[0] && typeof slidePreview[0].text === 'string'
? slidePreview[0].text
: undefined;
const metadata: Record<string, unknown> = {
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<DocumentAnalysisReport> {
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<string, unknown>) => { promise: Promise<any>; destroy?: () => Promise<void> } }).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<Record<string, unknown>> = [];
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<string, unknown> = {
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<DocumentAnalysisReport> {
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<string, unknown> {
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<SkillExecutionConfig> {
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<Record<string, unknown>> {
const text = await response.text();
if (!text.trim()) {
return {};
}
try {
return JSON.parse(text) as Record<string, unknown>;
} catch {
throw new Error(`Failed to parse JSON response (HTTP ${response.status}).`);
}
}
async function throwSearchResponseError(response: Response, providerLabel: string): Promise<never> {
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<SearchExecutionReport> {
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<SearchExecutionReport> {
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<string, unknown> = {
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<string, unknown> {
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<Record<string, unknown>> {
const { openUrlInBrowser } = await import('@electron/service/browser-open-service');
return openUrlInBrowser(url, { signal }) as Promise<Record<string, unknown>>;
}
async function executeClawHubSearch(
capability: SkillCapability,
query: string,
limit: number,
): Promise<SearchExecutionReport> {
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<string, unknown>,
): 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<ToolRuntimePreflightResult> {
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<ToolRuntimeExecutionResult> {
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<ToolRuntimePreflightResult> {
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<ToolRuntimeExecutionResult> {
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<ToolRuntimePreflightResult> {
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<ToolRuntimeExecutionResult> {
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<ToolRuntimePreflightResult> {
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<ToolRuntimeExecutionResult> {
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<ToolRuntimePreflightResult> {
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<string, unknown>;
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<ToolRuntimeExecutionResult> {
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<ToolRuntimePreflightResult> {
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<string, unknown>;
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<ToolRuntimeExecutionResult> {
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<ToolRuntimePreflightResult> {
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<string, unknown>;
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<ToolRuntimeExecutionResult> {
const resolved = findCommandExecutionCapability(capabilities, invocation.toolName);
const input = ensureRecord(invocation.input) as GenericSkillInput & Record<string, unknown>;
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<ToolRuntimePreflightResult> {
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<string, unknown>;
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<ToolRuntimeExecutionResult> {
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),
]);
}