mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-28 12:35:13 +02:00
157 lines
8.6 KiB
JavaScript
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();
|