mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 05:22:56 +00:00
refactor: split skills index follow-up
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../skills/loading/session.js";
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(() => []),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user