refactor: unify skill system on library.yaml, disable pi --skill flag
Switch to Option B: pi's built-in skill discovery disabled (--no-skills), agent-scoped skills injected via library.yaml + skills_search extension tool. Removes triple-loading where skills appeared in pi's system prompt, our --append-system-prompt, and the extension tool simultaneously. Agents now get only their assigned skills from library.yaml. - Remove --skill flag from controlplane-runner, jail-exec-runner, agent-runner - Add --no-skills to all pi invocations - Remove skillsDir from ControlplaneHeartbeatConfig and all plumbing - Simplify loadSkillsCatalog() to library.yaml only (remove fs fallback) - Remove dead: PI_TUI_SKILLS_PATH, PI_TUI_PROFILE_LABEL, countSkillsInDir - Remove 214 lines net across 17 files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2027d9870a
commit
dba0b691f8
17 changed files with 55 additions and 269 deletions
|
|
@ -19,9 +19,7 @@ import {
|
|||
PI_TUI_MODEL,
|
||||
PI_TUI_NO_SESSION,
|
||||
PI_TUI_OFFLINE,
|
||||
PI_TUI_PROFILE,
|
||||
PI_TUI_PROVIDER,
|
||||
PI_TUI_SKILLS_PATH,
|
||||
PI_TUI_THINKING,
|
||||
PI_TUI_TOOLS,
|
||||
TMP_DIR,
|
||||
|
|
@ -171,16 +169,9 @@ export async function runJailAgent(
|
|||
if (PI_TUI_MODEL) args.push('--model', PI_TUI_MODEL);
|
||||
if (PI_TUI_TOOLS) args.push('--tools', PI_TUI_TOOLS);
|
||||
if (PI_TUI_THINKING) args.push('--thinking', PI_TUI_THINKING);
|
||||
// Inject skills only for task-oriented profiles — not for operator/status
|
||||
// chat mode where the model gets distracted by the skills directory listing.
|
||||
const SKILL_AWARE_PROFILES = ['setup', 'docs', 'cms', 'git', 'memory', 'coding'];
|
||||
if (
|
||||
SKILL_AWARE_PROFILES.includes(PI_TUI_PROFILE) &&
|
||||
PI_TUI_SKILLS_PATH &&
|
||||
fs.existsSync(PI_TUI_SKILLS_PATH)
|
||||
) {
|
||||
args.push('--skill', PI_TUI_SKILLS_PATH);
|
||||
}
|
||||
// Disable pi's built-in skill discovery — skills are scoped per-agent via
|
||||
// library.yaml and served on-demand through the skills_search extension tool.
|
||||
args.push('--no-skills');
|
||||
// Append identity + per-call context (memory, skills) to system prompt.
|
||||
// This keeps ephemeral context out of the session file — it is never stored
|
||||
// in the JSONL history, so it cannot compound across turns.
|
||||
|
|
|
|||
|
|
@ -234,7 +234,6 @@ const resolvedPiTuiProfile = resolvePiTuiProfile({
|
|||
export const PI_TUI_PROFILE = resolvedPiTuiProfile.name;
|
||||
export const PI_TUI_PROVIDER = resolvedPiTuiProfile.provider;
|
||||
export const PI_TUI_MODEL = resolvedPiTuiProfile.model;
|
||||
export const PI_TUI_PROFILE_LABEL = resolvedPiTuiProfile.label;
|
||||
export const PI_TUI_DISPLAY_INTENT = resolvedPiTuiProfile.displayIntent;
|
||||
export const PI_TUI_SESSION_POLICY = resolvedPiTuiProfile.sessionPolicy;
|
||||
export const PI_TUI_TOOLS = resolvedPiTuiProfile.tools || '';
|
||||
|
|
@ -243,8 +242,6 @@ export const PI_TUI_NO_SESSION = resolvedPiTuiProfile.noSession === true;
|
|||
export const PI_TUI_OFFLINE = resolvedPiTuiProfile.offline === true;
|
||||
export const PI_TUI_APPEND_SYSTEM_PROMPT =
|
||||
resolvedPiTuiProfile.appendSystemPrompt;
|
||||
// Skill files in .agent/skills/ — exposed to pi via --skill flag
|
||||
export const PI_TUI_SKILLS_PATH = path.join(process.cwd(), '.agent', 'skills');
|
||||
export const POLL_INTERVAL = 2000;
|
||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
verifyPassword,
|
||||
generatePassword,
|
||||
copySkills,
|
||||
countSkillsInDir,
|
||||
CONTROLPLANE_SCHEMA_SQL,
|
||||
VALID_EVENT_TYPES,
|
||||
} from './controlplane-db.js';
|
||||
|
|
@ -170,31 +169,6 @@ describe('generatePassword', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('countSkillsInDir', () => {
|
||||
it('returns 0 for non-existent directory', () => {
|
||||
expect(countSkillsInDir(path.join(TMP, 'no-such-dir'))).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for empty directory', () => {
|
||||
const emptyDir = path.join(TMP, 'empty');
|
||||
fs.mkdirSync(emptyDir);
|
||||
expect(countSkillsInDir(emptyDir)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts only directories with SKILL.md', () => {
|
||||
const skillsDir = path.join(TMP, 'skills');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(skillsDir, 'skill-a'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'skill-a', 'SKILL.md'), '# A');
|
||||
fs.mkdirSync(path.join(skillsDir, 'skill-b'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'skill-b', 'SKILL.md'), '# B');
|
||||
fs.mkdirSync(path.join(skillsDir, 'not-a-skill'));
|
||||
fs.writeFileSync(path.join(skillsDir, 'readme.md'), 'ignore');
|
||||
|
||||
expect(countSkillsInDir(skillsDir)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copySkills', () => {
|
||||
it('throws if source directory does not exist', () => {
|
||||
expect(() =>
|
||||
|
|
|
|||
|
|
@ -583,16 +583,6 @@ export function copySkills(sourceDir: string, destDir: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function countSkillsInDir(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')),
|
||||
).length;
|
||||
}
|
||||
|
||||
// ── Task queries ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createTask(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ function makeConfig(
|
|||
pool: mockPool.pool as unknown as Pool,
|
||||
workspaceCwd: process.cwd(),
|
||||
sessionCwd: path.join(TMP, 'sessions'),
|
||||
skillsDir: path.join(TMP, 'skills'),
|
||||
apiKey: 'test-key',
|
||||
agentName: 'clawdie',
|
||||
tickIntervalMs: 60000,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ export interface ControlplaneHeartbeatConfig {
|
|||
pool: Pool;
|
||||
workspaceCwd: string;
|
||||
sessionCwd: string;
|
||||
skillsDir: string;
|
||||
apiKey: string;
|
||||
agentName: string;
|
||||
tickIntervalMs: number;
|
||||
|
|
@ -201,7 +200,7 @@ export async function runAgentHeartbeat(
|
|||
wakeReason: WakeReason,
|
||||
taskId?: string,
|
||||
): Promise<HeartbeatResult> {
|
||||
const { pool, workspaceCwd, sessionCwd, skillsDir, apiKey } = config;
|
||||
const { pool, workspaceCwd, sessionCwd, apiKey } = config;
|
||||
|
||||
// Resolve alias IDs (db-admin → db_admin_agent) for budget + identity lookups.
|
||||
const canonicalId = resolveCanonicalAgentId(agentId);
|
||||
|
|
@ -230,7 +229,7 @@ export async function runAgentHeartbeat(
|
|||
}
|
||||
|
||||
const taskDescription = task?.title ?? `Heartbeat check for ${agentId}`;
|
||||
const skills = loadSkillsCatalog(skillsDir);
|
||||
const skills = loadSkillsCatalog();
|
||||
const match = matchTaskToSkill(taskDescription, skills);
|
||||
|
||||
const prompt =
|
||||
|
|
@ -268,8 +267,6 @@ export async function runAgentHeartbeat(
|
|||
|
||||
if (jailName) {
|
||||
// ── Jail execution path ────────────────────────────────────────────
|
||||
// Skills are mounted at /opt/skills inside every agent jail (nullfs ro)
|
||||
const jailSkillsDir = '/opt/skills';
|
||||
const jailEnv = {
|
||||
CONTROLPLANE_AGENT_ID: agentId,
|
||||
CONTROLPLANE_TASK_ID: effectiveTaskId,
|
||||
|
|
@ -298,7 +295,6 @@ export async function runAgentHeartbeat(
|
|||
jailName,
|
||||
prompt: promptWithContext,
|
||||
systemPrompt: identityContent ?? undefined,
|
||||
skillsDir: jailSkillsDir,
|
||||
extensionPath: jailExtPath,
|
||||
env: jailEnv,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -122,11 +122,10 @@ describe('Control Plane Runner — env injection', () => {
|
|||
expect(result.args[idx + 1]).toContain('sysadmin_agent.jsonl');
|
||||
});
|
||||
|
||||
it('args include --skill pointing to data/skills/', () => {
|
||||
it('args include --no-skills to disable pi skill discovery', () => {
|
||||
const result = buildControlplaneRunCommand(makeOpts());
|
||||
const idx = result.args.indexOf('--skill');
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(result.args[idx + 1]).toContain('data/skills');
|
||||
expect(result.args).toContain('--no-skills');
|
||||
expect(result.args).not.toContain('--skill');
|
||||
});
|
||||
|
||||
it('args include --append-system-prompt with identity content', () => {
|
||||
|
|
|
|||
|
|
@ -127,19 +127,21 @@ export function buildControlplaneRunCommand(
|
|||
: path.resolve(workspaceCwd, sessionCwd);
|
||||
|
||||
const sessionFile = path.join(absSessionCwd, `${agentId}.jsonl`);
|
||||
const skillsDir = path.join(workspaceCwd, 'data', 'skills');
|
||||
const identityFile =
|
||||
opts.identityFile ?? resolveIdentityFile(agentId, agentName, workspaceCwd);
|
||||
const prompt = opts.prompt ?? `Control plane wake: ${wakeReason}`;
|
||||
|
||||
// Build system prompt: identity + skill index (lazy load via --skill flag)
|
||||
// Build system prompt: identity + scoped skill index from library.yaml.
|
||||
// Pi's built-in skill discovery is disabled (--no-skills) — we inject only
|
||||
// the agent's assigned skills via system prompt. Full content is available
|
||||
// on-demand through the skills_search extension tool.
|
||||
const identityContent = fs.existsSync(identityFile)
|
||||
? fs.readFileSync(identityFile, 'utf-8')
|
||||
: '';
|
||||
const skillIndex = getAgentSkillIndex(agentId);
|
||||
const skillContext =
|
||||
skillIndex.length > 0
|
||||
? '\n\n---\n\n## Available Skills (use --skill to load full content)\n\n' +
|
||||
? '\n\n---\n\n## Available Skills (use skills_search tool for full content)\n\n' +
|
||||
skillIndex.join('\n')
|
||||
: '';
|
||||
const systemPrompt = identityContent + skillContext;
|
||||
|
|
@ -147,10 +149,9 @@ export function buildControlplaneRunCommand(
|
|||
const args: string[] = [
|
||||
'--mode',
|
||||
'json',
|
||||
'--no-skills',
|
||||
'--session',
|
||||
sessionFile,
|
||||
'--skill',
|
||||
skillsDir,
|
||||
];
|
||||
|
||||
// Load the harness extension so agents get tools (hostd, skills_search, etc.)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import path from 'path';
|
|||
import {
|
||||
DEFAULT_AGENTS,
|
||||
copySkills,
|
||||
countSkillsInDir,
|
||||
generatePassword,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
|
|
@ -225,22 +224,7 @@ describe('Control Plane Provisioning', () => {
|
|||
expect(stat.isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('countSkillsInDir returns correct count', () => {
|
||||
const base = path.join(tmpDir, 'count-skills');
|
||||
for (const name of ['skill-a', 'skill-b', 'skill-c']) {
|
||||
fs.mkdirSync(path.join(base, name), { recursive: true });
|
||||
fs.writeFileSync(path.join(base, name, 'SKILL.md'), `---\nname: ${name}\n---`);
|
||||
}
|
||||
// add dir without SKILL.md
|
||||
fs.mkdirSync(path.join(base, 'not-a-skill'), { recursive: true });
|
||||
|
||||
expect(countSkillsInDir(base)).toBe(3);
|
||||
});
|
||||
|
||||
it('countSkillsInDir returns 0 for missing directory', () => {
|
||||
expect(countSkillsInDir(path.join(tmpDir, 'does-not-exist'))).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency (via upsert semantics)', () => {
|
||||
it('DEFAULT_AGENTS have unique ids (no duplicate roles)', () => {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import { bridgeTelegramMessage } from './controlplane-telegram.js';
|
|||
import type { ControlplaneHeartbeatConfig } from './controlplane-heartbeat.js';
|
||||
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
let sessionDir: string;
|
||||
|
||||
const insertedTasks: Array<{
|
||||
|
|
@ -65,7 +64,6 @@ function makeConfig(pool: Pool): ControlplaneHeartbeatConfig {
|
|||
pool,
|
||||
workspaceCwd: tmpDir,
|
||||
sessionCwd: sessionDir,
|
||||
skillsDir,
|
||||
apiKey: 'test-key',
|
||||
agentName: 'clawdie',
|
||||
tickIntervalMs: 30000,
|
||||
|
|
@ -73,22 +71,9 @@ function makeConfig(pool: Pool): ControlplaneHeartbeatConfig {
|
|||
};
|
||||
}
|
||||
|
||||
function writeSkill(name: string, invokePatterns: string[]): void {
|
||||
const dir = path.join(skillsDir, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const patterns = invokePatterns.map((p) => ` - "${p}"`).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: Test skill\ninvoke_patterns:\n${patterns}\n---\n\n# ${name}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-bridge-test-'));
|
||||
skillsDir = path.join(tmpDir, 'skills');
|
||||
sessionDir = path.join(tmpDir, 'sessions');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
});
|
||||
|
||||
|
|
@ -111,7 +96,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
it('routes to orchestrator when message does not match any skill', async () => {
|
||||
writeSkill('jail-status', ['Is * jail running', 'Check jail *']);
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -124,7 +108,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
it('returns task result when message matches a skill', async () => {
|
||||
writeSkill('jail-status', ['Is * jail running', 'Check jail *']);
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -136,7 +119,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
it('result includes skillName from matched skill', async () => {
|
||||
writeSkill('jail-status', ['Is * jail running', 'Check * jail']);
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -150,19 +132,17 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
|
||||
describe('agent routing', () => {
|
||||
it('routes jail messages to sysadmin_agent', async () => {
|
||||
writeSkill('jail-status', ['Check jail *', 'Is * jail running']);
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
makeConfig(pool),
|
||||
'tg:test',
|
||||
'Check jail db',
|
||||
'Check jail status',
|
||||
);
|
||||
expect(result.assignedTo).toBe('sysadmin_agent');
|
||||
});
|
||||
|
||||
it('routes database messages to db_admin_agent', async () => {
|
||||
writeSkill('backup-db', ['backup * database', 'run database backup']);
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -174,7 +154,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
it('routes git messages to git_admin_agent', async () => {
|
||||
writeSkill('git-status', ['check git repo', 'git status *']);
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -186,7 +165,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
it('routes unrecognised agent messages to orchestrator', async () => {
|
||||
writeSkill('general', ['review system status', 'system overview']);
|
||||
const pool = makePool();
|
||||
const result = await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -222,7 +200,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
|
||||
describe('task creation', () => {
|
||||
it('inserts a task into the database', async () => {
|
||||
writeSkill('jail-status', ['Check jail *']);
|
||||
const pool = makePool();
|
||||
await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -234,7 +211,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
it('task title is the message text (truncated to 200 chars)', async () => {
|
||||
writeSkill('jail-status', ['Check jail *']);
|
||||
const pool = makePool();
|
||||
await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -246,7 +222,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
it('task id starts with TG-', async () => {
|
||||
writeSkill('jail-status', ['Check jail *']);
|
||||
const pool = makePool();
|
||||
await bridgeTelegramMessage(
|
||||
pool,
|
||||
|
|
@ -258,13 +233,12 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
});
|
||||
|
||||
it('task assigned_to matches inferred role', async () => {
|
||||
writeSkill('jail-status', ['Check jail *']);
|
||||
const pool = makePool();
|
||||
await bridgeTelegramMessage(
|
||||
pool,
|
||||
makeConfig(pool),
|
||||
'tg:test',
|
||||
'Check jail db',
|
||||
'Check jail status',
|
||||
);
|
||||
expect(insertedTasks[0].assigned_to).toBe('sysadmin_agent');
|
||||
});
|
||||
|
|
@ -284,7 +258,6 @@ describe('Telegram → Control Plane Bridge', () => {
|
|||
|
||||
describe('long messages', () => {
|
||||
it('truncates title to 200 characters', async () => {
|
||||
writeSkill('jail-status', ['Check jail *']);
|
||||
const pool = makePool();
|
||||
const long = 'Check jail db ' + 'x'.repeat(300);
|
||||
const result = await bridgeTelegramMessage(
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export async function bridgeTelegramMessage(
|
|||
chatJid: string,
|
||||
message: string,
|
||||
): Promise<TelegramBridgeResult> {
|
||||
const skills = loadSkillsCatalog(config.skillsDir);
|
||||
const skills = loadSkillsCatalog();
|
||||
const match = matchTaskToSkill(message, skills);
|
||||
|
||||
const skillName = match.confidence !== 'none' ? match.skill?.name : undefined;
|
||||
|
|
|
|||
|
|
@ -37,14 +37,11 @@ import { type Agent, type Budget, type Task } from './controlplane-db.js';
|
|||
|
||||
let tmpDir: string;
|
||||
let sessionDir: string;
|
||||
let skillsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-integration-'));
|
||||
sessionDir = path.join(tmpDir, 'sessions');
|
||||
skillsDir = path.join(tmpDir, 'skills');
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -97,7 +94,6 @@ function makeConfig(pool: Pool): ControlplaneHeartbeatConfig {
|
|||
pool,
|
||||
workspaceCwd: tmpDir,
|
||||
sessionCwd: sessionDir,
|
||||
skillsDir,
|
||||
apiKey: 'test-key',
|
||||
agentName: 'clawdie',
|
||||
tickIntervalMs: 30000,
|
||||
|
|
@ -209,7 +205,6 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
|
|||
it('pi spawn failure posts error event to control plane', async () => {
|
||||
const pool = makePool();
|
||||
const config = makeConfig(pool);
|
||||
config.skillsDir = '/nonexistent/skills/path';
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
|
||||
expect(result.agentId).toBe('sysadmin_agent');
|
||||
});
|
||||
|
|
@ -217,7 +212,6 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
|
|||
it('error event includes action_taken field', async () => {
|
||||
const pool = makePool();
|
||||
const config = makeConfig(pool);
|
||||
config.skillsDir = '/nonexistent/skills/path';
|
||||
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
|
||||
if (activityLog.length > 0) {
|
||||
const hasActionTaken = activityLog.some((e) => {
|
||||
|
|
@ -231,7 +225,6 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
|
|||
it('scheduler does not crash on agent error (continues to next tick)', async () => {
|
||||
const pool = makePool();
|
||||
const config = makeConfig(pool);
|
||||
config.skillsDir = '/nonexistent';
|
||||
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveProperty('agentId');
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
MAIN_GROUP_FOLDER,
|
||||
METRICS_PORT,
|
||||
OPENAI_API_KEY,
|
||||
PI_TUI_SKILLS_PATH,
|
||||
PROJECT_ROOT,
|
||||
TMP_DIR,
|
||||
TRIGGER_PATTERN,
|
||||
|
|
@ -712,7 +711,6 @@ async function main(): Promise<void> {
|
|||
pool: getMemoryPool(),
|
||||
workspaceCwd: PROJECT_ROOT,
|
||||
sessionCwd: sessionDir,
|
||||
skillsDir: PI_TUI_SKILLS_PATH,
|
||||
apiKey: CONTROLPLANE_SHARED_SECRET || OPENAI_API_KEY,
|
||||
agentName: AGENT_NAME,
|
||||
tickIntervalMs: 30000,
|
||||
|
|
|
|||
|
|
@ -400,18 +400,18 @@ describe('jailPi', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('passes --skill to pi when skillsDir provided', async () => {
|
||||
it('passes --no-skills to disable pi skill discovery', async () => {
|
||||
mockSpawn.mockReturnValue(
|
||||
makeMockProc({ stdout: '{}', exitCode: 0 }) as any,
|
||||
);
|
||||
await jailPi({
|
||||
jailName: 'test-jail',
|
||||
prompt: 'hello',
|
||||
skillsDir: '/opt/skills',
|
||||
});
|
||||
const args = mockSpawn.mock.calls[0][1] as string[];
|
||||
const shellCmd = args[args.length - 1] as string;
|
||||
expect(shellCmd).toContain('--skill /opt/skills');
|
||||
expect(shellCmd).toContain('--no-skills');
|
||||
expect(shellCmd).not.toContain('--skill ');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -273,7 +273,6 @@ export interface JailPiOptions {
|
|||
systemPrompt?: string;
|
||||
sessionId?: string;
|
||||
sessionDir?: string;
|
||||
skillsDir?: string;
|
||||
extensionPath?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
|
|
@ -290,9 +289,9 @@ export async function jailPi(opts: JailPiOptions): Promise<JailExecResult> {
|
|||
} else {
|
||||
args.push('--continue');
|
||||
}
|
||||
if (opts.skillsDir) {
|
||||
args.push('--skill', opts.skillsDir);
|
||||
}
|
||||
// Disable pi's built-in skill discovery — skills are scoped per-agent via
|
||||
// library.yaml and served on-demand through the skills_search extension tool.
|
||||
args.push('--no-skills');
|
||||
// Load harness extension for tools (hostd, skills_search, etc.)
|
||||
if (opts.extensionPath) {
|
||||
args.push('-e', opts.extensionPath);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@
|
|||
*
|
||||
* Run on FreeBSD with: npx vitest run src/skills-discovery.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
loadSkillsCatalog,
|
||||
|
|
@ -15,39 +12,6 @@ import {
|
|||
type Skill,
|
||||
} from './skills-discovery.js';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeSkill(
|
||||
dir: string,
|
||||
name: string,
|
||||
patterns: string[] = [],
|
||||
desc = `${name} skill`,
|
||||
) {
|
||||
const skillDir = path.join(dir, name);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const fm = [
|
||||
'---',
|
||||
`name: ${name}`,
|
||||
`description: ${desc}`,
|
||||
'compatibility: FreeBSD 15.0+',
|
||||
'invoke_patterns:',
|
||||
...patterns.map((p) => ` - "${p}"`),
|
||||
`estimated_tokens: 300-500`,
|
||||
'---',
|
||||
'',
|
||||
`# ${name}`,
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), fm, 'utf-8');
|
||||
}
|
||||
|
||||
function makeSkills(names: string[]): Skill[] {
|
||||
return names.map((name) => ({
|
||||
name,
|
||||
|
|
@ -59,51 +23,24 @@ function makeSkills(names: string[]): Skill[] {
|
|||
|
||||
describe('Skills Discovery', () => {
|
||||
describe('catalog loading', () => {
|
||||
it('discovers all SKILL.md files from skills directory', () => {
|
||||
writeSkill(tmpDir, 'jail-status', ['Check if * jail is running']);
|
||||
writeSkill(tmpDir, 'disk-usage', ['How much free disk']);
|
||||
const skills = loadSkillsCatalog(tmpDir);
|
||||
it('loads skills from library.yaml', () => {
|
||||
const skills = loadSkillsCatalog();
|
||||
const names = skills.map((s) => s.name);
|
||||
expect(names).toContain('jail-status');
|
||||
expect(names).toContain('disk-usage');
|
||||
});
|
||||
|
||||
it('each skill has name, description, compatibility fields', () => {
|
||||
writeSkill(tmpDir, 'jail-status', ['Jail status']);
|
||||
const skills = loadSkillsCatalog(tmpDir);
|
||||
it('each skill has name, description, invoke_patterns', () => {
|
||||
const skills = loadSkillsCatalog();
|
||||
const skill = skills[0];
|
||||
expect(skill).toHaveProperty('name');
|
||||
expect(skill).toHaveProperty('description');
|
||||
expect(skill).toHaveProperty('compatibility');
|
||||
expect(skill).toHaveProperty('invoke_patterns');
|
||||
});
|
||||
|
||||
it('filesystem skills have compatibility field', () => {
|
||||
writeSkill(tmpDir, 'backup-db', ['Back up the database']);
|
||||
const skills = loadSkillsCatalog(tmpDir);
|
||||
const fsSkill = skills.find((s) => s.name === 'backup-db');
|
||||
expect(fsSkill).toBeDefined();
|
||||
expect(fsSkill!.compatibility).toContain('FreeBSD 15.0+');
|
||||
});
|
||||
|
||||
it('returns at least 2 skills from test directory', () => {
|
||||
writeSkill(tmpDir, 'a', []);
|
||||
writeSkill(tmpDir, 'b', []);
|
||||
writeSkill(tmpDir, 'c', []);
|
||||
const skills = loadSkillsCatalog(tmpDir);
|
||||
expect(skills.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('handles missing directory gracefully — returns library skills', () => {
|
||||
const skills = loadSkillsCatalog('/nonexistent/path/skills');
|
||||
expect(skills.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('skips subdirectories with no SKILL.md', () => {
|
||||
writeSkill(tmpDir, 'real-skill-xyz', ['test']);
|
||||
fs.mkdirSync(path.join(tmpDir, 'no-skill-md'), { recursive: true });
|
||||
const skills = loadSkillsCatalog(tmpDir);
|
||||
const fsSkill = skills.find((s) => s.name === 'real-skill-xyz');
|
||||
expect(fsSkill).toBeDefined();
|
||||
it('returns all skills from library.yaml', () => {
|
||||
const skills = loadSkillsCatalog();
|
||||
expect(skills.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,8 @@
|
|||
* src/skills-discovery.ts
|
||||
*
|
||||
* Skill discovery and pattern matching for task routing.
|
||||
*
|
||||
* Primary source: agent/library.yaml via skill-library.ts
|
||||
* Fallback: data/skills/ directories with SKILL.md frontmatter
|
||||
* Source of truth: agent/library.yaml via skill-library.ts
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { loadLibrary } from './skill-library.js';
|
||||
|
|
@ -89,69 +86,27 @@ export function parseSkillFrontmatter(
|
|||
|
||||
// ── Catalog loading ────────────────────────────────────────────────────────
|
||||
|
||||
export function loadSkillsCatalog(skillsDir?: string): Skill[] {
|
||||
const skills: Skill[] = [];
|
||||
|
||||
// Primary source: agent/library.yaml
|
||||
try {
|
||||
const lib = loadLibrary();
|
||||
for (const s of lib.skills) {
|
||||
const localPath = s.source.startsWith('local:')
|
||||
? s.source.slice('local:'.length)
|
||||
: null;
|
||||
const dirName = localPath
|
||||
? path.basename(localPath.replace(/\/SKILL\.md$/i, ''))
|
||||
: undefined;
|
||||
const skillMdPath = localPath
|
||||
? path.resolve(path.dirname(LIBRARY_PATH), localPath)
|
||||
: undefined;
|
||||
skills.push({
|
||||
name: s.id,
|
||||
description: s.description,
|
||||
compatibility: '',
|
||||
invoke_patterns: s.tags,
|
||||
dirName,
|
||||
path: skillMdPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Library not available — fall through to filesystem scan
|
||||
}
|
||||
|
||||
// Fallback: data/skills/ directories (override library entries with same dirName)
|
||||
if (skillsDir && fs.existsSync(skillsDir)) {
|
||||
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillMd = path.join(skillsDir, entry.name, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMd)) continue;
|
||||
|
||||
const content = fs.readFileSync(skillMd, 'utf-8');
|
||||
const fm = parseSkillFrontmatter(content);
|
||||
|
||||
const fsSkill: Skill = {
|
||||
name: (fm.name as string) ?? entry.name,
|
||||
description: (fm.description as string) ?? '',
|
||||
compatibility: (fm.compatibility as string) ?? '',
|
||||
invoke_patterns: Array.isArray(fm.invoke_patterns)
|
||||
? (fm.invoke_patterns as string[])
|
||||
: [],
|
||||
estimated_tokens: fm.estimated_tokens as string | undefined,
|
||||
dirName: entry.name,
|
||||
path: skillMd,
|
||||
};
|
||||
|
||||
// Replace library entry if same dirName, otherwise append
|
||||
const existingIdx = skills.findIndex((s) => s.dirName === entry.name);
|
||||
if (existingIdx >= 0) {
|
||||
skills[existingIdx] = fsSkill;
|
||||
} else {
|
||||
skills.push(fsSkill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
export function loadSkillsCatalog(): Skill[] {
|
||||
const lib = loadLibrary();
|
||||
return lib.skills.map((s) => {
|
||||
const localPath = s.source.startsWith('local:')
|
||||
? s.source.slice('local:'.length)
|
||||
: null;
|
||||
const dirName = localPath
|
||||
? path.basename(localPath.replace(/\/SKILL\.md$/i, ''))
|
||||
: undefined;
|
||||
const skillMdPath = localPath
|
||||
? path.resolve(path.dirname(LIBRARY_PATH), localPath)
|
||||
: undefined;
|
||||
return {
|
||||
name: s.id,
|
||||
description: s.description,
|
||||
compatibility: '',
|
||||
invoke_patterns: s.tags,
|
||||
dirName,
|
||||
path: skillMdPath,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pattern matching ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue