From 4f2c57eb4eb034867134f20cd53679de7a6a3fa3 Mon Sep 17 00:00:00 2001 From: mac26ai Date: Fri, 13 Feb 2026 00:24:36 +0800 Subject: [PATCH] feat(skills): compact skill paths with ~ to reduce prompt tokens Replace absolute home directory prefix with ~ in skill tags injected into the system prompt. Models understand ~ expansion and the read tool resolves it, so this is a safe, backward-compatible change. Saves ~5-6 tokens per skill path. For a workspace with 90+ skills, this reduces system prompt size by ~400-600 tokens. Changes: - Add compactSkillPaths() helper in workspace.ts - Apply in buildWorkspaceSkillSnapshot and buildWorkspaceSkillsPrompt - Add test for path compaction behavior Before: /Users/alice/.bun/install/global/node_modules/openclaw/skills/github/SKILL.md After: ~/.bun/install/global/node_modules/openclaw/skills/github/SKILL.md --- src/agents/skills.compact-skill-paths.test.ts | 80 +++++++++++++++++++ src/agents/skills/workspace.ts | 25 +++++- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 src/agents/skills.compact-skill-paths.test.ts diff --git a/src/agents/skills.compact-skill-paths.test.ts b/src/agents/skills.compact-skill-paths.test.ts new file mode 100644 index 0000000000..74c284c95c --- /dev/null +++ b/src/agents/skills.compact-skill-paths.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildWorkspaceSkillsPrompt } from "./skills.js"; + +async function writeSkill(params: { + dir: string; + name: string; + description: string; + body?: string; +}) { + const { dir, name, description, body } = params; + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "SKILL.md"), + `--- +name: ${name} +description: ${description} +--- + +${body ?? `# ${name}\n`} +`, + "utf-8", + ); +} + +describe("compactSkillPaths", () => { + it("replaces home directory prefix with ~ in skill locations", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const skillDir = path.join(workspaceDir, "skills", "test-skill"); + + await writeSkill({ + dir: skillDir, + name: "test-skill", + description: "A test skill for path compaction", + }); + + const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { + bundledSkillsDir: path.join(workspaceDir, ".bundled-empty"), + managedSkillsDir: path.join(workspaceDir, ".managed-empty"), + }); + + const home = os.homedir(); + // The prompt should NOT contain the absolute home directory path + // when the skill is under the home directory (which tmpdir usually is on macOS) + if (workspaceDir.startsWith(home)) { + expect(prompt).not.toContain(home + path.sep); + expect(prompt).toContain("~/"); + } + + // The skill name and description should still be present + expect(prompt).toContain("test-skill"); + expect(prompt).toContain("A test skill for path compaction"); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("preserves paths outside home directory", async () => { + // Skills outside ~ should keep their absolute paths + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const skillDir = path.join(workspaceDir, "skills", "ext-skill"); + + await writeSkill({ + dir: skillDir, + name: "ext-skill", + description: "External skill", + }); + + const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { + bundledSkillsDir: path.join(workspaceDir, ".bundled-empty"), + managedSkillsDir: path.join(workspaceDir, ".managed-empty"), + }); + + // Should still contain a valid location tag + expect(prompt).toMatch(/[^<]+SKILL\.md<\/location>/); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); +}); diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index b7470cb1ba..033221f4d2 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -32,6 +32,26 @@ const fsp = fs.promises; const skillsLogger = createSubsystemLogger("skills"); const skillCommandDebugOnce = new Set(); +/** + * Replace the user's home directory prefix with `~` in skill file paths + * to reduce system prompt token usage. Models understand `~` expansion, + * and the read tool resolves `~` to the home directory. + * + * Example: `/Users/alice/.bun/.../skills/github/SKILL.md` + * → `~/.bun/.../skills/github/SKILL.md` + * + * Saves ~5–6 tokens per skill path × N skills ≈ 400–600 tokens total. + */ +function compactSkillPaths(skills: Skill[]): Skill[] { + const home = os.homedir(); + if (!home) return skills; + const prefix = home.endsWith(path.sep) ? home : home + path.sep; + return skills.map((s) => ({ + ...s, + filePath: s.filePath.startsWith(prefix) ? "~/" + s.filePath.slice(prefix.length) : s.filePath, + })); +} + function debugSkillCommandOnce( messageKey: string, message: string, @@ -448,7 +468,6 @@ export function buildWorkspaceSkillSnapshot( ); const resolvedSkills = promptEntries.map((entry) => entry.skill); const remoteNote = opts?.eligibility?.remote?.note?.trim(); - const { skillsForPrompt, truncated } = applySkillsPromptLimits({ skills: resolvedSkills, config: opts?.config, @@ -458,7 +477,7 @@ export function buildWorkspaceSkillSnapshot( ? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.` : ""; - const prompt = [remoteNote, truncationNote, formatSkillsForPrompt(skillsForPrompt)] + const prompt = [remoteNote, truncationNote, formatSkillsForPrompt(compactSkillPaths(skillsForPrompt))] .filter(Boolean) .join("\n"); const skillFilter = normalizeSkillFilter(opts?.skillFilter); @@ -505,7 +524,7 @@ export function buildWorkspaceSkillsPrompt( const truncationNote = truncated ? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}. Run \`openclaw skills check\` to audit.` : ""; - return [remoteNote, truncationNote, formatSkillsForPrompt(skillsForPrompt)] + return [remoteNote, truncationNote, formatSkillsForPrompt(compactSkillPaths(skillsForPrompt))] .filter(Boolean) .join("\n"); }