- 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.
3572 lines
110 KiB
TypeScript
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(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, '\'')
|
|
.replace(/'/g, '\'')
|
|
.replace(/&/g, '&');
|
|
}
|
|
|
|
function normalizePlainText(value: string): string {
|
|
return decodeXmlEntities(value)
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function truncateText(value: string, maxLength = 280): string {
|
|
const normalized = normalizePlainText(value);
|
|
if (normalized.length <= maxLength) {
|
|
return normalized;
|
|
}
|
|
|
|
return `${normalized.slice(0, maxLength - 3).trim()}...`;
|
|
}
|
|
|
|
function countWords(value: string): number {
|
|
const normalized = normalizePlainText(value);
|
|
if (!normalized) {
|
|
return 0;
|
|
}
|
|
|
|
return normalized.split(/\s+/).filter(Boolean).length;
|
|
}
|
|
|
|
function toRecordArray(value: unknown): Record<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),
|
|
]);
|
|
}
|