refactor: split skills index follow-up

This commit is contained in:
Shakker
2026-05-29 15:29:20 +01:00
committed by Shakker
parent de83e9eb87
commit efffb42ef9
10 changed files with 4 additions and 824 deletions

View File

@@ -19,7 +19,7 @@ import {
appendUserTurnTranscriptMessage,
type PersistedUserTurnMessage,
} from "../../sessions/user-turn-transcript.js";
import { buildWorkspaceSkillSnapshot } from "../../skills/discovery/service.js";
import { buildWorkspaceSkillSnapshot } from "../../skills/loading/workspace.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { resolveMessageChannel } from "../../utils/message-channel.js";
import { resolveAuthProfileOrder } from "../auth-profiles/order.js";

View File

@@ -1 +0,0 @@
export * from "../../skills/loading/session.js";

View File

@@ -57,7 +57,7 @@ vi.mock("../../skills/runtime/remote.js", () => ({
getRemoteSkillEligibility: getRemoteSkillEligibilityMock,
}));
vi.mock("../../skills/discovery/service.js", () => ({
vi.mock("../../skills/loading/workspace.js", () => ({
buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock,
}));

View File

@@ -239,11 +239,8 @@ vi.mock("../agents/workspace.js", () => ({
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => ({ dir })),
}));
vi.mock("../skills/discovery/service.js", () => ({
buildWorkspaceSkillSnapshot: vi.fn(() => undefined),
}));
vi.mock("../skills/loading/workspace.js", () => ({
buildWorkspaceSkillSnapshot: vi.fn(() => undefined),
loadWorkspaceSkillEntries: vi.fn(() => []),
}));

View File

@@ -1,94 +0,0 @@
import type { Skill } from "../loading/skill-contract.js";
import type { SkillEntry } from "../types.js";
import { resolveSkillTrustInfo, type SkillSourceKind } from "./trust.js";
export type SkillIndexEntry = {
id: string;
name: string;
description: string;
path: string;
baseDir: string;
sourceLabel: string;
sourceKind: SkillSourceKind;
owner: string;
writable: boolean;
writableReason: string;
entry: SkillEntry;
};
export type SkillIndex = {
cacheKey: string;
builtAt: number;
entries: SkillIndexEntry[];
byId: ReadonlyMap<string, SkillIndexEntry>;
byName: ReadonlyMap<string, SkillIndexEntry[]>;
byPath: ReadonlyMap<string, SkillIndexEntry>;
};
export function createSkillId(params: {
sourceKind: SkillSourceKind;
sourceLabel: string;
name: string;
path: string;
}): string {
return `${params.sourceKind}:${params.sourceLabel}:${params.name}:${params.path}`;
}
export function buildSkillIndex(params: {
cacheKey: string;
entries: SkillEntry[];
builtAt?: number;
}): SkillIndex {
const indexed = params.entries.map((entry) => {
const trust = resolveSkillTrustInfo(entry);
return {
id: createSkillId({
sourceKind: trust.sourceKind,
sourceLabel: trust.sourceLabel,
name: entry.skill.name,
path: entry.skill.filePath,
}),
name: entry.skill.name,
description: entry.skill.description,
path: entry.skill.filePath,
baseDir: entry.skill.baseDir,
sourceLabel: trust.sourceLabel,
sourceKind: trust.sourceKind,
owner: trust.owner,
writable: trust.writable,
writableReason: trust.writableReason,
entry,
} satisfies SkillIndexEntry;
});
const byId = new Map<string, SkillIndexEntry>();
const byName = new Map<string, SkillIndexEntry[]>();
const byPath = new Map<string, SkillIndexEntry>();
for (const item of indexed) {
byId.set(item.id, item);
byPath.set(item.path, item);
const named = byName.get(item.name);
if (named) {
named.push(item);
} else {
byName.set(item.name, [item]);
}
}
return {
cacheKey: params.cacheKey,
builtAt: params.builtAt ?? Date.now(),
entries: indexed,
byId,
byName,
byPath,
};
}
export function skillIndexEntries(index: SkillIndex): SkillEntry[] {
return index.entries.map((entry) => entry.entry);
}
export function skillIndexResolvedSkills(index: SkillIndex): Skill[] {
return index.entries.map((entry) => entry.entry.skill);
}

View File

@@ -1,297 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { buildWorkspaceSkillSnapshot as buildLegacyWorkspaceSkillSnapshot } from "../loading/workspace.js";
import { writeWorkspaceSkills } from "../test-support/e2e-test-helpers.js";
import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js";
import type { SkillEntry } from "../types.js";
import { buildSkillIndexCacheKey, buildWorkspaceSkillSnapshot, SkillsService } from "./service.js";
const tempDirs: string[] = [];
async function makeTempWorkspace(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-service-"));
tempDirs.push(dir);
return dir;
}
function isolatedSkillRoots(workspaceDir: string) {
return {
managedSkillsDir: path.join(workspaceDir, ".managed"),
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
pluginSkillsDir: path.join(workspaceDir, ".plugin-skills"),
};
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("SkillsService", () => {
it("does not load an index when the effective skill filter is empty", () => {
type LoadIndexPatch = {
loadIndex: () => never;
};
const service = new SkillsService();
const patch = service as unknown as LoadIndexPatch;
patch.loadIndex = () => {
throw new Error("empty skill filters must not scan skill roots");
};
const config = {
agents: { defaults: { skills: [] } },
} satisfies OpenClawConfig;
const explicit = service.buildSnapshot("/workspace/that/does/not/exist", {
skillFilter: [],
snapshotVersion: 11,
});
const fromConfig = service.buildSnapshot("/workspace/that/does/not/exist", {
config,
agentId: "demo-agent",
snapshotVersion: 12,
});
expect(explicit).toMatchObject({ prompt: "", skills: [], resolvedSkills: [], version: 11 });
expect(fromConfig).toMatchObject({ prompt: "", skills: [], resolvedSkills: [], version: 12 });
});
it("does not apply default skill filters without an agent id", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-default-filter", description: "Default filter workflow" },
]);
const roots = isolatedSkillRoots(workspaceDir);
const config = {
agents: { defaults: { skills: [] } },
} satisfies OpenClawConfig;
const actual = buildWorkspaceSkillSnapshot(workspaceDir, {
...roots,
config,
snapshotVersion: 13,
});
const expected = buildLegacyWorkspaceSkillSnapshot(workspaceDir, {
...roots,
config,
snapshotVersion: 13,
});
expect(actual.prompt).toBe(expected.prompt);
expect(actual.skills).toEqual(expected.skills);
expect(actual.resolvedSkills).toEqual(expected.resolvedSkills);
expect(actual.skills.map((skill) => skill.name)).toContain("service-default-filter");
});
it("builds snapshots from the index without changing prompt output", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "alpha", description: "Alpha workflow" },
{ name: "beta", description: "Beta workflow" },
]);
const service = new SkillsService();
const roots = isolatedSkillRoots(workspaceDir);
const actual = service.buildSnapshot(workspaceDir, {
...roots,
snapshotVersion: 42,
});
const expected = buildLegacyWorkspaceSkillSnapshot(workspaceDir, {
...roots,
snapshotVersion: 42,
});
expect(actual.prompt).toBe(expected.prompt);
expect(actual.skills).toEqual(expected.skills);
expect(actual.resolvedSkills).toEqual(expected.resolvedSkills);
expect(actual.version).toBe(42);
});
it("preserves the preloaded entries snapshot path", () => {
const entry: SkillEntry = {
skill: createCanonicalFixtureSkill({
name: "synthetic-entry-skill",
description: "Synthetic entry",
filePath: "/synthetic/skills/synthetic-entry-skill/SKILL.md",
baseDir: "/synthetic/skills/synthetic-entry-skill",
source: "openclaw-workspace",
}),
frontmatter: {},
};
const snapshot = buildWorkspaceSkillSnapshot("/workspace/that/does/not/exist", {
entries: [entry],
snapshotVersion: 9,
});
expect(snapshot.prompt).toContain("synthetic-entry-skill");
expect(snapshot.skills).toEqual([{ name: "synthetic-entry-skill" }]);
expect(snapshot.resolvedSkills).toEqual([entry.skill]);
expect(snapshot.version).toBe(9);
});
it("caches the source-aware index until the version key changes", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-cache-alpha", description: "Alpha workflow" },
]);
const service = new SkillsService();
const roots = isolatedSkillRoots(workspaceDir);
const first = service.getIndex({ workspaceDir, ...roots, snapshotVersion: 1 });
const second = service.getIndex({ workspaceDir, ...roots, snapshotVersion: 1 });
const third = service.getIndex({ workspaceDir, ...roots, snapshotVersion: 2 });
const firstAgain = service.getIndex({ workspaceDir, ...roots, snapshotVersion: 1 });
expect(second).toBe(first);
expect(third).not.toBe(first);
expect(firstAgain).not.toBe(first);
expect(first.entries.find((entry) => entry.name === "service-cache-alpha")).toMatchObject({
name: "service-cache-alpha",
sourceKind: "workspace",
owner: "workspace",
writable: true,
writableReason: "workspace-owned-skill",
});
});
it("does not cache generation zero because it means no invalidation has happened yet", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-zero-alpha", description: "Alpha workflow" },
]);
const service = new SkillsService();
const roots = isolatedSkillRoots(workspaceDir);
const before = service.getIndex({ workspaceDir, ...roots, snapshotVersion: 0 });
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-zero-beta", description: "Beta workflow" },
]);
const after = service.getIndex({ workspaceDir, ...roots, snapshotVersion: 0 });
expect(after).not.toBe(before);
expect(before.entries.map((entry) => entry.name)).not.toContain("service-zero-beta");
expect(after.entries.map((entry) => entry.name)).toContain("service-zero-beta");
});
it("does not cache explicit indexes when skill watching is disabled", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-watch-alpha", description: "Alpha workflow" },
]);
const service = new SkillsService();
const roots = isolatedSkillRoots(workspaceDir);
const config = { skills: { load: { watch: false } } } satisfies OpenClawConfig;
const before = service.getIndex({ workspaceDir, ...roots, config, snapshotVersion: 1 });
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-watch-beta", description: "Beta workflow" },
]);
const after = service.getIndex({ workspaceDir, ...roots, config, snapshotVersion: 1 });
expect(after).not.toBe(before);
expect(before.entries.map((entry) => entry.name)).not.toContain("service-watch-beta");
expect(after.entries.map((entry) => entry.name)).toContain("service-watch-beta");
});
it("keeps snapshots uncached even when callers pass a positive version", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-snapshot-alpha", description: "Alpha workflow" },
]);
const service = new SkillsService();
const roots = isolatedSkillRoots(workspaceDir);
const before = service.buildSnapshot(workspaceDir, { ...roots, snapshotVersion: 1 });
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-snapshot-beta", description: "Beta workflow" },
]);
const after = service.buildSnapshot(workspaceDir, { ...roots, snapshotVersion: 1 });
expect(before.skills.map((skill) => skill.name)).not.toContain("service-snapshot-beta");
expect(after.skills.map((skill) => skill.name)).toContain("service-snapshot-beta");
});
it("bounds cached indexes across workspace scopes", async () => {
const service = new SkillsService();
const workspaces = await Promise.all(
Array.from({ length: 17 }, async (_, index) => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: `service-lru-${index}`, description: "Cached workflow" },
]);
return workspaceDir;
}),
);
const firstWorkspace = workspaces[0];
const first = service.getIndex({
workspaceDir: firstWorkspace,
...isolatedSkillRoots(firstWorkspace),
snapshotVersion: 1,
});
for (const workspaceDir of workspaces.slice(1)) {
service.getIndex({
workspaceDir,
...isolatedSkillRoots(workspaceDir),
snapshotVersion: 1,
});
}
const firstAgain = service.getIndex({
workspaceDir: firstWorkspace,
...isolatedSkillRoots(firstWorkspace),
snapshotVersion: 1,
});
expect(firstAgain).not.toBe(first);
});
it("includes plugin config in the versioned cache key", async () => {
const workspaceDir = await makeTempWorkspace();
const roots = isolatedSkillRoots(workspaceDir);
const enabledConfig = {
plugins: { entries: { demo: { enabled: true } } },
} satisfies OpenClawConfig;
const disabledConfig = {
plugins: { entries: { demo: { enabled: false } } },
} satisfies OpenClawConfig;
const enabledKey = buildSkillIndexCacheKey({
workspaceDir,
...roots,
config: enabledConfig,
snapshotVersion: 1,
});
const disabledKey = buildSkillIndexCacheKey({
workspaceDir,
...roots,
config: disabledConfig,
snapshotVersion: 1,
});
expect(enabledKey).not.toBe(disabledKey);
});
it("keeps legacy snapshot calls uncached unless a version is supplied", async () => {
const workspaceDir = await makeTempWorkspace();
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-legacy-alpha", description: "Alpha workflow" },
]);
const service = new SkillsService();
const roots = isolatedSkillRoots(workspaceDir);
const before = service.buildSnapshot(workspaceDir, roots);
await writeWorkspaceSkills(workspaceDir, [
{ name: "service-legacy-beta", description: "Beta workflow" },
]);
const after = service.buildSnapshot(workspaceDir, roots);
const beforeNames = before.skills.map((skill) => skill.name);
const afterNames = after.skills.map((skill) => skill.name);
expect(beforeNames).toContain("service-legacy-alpha");
expect(beforeNames).not.toContain("service-legacy-beta");
expect(afterNames).toContain("service-legacy-alpha");
expect(afterNames).toContain("service-legacy-beta");
});
});

View File

@@ -1,253 +0,0 @@
import crypto from "node:crypto";
import path from "node:path";
import { stableStringify } from "../../agents/stable-stringify.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import {
buildWorkspaceSkillSnapshot as buildWorkspaceSkillSnapshotFromEntries,
loadWorkspaceSkillEntries,
} from "../loading/workspace.js";
import { getSkillsSnapshotVersion } from "../runtime/refresh-state.js";
import type { SkillEligibilityContext, SkillEntry, SkillSnapshot } from "../types.js";
import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js";
import { normalizeSkillFilter } from "./filter.js";
import { buildSkillIndex, skillIndexEntries, type SkillIndex } from "./registry.js";
const MAX_SKILL_INDEX_CACHE_ENTRIES = 16;
export type SkillIndexRequest = {
workspaceDir: string;
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
pluginSkillsDir?: string;
snapshotVersion?: number;
};
export type SkillSnapshotBuildOptions = {
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
pluginSkillsDir?: string;
entries?: SkillEntry[];
agentId?: string;
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
snapshotVersion?: number;
};
export class SkillsService {
private readonly cache = new Map<string, SkillIndex>();
private readonly cacheScopes = new Map<string, string>();
getIndex(request: SkillIndexRequest): SkillIndex {
const snapshotVersion =
request.snapshotVersion ?? getSkillsSnapshotVersion(request.workspaceDir);
if (!shouldCacheSkillIndex(request, snapshotVersion)) {
return this.loadIndex(request, buildUncachedSkillIndexCacheKey(request, snapshotVersion));
}
const cacheKeyParts = buildSkillIndexCacheKeyParts(request, snapshotVersion);
const cacheKey = stringifyCacheKeyParts(cacheKeyParts);
const cached = this.cache.get(cacheKey);
if (cached) {
this.cache.delete(cacheKey);
this.cache.set(cacheKey, cached);
return cached;
}
const index = this.loadIndex(request, cacheKey);
this.pruneScope(cacheKeyParts.scope, cacheKey);
this.cache.set(cacheKey, index);
this.cacheScopes.set(cacheKey, cacheKeyParts.scope);
this.pruneCapacity();
return index;
}
private loadIndex(request: SkillIndexRequest, cacheKey: string): SkillIndex {
const entries = loadWorkspaceSkillEntries(request.workspaceDir, {
config: request.config,
managedSkillsDir: request.managedSkillsDir,
bundledSkillsDir: request.bundledSkillsDir,
pluginSkillsDir: request.pluginSkillsDir,
});
return buildSkillIndex({ cacheKey, entries });
}
buildSnapshot(workspaceDir: string, opts?: SkillSnapshotBuildOptions): SkillSnapshot {
if (opts?.entries || hasEmptyEffectiveSkillFilter(opts)) {
return buildWorkspaceSkillSnapshotFromEntries(workspaceDir, opts);
}
const request = {
workspaceDir,
config: opts?.config,
managedSkillsDir: opts?.managedSkillsDir,
bundledSkillsDir: opts?.bundledSkillsDir,
pluginSkillsDir: opts?.pluginSkillsDir,
snapshotVersion: opts?.snapshotVersion,
};
const snapshotVersion = request.snapshotVersion ?? getSkillsSnapshotVersion(workspaceDir);
const index = this.loadIndex(
request,
buildUncachedSkillIndexCacheKey(request, snapshotVersion),
);
return buildSkillSnapshotFromIndex(workspaceDir, index, opts);
}
invalidate(): void {
this.cache.clear();
this.cacheScopes.clear();
}
private pruneScope(scope: string, keepKey: string): void {
for (const [key, cachedScope] of this.cacheScopes) {
if (key === keepKey || cachedScope !== scope) {
continue;
}
this.cache.delete(key);
this.cacheScopes.delete(key);
}
}
private pruneCapacity(): void {
while (this.cache.size > MAX_SKILL_INDEX_CACHE_ENTRIES) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey === undefined) {
break;
}
this.cache.delete(oldestKey);
this.cacheScopes.delete(oldestKey);
}
}
}
export const skillsService = new SkillsService();
export function buildSkillSnapshotFromIndex(
workspaceDir: string,
index: SkillIndex,
opts?: SkillSnapshotBuildOptions,
): SkillSnapshot {
return buildWorkspaceSkillSnapshotFromEntries(workspaceDir, {
entries: skillIndexEntries(index),
config: opts?.config,
agentId: opts?.agentId,
skillFilter: opts?.skillFilter,
eligibility: opts?.eligibility,
snapshotVersion: opts?.snapshotVersion,
});
}
export function buildWorkspaceSkillSnapshot(
workspaceDir: string,
opts?: SkillSnapshotBuildOptions,
): SkillSnapshot {
return skillsService.buildSnapshot(workspaceDir, opts);
}
function stableHash(value: unknown): string {
return crypto.createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
}
function normalizedOptionalPath(value?: string): string {
return value ? path.resolve(value) : "";
}
function hasEmptyEffectiveSkillFilter(opts?: SkillSnapshotBuildOptions): boolean {
if (opts?.skillFilter !== undefined) {
const filter = normalizeSkillFilter(opts.skillFilter);
return filter !== undefined && filter.length === 0;
}
if (!opts?.config || !opts.agentId) {
return false;
}
const filter = resolveEffectiveAgentSkillFilter(opts.config, opts.agentId);
return filter !== undefined && filter.length === 0;
}
export function buildSkillIndexCacheKey(request: SkillIndexRequest): string {
const snapshotVersion = request.snapshotVersion ?? getSkillsSnapshotVersion(request.workspaceDir);
return stringifyCacheKeyParts(buildSkillIndexCacheKeyParts(request, snapshotVersion));
}
type SkillIndexCacheKeyParts = {
scope: string;
snapshotVersion: number;
};
function shouldCacheSkillIndex(
request: SkillIndexRequest,
snapshotVersion: number | undefined,
): boolean {
return (
typeof snapshotVersion === "number" &&
snapshotVersion > 0 &&
request.config?.skills?.load?.watch !== false
);
}
function buildUncachedSkillIndexCacheKey(
request: SkillIndexRequest,
snapshotVersion: number,
): string {
return stableStringify({
uncached: true,
workspaceDir: path.resolve(request.workspaceDir),
snapshotVersion,
});
}
function buildSkillIndexCacheKeyParts(
request: SkillIndexRequest,
snapshotVersion: number,
): SkillIndexCacheKeyParts {
const scope = stableStringify({
workspaceDir: path.resolve(request.workspaceDir),
managedSkillsDir: normalizedOptionalPath(request.managedSkillsDir),
bundledSkillsDir: normalizedOptionalPath(request.bundledSkillsDir),
pluginSkillsDir: normalizedOptionalPath(request.pluginSkillsDir),
config: stableHash(request.config ?? {}),
pluginDiscovery: resolvePluginSkillDiscoveryFingerprint(request),
});
return { scope, snapshotVersion };
}
function stringifyCacheKeyParts(parts: SkillIndexCacheKeyParts): string {
return stableStringify(parts);
}
function resolvePluginSkillDiscoveryFingerprint(request: SkillIndexRequest): string {
const snapshot = resolvePluginMetadataSnapshot({
workspaceDir: request.workspaceDir,
config: request.config ?? {},
env: process.env,
allowWorkspaceScopedCurrent: true,
});
return stableHash({
policyHash: snapshot.policyHash,
configFingerprint: snapshot.configFingerprint ?? null,
registrySource: snapshot.registrySource ?? null,
index: {
hostContractVersion: snapshot.index.hostContractVersion,
compatRegistryVersion: snapshot.index.compatRegistryVersion,
migrationVersion: snapshot.index.migrationVersion,
policyHash: snapshot.index.policyHash,
installRecords: snapshot.index.installRecords,
plugins: snapshot.index.plugins.map((plugin) => ({
pluginId: plugin.pluginId,
enabled: plugin.enabled,
enabledByDefault: plugin.enabledByDefault ?? null,
manifestHash: plugin.manifestHash,
manifestPath: plugin.manifestPath,
origin: plugin.origin,
rootDir: plugin.rootDir,
})),
},
manifestPlugins: snapshot.manifestRegistry.plugins.map((plugin) => ({
id: plugin.id,
enabledByDefault: plugin.enabledByDefault ?? null,
kind: plugin.kind ?? null,
origin: plugin.origin,
rootDir: plugin.rootDir,
skills: plugin.skills,
})),
});
}

View File

@@ -1,107 +0,0 @@
import path from "node:path";
import { resolveSkillSource } from "../loading/source.js";
import type { SkillEntry } from "../types.js";
export type SkillSourceKind =
| "workspace"
| "generated"
| "bundled"
| "clawhub"
| "plugin"
| "extra"
| "system";
export type SkillWritablePolicy = {
writable: boolean;
reason: string;
};
export type SkillTrustInfo = {
sourceLabel: string;
sourceKind: SkillSourceKind;
owner: string;
writable: boolean;
writableReason: string;
};
export function classifySkillSourceKind(sourceLabel: string): SkillSourceKind {
switch (sourceLabel) {
case "openclaw-workspace":
case "agents-skills-project":
return "workspace";
case "openclaw-managed":
return "clawhub";
case "openclaw-bundled":
return "bundled";
case "agents-skills-personal":
case "openclaw-extra":
return "extra";
default:
return "extra";
}
}
export function resolveSkillOwner(params: {
sourceKind: SkillSourceKind;
sourceLabel: string;
skillPath: string;
}): string {
if (params.sourceKind === "workspace") {
return "workspace";
}
if (params.sourceKind === "generated") {
return "workspace";
}
if (params.sourceKind === "bundled") {
return "openclaw-release";
}
if (params.sourceKind === "clawhub") {
return "clawhub";
}
if (params.sourceKind === "plugin") {
return "plugin";
}
if (params.sourceKind === "system") {
return "openclaw-system";
}
if (params.sourceLabel === "agents-skills-personal") {
return "user";
}
return path.basename(path.dirname(params.skillPath)) || "extra";
}
export function resolveSkillWritablePolicy(sourceKind: SkillSourceKind): SkillWritablePolicy {
switch (sourceKind) {
case "workspace":
case "generated":
return { writable: true, reason: "workspace-owned-skill" };
case "bundled":
return { writable: false, reason: "release-owned-skill" };
case "clawhub":
return { writable: false, reason: "installer-owned-skill" };
case "plugin":
return { writable: false, reason: "plugin-owned-skill" };
case "system":
return { writable: false, reason: "system-owned-skill" };
case "extra":
return { writable: false, reason: "extra-root-load-only" };
}
return { writable: false, reason: "unknown-source-load-only" };
}
export function resolveSkillTrustInfo(entry: SkillEntry): SkillTrustInfo {
const sourceLabel = resolveSkillSource(entry.skill);
const sourceKind = classifySkillSourceKind(sourceLabel);
const writable = resolveSkillWritablePolicy(sourceKind);
return {
sourceLabel,
sourceKind,
owner: resolveSkillOwner({
sourceKind,
sourceLabel,
skillPath: entry.skill.filePath,
}),
writable: writable.writable,
writableReason: writable.reason,
};
}

View File

@@ -1,65 +0,0 @@
export {
hasBinary,
isBundledSkillAllowed,
isConfigPathTruthy,
resolveBundledAllowlist,
resolveConfigPath,
resolveRuntimePlatform,
resolveSkillConfig,
resolveSkillsInstallPreferences,
} from "./loading/config.js";
export {
applySkillEnvOverrides,
applySkillEnvOverridesFromSnapshot,
} from "./runtime/env-overrides.js";
export type {
OpenClawSkillMetadata,
SkillEligibilityContext,
SkillCommandSpec,
SkillEntry,
SkillInstallSpec,
SkillSnapshot,
SkillTelemetrySource,
SkillsInstallPreferences,
} from "./types.js";
export {
buildWorkspaceSkillsPrompt,
filterWorkspaceSkillEntries,
filterWorkspaceSkillEntriesWithOptions,
loadWorkspaceSkillEntries,
resolveSkillsPromptForRun,
syncSkillsToWorkspace,
} from "./loading/workspace.js";
export { buildWorkspaceSkillCommandSpecs } from "./discovery/command-specs.js";
export type {
LoadSkillsFromDirOptions,
LoadSkillsOptions,
LoadSkillsResult,
Skill,
SkillFrontmatter,
} from "./loading/session.js";
export {
formatSkillsForPrompt as formatSessionSkillsForPrompt,
loadSkills,
} from "./loading/session.js";
export type { SkillIndex, SkillIndexEntry } from "./discovery/registry.js";
export {
buildSkillIndex,
skillIndexEntries,
skillIndexResolvedSkills,
} from "./discovery/registry.js";
export type { SkillSourceKind, SkillTrustInfo, SkillWritablePolicy } from "./discovery/trust.js";
export {
classifySkillSourceKind,
resolveSkillOwner,
resolveSkillTrustInfo,
resolveSkillWritablePolicy,
} from "./discovery/trust.js";
export type { SkillIndexRequest, SkillSnapshotBuildOptions } from "./discovery/service.js";
export {
SkillsService,
buildSkillIndexCacheKey,
buildSkillSnapshotFromIndex,
buildWorkspaceSkillSnapshot,
skillsService,
} from "./discovery/service.js";

View File

@@ -3,7 +3,7 @@ import { stableStringify } from "../../agents/stable-stringify.js";
import { redactConfigObject } from "../../config/redact-snapshot.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { matchesSkillFilter } from "../discovery/filter.js";
import { buildWorkspaceSkillSnapshot } from "../discovery/service.js";
import { buildWorkspaceSkillSnapshot } from "../loading/workspace.js";
import type { SkillEligibilityContext, SkillSnapshot } from "../types.js";
import { getSkillsSnapshotVersion, shouldRefreshSnapshotForVersion } from "./refresh-state.js";
import { ensureSkillsWatcher } from "./refresh.js";