UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

163 lines (162 loc) 7.87 kB
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js"; import { n as createSyntheticSourceInfo } from "./source-info-CcFiWAof.js"; import { n as resolveSkillInvocationPolicy, t as parseSkillFrontmatter } from "./frontmatter-CrPBFeiv.js"; import { t as bumpSkillsSnapshotVersion } from "./refresh-state-DHnXO3IV.js"; import { r as resolveNodeIdFromNodeList } from "./node-resolve-CB9uOmem.js"; //#region src/skills/runtime/remote-skills.ts const remoteSkillNodes = /* @__PURE__ */ new Map(); const log = createSubsystemLogger("gateway/skills-remote"); let reconcileRemoteSkillConnections = null; function remoteConnectionKey(nodeId, connId) { return `${nodeId}\0${connId}`; } /** Installs the gateway-owned persistent-generation reconciliation boundary. */ function setRemoteSkillConnectionReconciler(reconcile) { reconcileRemoteSkillConnections = reconcile; } function prepareNodeSkills(nodeId, skills) { const prepared = []; for (const skill of skills) try { const frontmatter = parseSkillFrontmatter(skill.content); if (frontmatter.name?.trim() !== skill.name || frontmatter.description?.trim() !== skill.description) { log.warn(`dropped node skill with mismatched frontmatter: ${nodeId}/${skill.name}`); continue; } prepared.push({ ...skill, frontmatter }); } catch (error) { const filePath = `node://${encodeURIComponent(nodeId)}/skills/${skill.name}/SKILL.md`; log.warn(`dropped node skill with invalid frontmatter (${filePath}): ${String(error)}`); } return prepared; } function sameSkills(left, right) { return left.length === right.length && left.every((skill, index) => skill.name === right[index]?.name && skill.description === right[index]?.description && skill.content === right[index]?.content); } function recordRemoteSkillNodeInfo(node) { const existing = remoteSkillNodes.get(node.nodeId); const connectionChanged = Boolean(node.connId && existing?.connId !== node.connId); const displayChanged = existing?.displayName !== node.displayName; const canExec = node.commands?.includes("system.run") ?? existing?.canExec ?? false; const executionChanged = existing?.canExec !== canExec; remoteSkillNodes.set(node.nodeId, { nodeId: node.nodeId, connId: node.connId ?? existing?.connId, displayName: node.displayName, connected: true, canExec, skills: connectionChanged ? [] : existing?.skills ?? [] }); if ((connectionChanged || displayChanged || executionChanged) && (existing?.skills.length ?? 0) > 0) bumpSkillsSnapshotVersion({ reason: "remote-node" }); } function replaceRemoteNodeSkills(params) { const nextSkills = prepareNodeSkills(params.nodeId, params.skills); const existing = remoteSkillNodes.get(params.nodeId); const changed = !existing?.connected || existing.displayName !== params.displayName || !sameSkills(existing.skills, nextSkills); remoteSkillNodes.set(params.nodeId, { nodeId: params.nodeId, connId: existing?.connId, displayName: params.displayName ?? existing?.displayName, connected: true, canExec: existing?.canExec ?? false, skills: nextSkills }); if (changed) bumpSkillsSnapshotVersion({ reason: "remote-node" }); } function removeRemoteNodeSkills(nodeId) { const existing = remoteSkillNodes.get(nodeId); remoteSkillNodes.delete(nodeId); if (existing?.skills.length) bumpSkillsSnapshotVersion({ reason: "remote-node" }); } function sanitizeSkillNameFragment(value) { return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24).replace(/-+$/g, "") || "node"; } function prefixedSkillName(params) { const prefix = `${sanitizeSkillNameFragment(params.nodeId)}-`; for (let index = 0; index < 100; index += 1) { const suffix = index === 0 ? "" : `-${index + 1}`; const availableBaseLength = Math.max(1, 64 - prefix.length - suffix.length); const candidate = `${prefix}${params.baseName.slice(0, availableBaseLength).replace(/-+$/g, "") || "skill"}${suffix}`; if (!params.usedNames.has(candidate)) return candidate; } return null; } function remoteSkillLocation(nodeId, name) { return `node://${encodeURIComponent(nodeId)}/skills/${name}/SKILL.md`; } function locatorNote(node, skillName) { const label = node.displayName?.trim() || node.nodeId; const cwd = remoteSkillLocation(node.nodeId, skillName).slice(0, -9); return `Node-hosted on ${label} (${node.nodeId}). Read this SKILL.md with the normal read tool at its exact node:// location; do not use file_fetch, which only accepts approved absolute node paths. If read is unavailable, use exec host=node node=${node.nodeId} with workdir=${cwd} to run cat SKILL.md. Run referenced files and bins with the same exec target and workdir; the node host resolves that locator to the node-local skill directory.`; } function mergeRemoteNodeSkillEntries(localEntries, options) { if (options?.canExec !== true) return [...localEntries]; const currentConnections = reconcileRemoteSkillConnections?.(); const connectedNodes = [...remoteSkillNodes.values()].filter((node) => node.connected && node.canExec && (!currentConnections || node.connId !== void 0 && currentConnections.has(remoteConnectionKey(node.nodeId, node.connId)))); let boundNodeId; if (options.node) try { boundNodeId = resolveNodeIdFromNodeList(connectedNodes, options.node); } catch { return [...localEntries]; } const remote = connectedNodes.filter((node) => !boundNodeId || node.nodeId === boundNodeId).flatMap((node) => node.skills.map((skill) => ({ node, skill }))).toSorted((left, right) => left.skill.name.localeCompare(right.skill.name, "en") || left.node.nodeId.localeCompare(right.node.nodeId, "en")); if (remote.length === 0) return [...localEntries]; const remoteNameCounts = /* @__PURE__ */ new Map(); for (const { skill } of remote) remoteNameCounts.set(skill.name, (remoteNameCounts.get(skill.name) ?? 0) + 1); const localNames = new Set(localEntries.map((entry) => entry.skill.name)); const usedNames = new Set(localNames); const remoteEntries = []; for (const { node, skill } of remote) { const exposedName = usedNames.has(skill.name) || (remoteNameCounts.get(skill.name) ?? 0) > 1 ? prefixedSkillName({ nodeId: node.nodeId, baseName: skill.name, usedNames }) : skill.name; if (!exposedName || usedNames.has(exposedName)) { log.warn(`dropped node skill with unresolved name collision: ${node.nodeId}/${skill.name}`); continue; } usedNames.add(exposedName); const filePath = remoteSkillLocation(node.nodeId, skill.name); const invocation = resolveSkillInvocationPolicy(skill.frontmatter); remoteEntries.push({ skill: { name: exposedName, description: skill.description, locationNote: locatorNote(node, skill.name), readContent: skill.content, filePath, baseDir: filePath.slice(0, -9), source: "openclaw-node", sourceInfo: createSyntheticSourceInfo(filePath, { source: "openclaw-node", scope: "temporary", origin: "top-level", baseDir: filePath.slice(0, -9) }), disableModelInvocation: invocation.disableModelInvocation }, frontmatter: skill.frontmatter, invocation, disableCommandDispatch: true, exposure: { includeInRuntimeRegistry: true, includeInAvailableSkillsPrompt: !invocation.disableModelInvocation, userInvocable: invocation.userInvocable } }); } return [...localEntries, ...remoteEntries].toSorted((left, right) => left.skill.name.localeCompare(right.skill.name, "en")); } function resetRemoteNodeSkillsForTests() { remoteSkillNodes.clear(); reconcileRemoteSkillConnections = null; } if (process.env.VITEST || false) globalThis[Symbol.for("openclaw.remoteNodeSkillsTestApi")] = { resetRemoteNodeSkillsForTests }; //#endregion export { setRemoteSkillConnectionReconciler as a, replaceRemoteNodeSkills as i, recordRemoteSkillNodeInfo as n, removeRemoteNodeSkills as r, mergeRemoteNodeSkillEntries as t };