diff --git a/apps/memos-local-openclaw/src/skill/__tests__/evolver.test.ts b/apps/memos-local-openclaw/src/skill/__tests__/evolver.test.ts new file mode 100644 index 000000000..31dd7abef --- /dev/null +++ b/apps/memos-local-openclaw/src/skill/__tests__/evolver.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { SkillEvolver } from "../evolver"; +import type { SqliteStore } from "../../storage/sqlite"; +import type { RecallEngine } from "../../recall/engine"; +import type { PluginContext, Skill } from "../../types"; + +describe("SkillEvolver - autoInstall configuration", () => { + let mockStore: SqliteStore; + let mockEngine: RecallEngine; + let mockContext: PluginContext; + let evolver: SkillEvolver; + + beforeEach(() => { + mockStore = { + getSkill: vi.fn(), + updateSkill: vi.fn(), + setTaskSkillMeta: vi.fn(), + getTasksBySkillStatus: vi.fn(() => []), + getChunksByTask: vi.fn(() => []), + setChunkSkillId: vi.fn(), + } as any; + + mockEngine = {} as RecallEngine; + + mockContext = { + workspaceDir: "/tmp/test-workspace", + config: {}, + log: { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, + } as any; + + evolver = new SkillEvolver(mockStore, mockEngine, mockContext); + }); + + it("should NOT auto-install when autoInstall is false, even for install_recommended skills", () => { + // Setup: autoInstall explicitly disabled + mockContext.config.skillEvolution = { + enabled: true, + autoInstall: false, + }; + + // Create a skill that would trigger install_recommended + // (≥3 scripts, >20KB total size) + const skill: Skill = { + id: "test-skill-1", + name: "test-skill", + status: "active", + version: 1, + dirPath: "/tmp/skills/test-skill", + installed: 0, + description: "Test skill with many companion files", + chunks: 10, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + // Mock the installer's install method + const installSpy = vi.fn(); + (evolver as any).installer = { + install: installSpy, + }; + + // Call autoInstallIfNeeded + (evolver as any).autoInstallIfNeeded(skill); + + // Assert: install should NOT be called when autoInstall is false + expect(installSpy).not.toHaveBeenCalled(); + }); + + it("should auto-install when autoInstall is true", () => { + // Setup: autoInstall enabled + mockContext.config.skillEvolution = { + enabled: true, + autoInstall: true, + }; + + const skill: Skill = { + id: "test-skill-2", + name: "test-skill-2", + status: "active", + version: 1, + dirPath: "/tmp/skills/test-skill-2", + installed: 0, + description: "Test skill", + chunks: 5, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const installSpy = vi.fn(); + (evolver as any).installer = { + install: installSpy, + }; + + // Call autoInstallIfNeeded + (evolver as any).autoInstallIfNeeded(skill); + + // Assert: install should be called when autoInstall is true + expect(installSpy).toHaveBeenCalledWith("test-skill-2"); + }); + + it("should NOT auto-install when skill status is not active", () => { + mockContext.config.skillEvolution = { + enabled: true, + autoInstall: true, + }; + + const skill: Skill = { + id: "test-skill-3", + name: "test-skill-3", + status: "draft", + version: 1, + dirPath: "/tmp/skills/test-skill-3", + installed: 0, + description: "Draft skill", + chunks: 5, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const installSpy = vi.fn(); + (evolver as any).installer = { + install: installSpy, + }; + + (evolver as any).autoInstallIfNeeded(skill); + + // Assert: install should NOT be called for non-active skills + expect(installSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/memos-local-openclaw/src/skill/evolver.ts b/apps/memos-local-openclaw/src/skill/evolver.ts index 42516e8b0..fc8d319cf 100644 --- a/apps/memos-local-openclaw/src/skill/evolver.ts +++ b/apps/memos-local-openclaw/src/skill/evolver.ts @@ -370,17 +370,14 @@ Use selectedIndex 0 when none is highly relevant.`; if (skill.status !== "active") return; const explicitAutoInstall = this.ctx.config.skillEvolution?.autoInstall ?? DEFAULTS.skillAutoInstall; - if (explicitAutoInstall) { - this.installer.install(skill.id); - this.ctx.log.info(`SkillEvolver: auto-installed "${skill.name}" (explicit autoInstall=true)`); + if (!explicitAutoInstall) { + this.ctx.log.debug(`SkillEvolver: skipping auto-install for "${skill.name}" (autoInstall=false)`); return; } + this.installer.install(skill.id); const manifest = SkillInstaller.buildManifest(skill.dirPath, !!skill.installed, skill.name); - if (manifest.installMode === "install_recommended") { - this.installer.install(skill.id); - this.ctx.log.info(`SkillEvolver: auto-installed "${skill.name}" (install_recommended: ${manifest.scriptsCount} scripts, ${Math.round(manifest.totalSize / 1024)}KB)`); - } + this.ctx.log.info(`SkillEvolver: auto-installed "${skill.name}" (autoInstall=true, mode=${manifest.installMode}, ${manifest.scriptsCount} scripts, ${Math.round(manifest.totalSize / 1024)}KB)`); } private readSkillContent(skill: Skill): string | null { diff --git a/apps/memos-local-openclaw/tests/skill-auto-install.test.ts b/apps/memos-local-openclaw/tests/skill-auto-install.test.ts new file mode 100644 index 000000000..6fdbb19f5 --- /dev/null +++ b/apps/memos-local-openclaw/tests/skill-auto-install.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { SqliteStore } from "../src/storage/sqlite"; +import { SkillEvolver } from "../src/skill/evolver"; +import { RecallEngine } from "../src/recall/engine"; +import type { Logger, PluginContext, MemosLocalConfig, Task } from "../src/types"; + +const noopLog: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +let tmpDir: string; +let store: SqliteStore; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-autoinstall-")); + const dbPath = path.join(tmpDir, "memos.db"); + store = new SqliteStore(dbPath, noopLog); +}); + +afterEach(() => { + store.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("SkillEvolver autoInstall behavior", () => { + it("should NOT auto-install install_recommended skills when autoInstall=false", async () => { + const ctx: PluginContext = { + stateDir: tmpDir, + workspaceDir: tmpDir, + config: { + skillEvolution: { + enabled: true, + autoInstall: false, + autoEvaluate: false, + }, + } as MemosLocalConfig, + log: noopLog, + }; + + // Create a skill with install_recommended characteristics (3+ scripts) + const skillDir = path.join(tmpDir, "skills-repo", "deploy-automation"); + const scriptsDir = path.join(skillDir, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + + fs.writeFileSync(path.join(skillDir, "SKILL.md"), `--- +name: "deploy-automation" +description: "Automated deployment scripts" +version: 1 +--- + +## Steps +1. Run deploy scripts +`, "utf-8"); + + // Create 3 scripts to trigger install_recommended + fs.writeFileSync(path.join(scriptsDir, "deploy.sh"), "#!/bin/bash\necho deploy", "utf-8"); + fs.writeFileSync(path.join(scriptsDir, "rollback.sh"), "#!/bin/bash\necho rollback", "utf-8"); + fs.writeFileSync(path.join(scriptsDir, "health-check.sh"), "#!/bin/bash\necho check", "utf-8"); + + const skillId = "deploy-automation-001"; + store.insertSkill({ + id: skillId, + name: "deploy-automation", + description: "Automated deployment", + version: 1, + status: "active", + tags: "", + sourceType: "task", + dirPath: skillDir, + installed: 0, + owner: "agent:main", + visibility: "private", + qualityScore: 8, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const engine = new RecallEngine(store, ctx); + const evolver = new SkillEvolver(store, engine, ctx); + + // Trigger the private autoInstallIfNeeded through reflection + const skill = store.getSkill(skillId); + expect(skill).not.toBeNull(); + + // Use type assertion to access private method for testing + (evolver as any).autoInstallIfNeeded(skill); + + // Verify the skill was NOT installed + const updatedSkill = store.getSkill(skillId); + expect(updatedSkill?.installed).toBe(0); + + const workspaceSkillDir = path.join(tmpDir, "skills", "deploy-automation"); + expect(fs.existsSync(workspaceSkillDir)).toBe(false); + }); + + it("should auto-install install_recommended skills when autoInstall=true", async () => { + const ctx: PluginContext = { + stateDir: tmpDir, + workspaceDir: tmpDir, + config: { + skillEvolution: { + enabled: true, + autoInstall: true, + autoEvaluate: false, + }, + } as MemosLocalConfig, + log: noopLog, + }; + + // Create a skill with install_recommended characteristics + const skillDir = path.join(tmpDir, "skills-repo", "build-tools"); + const scriptsDir = path.join(skillDir, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + + fs.writeFileSync(path.join(skillDir, "SKILL.md"), `--- +name: "build-tools" +description: "Build automation tools" +version: 1 +--- + +## Steps +1. Run build scripts +`, "utf-8"); + + // Create 3 scripts to trigger install_recommended + fs.writeFileSync(path.join(scriptsDir, "build.sh"), "#!/bin/bash\necho build", "utf-8"); + fs.writeFileSync(path.join(scriptsDir, "test.sh"), "#!/bin/bash\necho test", "utf-8"); + fs.writeFileSync(path.join(scriptsDir, "package.sh"), "#!/bin/bash\necho package", "utf-8"); + + const skillId = "build-tools-001"; + store.insertSkill({ + id: skillId, + name: "build-tools", + description: "Build automation", + version: 1, + status: "active", + tags: "", + sourceType: "task", + dirPath: skillDir, + installed: 0, + owner: "agent:main", + visibility: "private", + qualityScore: 8, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const engine = new RecallEngine(store, ctx); + const evolver = new SkillEvolver(store, engine, ctx); + + const skill = store.getSkill(skillId); + expect(skill).not.toBeNull(); + + // Use type assertion to access private method for testing + (evolver as any).autoInstallIfNeeded(skill); + + // Verify the skill WAS installed + const updatedSkill = store.getSkill(skillId); + expect(updatedSkill?.installed).toBe(1); + + const workspaceSkillDir = path.join(tmpDir, "skills", "build-tools"); + expect(fs.existsSync(workspaceSkillDir)).toBe(true); + expect(fs.existsSync(path.join(workspaceSkillDir, "scripts", "build.sh"))).toBe(true); + }); + + it("should respect default autoInstall=true when config is not specified", async () => { + const ctx: PluginContext = { + stateDir: tmpDir, + workspaceDir: tmpDir, + config: { + skillEvolution: { + enabled: true, + // autoInstall not specified, should default to true + }, + } as MemosLocalConfig, + log: noopLog, + }; + + const skillDir = path.join(tmpDir, "skills-repo", "default-test"); + const scriptsDir = path.join(skillDir, "scripts"); + fs.mkdirSync(scriptsDir, { recursive: true }); + + fs.writeFileSync(path.join(skillDir, "SKILL.md"), `--- +name: "default-test" +description: "Test default behavior" +version: 1 +--- + +## Steps +1. Test +`, "utf-8"); + + fs.writeFileSync(path.join(scriptsDir, "script1.sh"), "#!/bin/bash\necho 1", "utf-8"); + fs.writeFileSync(path.join(scriptsDir, "script2.sh"), "#!/bin/bash\necho 2", "utf-8"); + fs.writeFileSync(path.join(scriptsDir, "script3.sh"), "#!/bin/bash\necho 3", "utf-8"); + + const skillId = "default-test-001"; + store.insertSkill({ + id: skillId, + name: "default-test", + description: "Default test", + version: 1, + status: "active", + tags: "", + sourceType: "task", + dirPath: skillDir, + installed: 0, + owner: "agent:main", + visibility: "private", + qualityScore: 8, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const engine = new RecallEngine(store, ctx); + const evolver = new SkillEvolver(store, engine, ctx); + + const skill = store.getSkill(skillId); + (evolver as any).autoInstallIfNeeded(skill); + + // Should be installed by default + const updatedSkill = store.getSkill(skillId); + expect(updatedSkill?.installed).toBe(1); + }); +}); diff --git a/packages/memos-core/src/skill/evolver.ts b/packages/memos-core/src/skill/evolver.ts index 42516e8b0..495728918 100644 --- a/packages/memos-core/src/skill/evolver.ts +++ b/packages/memos-core/src/skill/evolver.ts @@ -370,17 +370,13 @@ Use selectedIndex 0 when none is highly relevant.`; if (skill.status !== "active") return; const explicitAutoInstall = this.ctx.config.skillEvolution?.autoInstall ?? DEFAULTS.skillAutoInstall; - if (explicitAutoInstall) { - this.installer.install(skill.id); - this.ctx.log.info(`SkillEvolver: auto-installed "${skill.name}" (explicit autoInstall=true)`); + if (!explicitAutoInstall) { + this.ctx.log.debug(`SkillEvolver: skipping auto-install for "${skill.name}" (autoInstall=false)`); return; } - const manifest = SkillInstaller.buildManifest(skill.dirPath, !!skill.installed, skill.name); - if (manifest.installMode === "install_recommended") { - this.installer.install(skill.id); - this.ctx.log.info(`SkillEvolver: auto-installed "${skill.name}" (install_recommended: ${manifest.scriptsCount} scripts, ${Math.round(manifest.totalSize / 1024)}KB)`); - } + this.installer.install(skill.id); + this.ctx.log.info(`SkillEvolver: auto-installed "${skill.name}" (autoInstall=true)`); } private readSkillContent(skill: Skill): string | null {