Files
ECC/scripts/dev/generate-skill-triggers.js
T

157 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
'use strict';
// Dev-time generator for manifests/context-packs/skill-triggers@1.json.
//
// For every canonical skill, asks the pinned provider for short trigger
// phrasings a user would type when that skill applies (synonyms, task
// wordings, related technology names), grounded STRICTLY in the skill's own
// description. The manifest is checked in, digest-stable, and read by the
// retrieval index at runtime, so runtime behavior stays deterministic and
// offline. Rerun this script after adding or re-describing skills.
//
// Usage:
// node scripts/dev/generate-skill-triggers.js --auth-home ~/.ecc-eval/auth \
// [--model gpt-5.6-sol] [--executable /path/to/codex] [--batch 25] [--dry-run]
// node scripts/dev/generate-skill-triggers.js --provider claude \
// [--model claude-sonnet-5] [--executable /path/to/claude] [--batch 40] [--dry-run]
//
// Codex requires an isolated executable and a dedicated subscription login
// home (the same lease rules as the outcome evaluator: never the user's own
// Codex home). Claude authenticates through CLAUDE_CODE_OAUTH_TOKEN,
// ANTHROPIC_API_KEY, or the macOS Keychain login, with an isolated
// CLAUDE_CONFIG_DIR per call. Provider calls: ceil(skills / batch).
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { loadContextRegistry } = require('../lib/context-pack-registry');
const { createAuthLease, parseCodexJsonl, parseClaudeJson, providerFamily, readClaudeKeychainToken } = require('../../docker/context-profiles/ai-eval-lib');
const { digestObject, stableStringify } = require('../lib/context-profile-support');
const MANIFEST_PATH = 'manifests/context-packs/skill-triggers@1.json';
const MAX_TRIGGERS_PER_SKILL = 12;
const MAX_TRIGGER_CHARS = 80;
const DEFAULT_MODEL = { codex: 'gpt-5.6-sol', claude: 'claude-sonnet-5' };
function parseFlags(argv) {
const flags = { batch: 25 };
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--dry-run') flags.dryRun = true;
else if (['--auth-home', '--model', '--executable', '--batch', '--provider'].includes(arg)) {
flags[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = argv[index += 1];
} else throw new Error(`Unknown flag: ${arg}`);
}
if (!/^[1-9][0-9]*$/.test(String(flags.batch)) || !Number.isSafeInteger(Number(flags.batch))) {
throw new Error('--batch must be a positive integer');
}
flags.batch = Number(flags.batch);
return flags;
}
function promptFor(batch) {
const lines = batch.map(entry => ({ id: entry.id, name: entry.name, description: entry.description }));
return `You generate retrieval triggers for a skills library. For EACH skill below, output a JSON object mapping its id to an array of ${MAX_TRIGGERS_PER_SKILL} short trigger phrases (each under ${MAX_TRIGGER_CHARS} characters): realistic task wordings, synonyms, and related technology names a developer would type when this skill applies. Ground every trigger ONLY in the skill description; never invent capabilities the description does not claim. Prefer concrete task phrasings over category words. Output ONE JSON object and nothing else.\n\n${JSON.stringify(lines, null, 1)}`;
}
function extractJson(text) {
const trimmed = text.trim();
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start < 0 || end <= start) throw new Error('Provider returned no JSON object');
return JSON.parse(trimmed.slice(start, end + 1));
}
function cleanTriggers(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value.map(item => String(item).trim().toLowerCase()).filter(item => {
if (!item || item.length > MAX_TRIGGER_CHARS || seen.has(item)) return false;
if (!/^[a-z0-9][a-z0-9 +/#.:-]*$/.test(item)) return false;
seen.add(item);
return true;
}).slice(0, MAX_TRIGGERS_PER_SKILL);
}
function main() {
const flags = parseFlags(process.argv);
const repoRoot = path.join(__dirname, '..', '..');
const registry = loadContextRegistry({ repoRoot });
const entries = registry.entries.filter(entry => entry.id.startsWith('skill:'));
const executable = flags.executable || (flags.provider === 'claude' ? 'claude' : `${process.env.HOME}/.ecc-eval/codex/node_modules/.bin/codex`);
const family = flags.provider || providerFamily(executable);
const model = flags.model || DEFAULT_MODEL[family];
if (flags.dryRun) {
console.log(`would generate triggers for ${entries.length} skills via ${family} (${model}) in ${Math.ceil(entries.length / flags.batch)} provider calls`);
return;
}
if (family === 'codex' && (!flags.authHome || !path.isAbsolute(flags.authHome))) throw new Error('--auth-home with an absolute dedicated login home is required for Codex');
const lease = family === 'codex' ? createAuthLease(flags.authHome) : null;
const claudeToken = () => process.env.CLAUDE_CODE_OAUTH_TOKEN || readClaudeKeychainToken();
const triggers = {};
const failed = [];
const callProvider = batch => {
const home = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ecc-trigger-gen-'));
try {
if (family === 'codex') {
let parsed = null;
lease.run(home, () => {
const env = { PATH: process.env.PATH, HOME: home, CODEX_HOME: home, LANG: 'C.UTF-8' };
const result = require('node:child_process').spawnSync(executable,
['exec', '--json', '--ephemeral', '--skip-git-repo-check', '--sandbox', 'read-only',
'--disable', 'apps', '--disable', 'remote_plugin', '-c', 'approval_policy="never"',
'-c', 'model_reasoning_effort="low"', '--model', model, '-'],
{ input: promptFor(batch), cwd: home, env, encoding: 'utf8', shell: false,
timeout: 240000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 });
if (result.status !== 0) throw new Error(`provider exited ${result.status}`);
parsed = extractJson(parseCodexJsonl(result.stdout).text);
});
return parsed;
}
const env = { PATH: process.env.PATH, HOME: home, CLAUDE_CONFIG_DIR: home, LANG: 'C.UTF-8',
DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1', CLAUDE_CODE_OAUTH_TOKEN: claudeToken() };
const result = require('node:child_process').spawnSync(executable,
['--print', '--output-format', 'json', '--tools', '', '--no-session-persistence', '--model', model],
{ input: promptFor(batch), cwd: home, env, encoding: 'utf8', shell: false,
timeout: 240000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 });
if (result.status !== 0) throw new Error(`provider exited ${result.status}`);
return extractJson(parseClaudeJson(result.stdout).text);
} finally { fs.rmSync(home, { recursive: true, force: true, maxRetries: 5 }); }
};
// Model-generated JSON degrades at batch scale: retry each batch once, then halve until singles.
const processBatch = batch => {
try {
const parsed = callProvider(batch);
let ok = 0;
for (const entry of batch) {
const cleaned = cleanTriggers(parsed[entry.id]);
if (cleaned.length) { triggers[entry.id] = cleaned; ok += 1; }
}
if (!ok) throw new Error('provider returned no usable triggers');
} catch (error) {
if (batch.length === 1) { failed.push(batch[0].id); console.error(`skill ${batch[0].id}: ${error.message}`); return; }
const half = Math.ceil(batch.length / 2);
processBatch(batch.slice(0, half));
processBatch(batch.slice(half));
}
};
for (let index = 0; index < entries.length; index += flags.batch) {
processBatch(entries.slice(index, index + flags.batch));
console.log(`progress: ${Object.keys(triggers).length}/${entries.length} skills have triggers`);
}
const manifest = { schemaVersion: 1, id: 'skill-triggers@1', registryDigest: registry.registryDigest,
model: { id: model, ...(family === 'codex' ? { effort: 'low' } : {}),
source: family === 'codex' ? 'codex-subscription-lease' : 'claude-subscription-login' },
generatedAt: new Date().toISOString(),
coverage: { skills: entries.length, withTriggers: Object.keys(triggers).length },
triggers, triggersDigest: digestObject(triggers) };
const target = path.join(repoRoot, MANIFEST_PATH);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${stableStringify(manifest)}\n`);
console.log(`wrote ${MANIFEST_PATH}: ${manifest.coverage.withTriggers}/${manifest.coverage.skills} skills, ${Object.values(triggers).reduce((n, t) => n + t.length, 0)} triggers`);
if (failed.length) { console.error(`skills with no usable triggers: ${failed.join(', ')}`); process.exitCode = 1; }
}
main();