261 lines
8.7 KiB
JavaScript
261 lines
8.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Generate hybrid-codex piece variants from standard pieces.
|
|
*
|
|
* For each standard piece (not already -hybrid-codex, not in skip list):
|
|
* 1. Parse the YAML
|
|
* 2. Add `provider: codex` to all coder movements (including parallel sub-movements)
|
|
* 3. Change name to {name}-hybrid-codex
|
|
* 4. Write the hybrid-codex YAML file
|
|
* 5. Update piece-categories.yaml to include generated hybrids
|
|
*
|
|
* Usage:
|
|
* node tools/generate-hybrid-codex.mjs # Generate all
|
|
* node tools/generate-hybrid-codex.mjs --dry-run # Preview only
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
import { join, basename, dirname } from 'node:path';
|
|
import { parse, stringify } from 'yaml';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(__dirname, '..');
|
|
const BUILTINS = join(ROOT, 'builtins');
|
|
const LANGUAGES = ['en', 'ja'];
|
|
|
|
/** Pieces that should NOT get hybrid variants (no coder involvement or special purpose) */
|
|
const SKIP_PIECES = new Set(['magi', 'research', 'review-only']);
|
|
|
|
const CODER_PERSONA = 'coder';
|
|
const dryRun = process.argv.includes('--dry-run');
|
|
|
|
// ─────────────────────────────────────────
|
|
// Movement transformation
|
|
// ─────────────────────────────────────────
|
|
|
|
function hasCoderPersona(movement) {
|
|
if (movement.persona === CODER_PERSONA) return true;
|
|
if (movement.parallel) return movement.parallel.some(sub => sub.persona === CODER_PERSONA);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Insert a field into an object after specified anchor fields (preserves key order).
|
|
* If anchor not found, appends at end.
|
|
*/
|
|
function insertFieldAfter(obj, key, value, anchorFields) {
|
|
if (obj[key] === value) return obj;
|
|
const result = {};
|
|
let inserted = false;
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (k === key) continue; // Remove existing (will re-insert)
|
|
result[k] = v;
|
|
if (!inserted && anchorFields.includes(k)) {
|
|
result[key] = value;
|
|
inserted = true;
|
|
}
|
|
}
|
|
if (!inserted) result[key] = value;
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Add `provider: codex` to all coder movements (recursively handles parallel).
|
|
*/
|
|
function addCodexToCoders(movements) {
|
|
return movements.map(m => {
|
|
if (m.parallel) {
|
|
return { ...m, parallel: addCodexToCoders(m.parallel) };
|
|
}
|
|
if (m.persona === CODER_PERSONA) {
|
|
return insertFieldAfter(m, 'provider', 'codex', ['knowledge', 'stance', 'persona']);
|
|
}
|
|
return m;
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────
|
|
// Hybrid piece builder
|
|
// ─────────────────────────────────────────
|
|
|
|
/** Top-level field order for readable output */
|
|
const TOP_FIELD_ORDER = [
|
|
'name', 'description', 'max_iterations',
|
|
'stances', 'knowledge', 'personas', 'instructions', 'report_formats',
|
|
'initial_movement', 'loop_monitors', 'answer_agent', 'movements',
|
|
];
|
|
|
|
function buildHybrid(parsed) {
|
|
const hybrid = {};
|
|
for (const field of TOP_FIELD_ORDER) {
|
|
if (field === 'name') {
|
|
hybrid.name = `${parsed.name}-hybrid-codex`;
|
|
} else if (field === 'movements') {
|
|
hybrid.movements = addCodexToCoders(parsed.movements);
|
|
} else if (parsed[field] != null) {
|
|
hybrid[field] = parsed[field];
|
|
}
|
|
}
|
|
// Carry over any extra top-level fields not in the order list
|
|
for (const key of Object.keys(parsed)) {
|
|
if (!(key in hybrid) && key !== 'name') {
|
|
hybrid[key] = parsed[key];
|
|
}
|
|
}
|
|
return hybrid;
|
|
}
|
|
|
|
function generateHeader(sourceFile) {
|
|
return [
|
|
`# Auto-generated from ${sourceFile} by tools/generate-hybrid-codex.mjs`,
|
|
'# Do not edit manually. Edit the source piece and re-run the generator.',
|
|
'',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
// ─────────────────────────────────────────
|
|
// Category handling
|
|
// ─────────────────────────────────────────
|
|
|
|
/** Recursively collect all piece names from a category tree */
|
|
function collectPieces(obj) {
|
|
const pieces = [];
|
|
if (!obj || typeof obj !== 'object') return pieces;
|
|
if (Array.isArray(obj.pieces)) pieces.push(...obj.pieces);
|
|
for (const [key, val] of Object.entries(obj)) {
|
|
if (key === 'pieces') continue;
|
|
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
|
pieces.push(...collectPieces(val));
|
|
}
|
|
}
|
|
return pieces;
|
|
}
|
|
|
|
/** Find the key for the hybrid top-level category */
|
|
function findHybridTopKey(categories) {
|
|
for (const key of Object.keys(categories)) {
|
|
if (key.includes('Hybrid') || key.includes('ハイブリッド')) return key;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build mapping: standard piece name → top-level category key.
|
|
* Excludes the hybrid category and "Others" category.
|
|
*/
|
|
function getTopLevelMapping(categories, hybridKey, othersKey) {
|
|
const map = new Map();
|
|
for (const [key, val] of Object.entries(categories)) {
|
|
if (key === hybridKey) continue;
|
|
if (othersKey && key === othersKey) continue;
|
|
if (typeof val !== 'object' || val === null) continue;
|
|
const pieces = collectPieces(val);
|
|
for (const p of pieces) map.set(p, key);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Build the hybrid category section by mirroring standard categories.
|
|
*/
|
|
function buildHybridCategories(generatedNames, topMap) {
|
|
// Group hybrids by their source piece's top-level category
|
|
const grouped = new Map();
|
|
for (const hybridName of generatedNames) {
|
|
const sourceName = hybridName.replace('-hybrid-codex', '');
|
|
const topCat = topMap.get(sourceName);
|
|
if (!topCat) continue;
|
|
if (!grouped.has(topCat)) grouped.set(topCat, []);
|
|
grouped.get(topCat).push(hybridName);
|
|
}
|
|
|
|
const section = {};
|
|
for (const [catKey, hybrids] of grouped) {
|
|
section[catKey] = { pieces: hybrids.sort() };
|
|
}
|
|
return section;
|
|
}
|
|
|
|
// ─────────────────────────────────────────
|
|
// Main
|
|
// ─────────────────────────────────────────
|
|
|
|
console.log('=== Generating hybrid-codex pieces ===\n');
|
|
|
|
for (const lang of LANGUAGES) {
|
|
console.log(`[${lang}]`);
|
|
const generatedNames = [];
|
|
|
|
const piecesDir = join(BUILTINS, lang, 'pieces');
|
|
const files = readdirSync(piecesDir)
|
|
.filter(f => f.endsWith('.yaml') && !f.includes('-hybrid-codex'))
|
|
.sort();
|
|
|
|
for (const file of files) {
|
|
const name = basename(file, '.yaml');
|
|
if (SKIP_PIECES.has(name)) {
|
|
console.log(` Skip: ${name} (in skip list)`);
|
|
continue;
|
|
}
|
|
|
|
const content = readFileSync(join(piecesDir, file), 'utf-8');
|
|
const parsed = parse(content);
|
|
|
|
if (!parsed.movements?.some(hasCoderPersona)) {
|
|
console.log(` Skip: ${name} (no coder movements)`);
|
|
continue;
|
|
}
|
|
|
|
const hybrid = buildHybrid(parsed);
|
|
const header = generateHeader(file);
|
|
const yamlOutput = stringify(hybrid, { lineWidth: 120, indent: 2 });
|
|
const outputPath = join(piecesDir, `${name}-hybrid-codex.yaml`);
|
|
|
|
if (dryRun) {
|
|
console.log(` Would generate: ${name}-hybrid-codex.yaml`);
|
|
} else {
|
|
writeFileSync(outputPath, header + yamlOutput, 'utf-8');
|
|
console.log(` Generated: ${name}-hybrid-codex.yaml`);
|
|
}
|
|
|
|
generatedNames.push(`${name}-hybrid-codex`);
|
|
}
|
|
|
|
// ─── Update piece-categories.yaml ───
|
|
const catPath = join(BUILTINS, lang, 'piece-categories.yaml');
|
|
const catRaw = readFileSync(catPath, 'utf-8');
|
|
const catParsed = parse(catRaw);
|
|
const cats = catParsed.piece_categories;
|
|
|
|
if (cats) {
|
|
const hybridKey = findHybridTopKey(cats);
|
|
const othersKey = Object.keys(cats).find(k =>
|
|
k === 'Others' || k === 'その他'
|
|
);
|
|
|
|
if (hybridKey) {
|
|
const topMap = getTopLevelMapping(cats, hybridKey, othersKey);
|
|
const newSection = buildHybridCategories(generatedNames, topMap);
|
|
cats[hybridKey] = newSection;
|
|
|
|
if (dryRun) {
|
|
console.log(` Would update: piece-categories.yaml`);
|
|
console.log(` Hybrid pieces: ${generatedNames.join(', ')}`);
|
|
} else {
|
|
const catOut = stringify(catParsed, { lineWidth: 120, indent: 2 });
|
|
writeFileSync(catPath, catOut, 'utf-8');
|
|
console.log(` Updated: piece-categories.yaml`);
|
|
}
|
|
} else {
|
|
console.log(` Warning: No hybrid category found in piece-categories.yaml`);
|
|
}
|
|
}
|
|
|
|
console.log();
|
|
}
|
|
|
|
console.log('Done!');
|
|
if (dryRun) console.log('(dry-run mode, no files were written)');
|