refactor: localize workspace skill prompt contract

This commit is contained in:
Shakker
2026-04-01 18:40:29 +01:00
committed by Peter Steinberger
parent cc57bcfe2f
commit 9a6dda1b66
8 changed files with 82 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { createSyntheticSourceInfo, type Skill } from "@mariozechner/pi-coding-agent";
import { createSyntheticSourceInfo, type Skill } from "./skills/skill-contract.js";
export async function writeSkill(params: {
dir: string;

View File

@@ -1,8 +1,9 @@
import os from "node:os";
import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent";
import { formatSkillsForPrompt as upstreamFormatSkillsForPrompt } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { createCanonicalFixtureSkill } from "../skills.test-helpers.js";
import { formatSkillsForPrompt, type Skill } from "./skill-contract.js";
import type { SkillEntry } from "./types.js";
import {
formatSkillsCompact,
@@ -42,6 +43,16 @@ function buildPrompt(
}
describe("formatSkillsCompact", () => {
it("keeps the full-format XML output aligned with the upstream formatter", () => {
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
const skills = [
makeSkill("weather", "Get weather <data> & forecasts"),
makeSkill("notes", "Summarize notes", "/tmp/notes/SKILL.md"),
hidden,
];
expect(formatSkillsForPrompt(skills)).toBe(upstreamFormatSkillsForPrompt(skills));
});
it("returns empty string for no skills", () => {
expect(formatSkillsCompact([])).toBe("");
});

View File

@@ -1,4 +1,3 @@
import type { Skill } from "@mariozechner/pi-coding-agent";
import { validateRegistryNpmSpec } from "../../infra/npm-registry-spec.js";
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
import {
@@ -12,6 +11,7 @@ import {
resolveOpenClawManifestOs,
resolveOpenClawManifestRequires,
} from "../../shared/frontmatter.js";
import type { Skill } from "./skill-contract.js";
import type {
OpenClawSkillMetadata,
ParsedSkillFrontmatter,

View File

@@ -1,8 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { createSyntheticSourceInfo, type Skill } from "@mariozechner/pi-coding-agent";
import { openVerifiedFileSync } from "../../infra/safe-open-sync.js";
import { parseFrontmatter, resolveSkillInvocationPolicy } from "./frontmatter.js";
import { createSyntheticSourceInfo, type Skill } from "./skill-contract.js";
function isPathWithinRoot(rootRealPath: string, candidatePath: string): boolean {
const relative = path.relative(rootRealPath, candidatePath);

View File

@@ -0,0 +1,64 @@
import type { Skill as CanonicalSkill, SourceInfo } from "@mariozechner/pi-coding-agent";
export type SourceScope = "user" | "project" | "temporary";
export type SourceOrigin = "package" | "top-level";
export type Skill = CanonicalSkill & {
// Preserve legacy source reads while keeping the canonical upstream shape.
source?: string;
};
export function createSyntheticSourceInfo(
path: string,
options: {
source: string;
scope?: SourceScope;
origin?: SourceOrigin;
baseDir?: string;
},
): SourceInfo {
return {
path,
source: options.source,
scope: options.scope ?? "temporary",
origin: options.origin ?? "top-level",
baseDir: options.baseDir,
};
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Keep this formatter byte-for-byte aligned with the upstream Agent Skills XML
* layout so we can avoid importing the full pi-coding-agent package root on the
* cold skills path.
*/
export function formatSkillsForPrompt(skills: Skill[]): string {
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
if (visibleSkills.length === 0) {
return "";
}
const lines = [
"\n\nThe following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its description.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
"",
"<available_skills>",
];
for (const skill of visibleSkills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
}
lines.push("</available_skills>");
return lines.join("\n");
}

View File

@@ -1,4 +1,4 @@
import type { Skill } from "@mariozechner/pi-coding-agent";
import type { Skill } from "./skill-contract.js";
type SkillSourceCompat = Skill & {
sourceInfo?: {

View File

@@ -1,4 +1,4 @@
import type { Skill } from "@mariozechner/pi-coding-agent";
import type { Skill } from "./skill-contract.js";
export type SkillInstallSpec = {
id?: string;

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../../config/config.js";
import { isPathInside } from "../../infra/path-guards.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -14,6 +13,7 @@ import { resolveOpenClawMetadata, resolveSkillInvocationPolicy } from "./frontma
import { loadSkillsFromDirSafe, readSkillFrontmatterSafe } from "./local-loader.js";
import { resolvePluginSkillDirs } from "./plugin-skills.js";
import { serializeByKey } from "./serialize.js";
import { formatSkillsForPrompt, type Skill } from "./skill-contract.js";
import type {
ParsedSkillFrontmatter,
SkillEligibilityContext,