Files
santifer--career-ops/openrouter-runner.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

777 lines
28 KiB
JavaScript

#!/usr/bin/env node
/**
* career-ops OpenRouter Runner
* No Claude Code CLI required — uses OpenRouter free models with automatic fallback.
*
* Usage:
* node openrouter-runner.mjs scan → Scan Greenhouse API companies for new listings
* node openrouter-runner.mjs evaluate <url> → Evaluate a job by URL
* node openrouter-runner.mjs evaluate → Paste job text interactively
* node openrouter-runner.mjs pipeline → Process all pending URLs from pipeline.md
* node openrouter-runner.mjs apply <report_no> → Generate draft application form answers
* node openrouter-runner.mjs models → List available free models
* node openrouter-runner.mjs help → Show this help
*
* Setup:
* 1. copy .env.example .env
* 2. Add OPENROUTER_API_KEY=sk-or-v1-... to .env
* 3. Free API key: https://openrouter.ai
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import readline from 'node:readline';
import yaml from 'js-yaml';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ---------------------------------------------------------------------------
// .env loader
// ---------------------------------------------------------------------------
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
const m = line.trim().match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && process.env[m[1]] === undefined) {
process.env[m[1]] = m[2].trim().replace(/^(['"])(.*?)\1$/, '$2');
}
}
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
const MAX_TOKENS = 8192;
const RATE_LIMIT_DELAY_MS = 2500; // pause between requests on free tier
const MODEL_TIMEOUT_MS = 15_000; // abort a single model call after 15 s
// Provider priority order — models are sorted by provider prefix, not hardcoded names.
// Add, remove, or reorder providers here; model names are resolved at runtime from the API.
const PROVIDER_PRIORITY = [
'google',
'qwen',
'openai',
'meta-llama',
'nvidia',
'mistralai',
'nousresearch',
'minimax',
'arcee-ai',
// any unlisted provider goes last (alphabetical)
];
// In-memory model list — populated on first callOpenRouter()
let freeModels = null; // string[]
let modelIndex = 0; // current position in rotation
// Persistent blacklist file — survives process restarts
const BLACKLIST_FILE = path.join(__dirname, 'data', 'model-blacklist.json');
function loadPersistedBlacklist() {
try {
const data = JSON.parse(fs.readFileSync(BLACKLIST_FILE, 'utf-8'));
return Array.isArray(data) ? data : [];
} catch { return []; }
}
function saveBlacklist(set) {
try {
fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
fs.writeFileSync(BLACKLIST_FILE, JSON.stringify([...set], null, 2), 'utf-8');
} catch {}
}
// Models that failed permanently (403, timeout, persistent 429) — never retry
const blacklistedModels = new Set(loadPersistedBlacklist());
if (blacklistedModels.size > 0) {
console.log(`[blacklist] Loaded ${blacklistedModels.size} pre-blacklisted model(s) from disk.`);
}
// 429 failure count per model — auto-blacklist after 3 consecutive 429s
const rateLimitCounts = {};
// ---------------------------------------------------------------------------
// Fetch free models from OpenRouter API
// ---------------------------------------------------------------------------
async function loadFreeModels() {
if (freeModels !== null) return freeModels;
try {
const resp = await fetch(OPENROUTER_MODELS_URL, {
headers: { 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}` }
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
// A model is free when its prompt and completion pricing are both "0"
const list = (data.data ?? [])
.filter(m => {
const p = m.pricing ?? {};
return String(p.prompt) === '0' && String(p.completion) === '0';
})
.map(m => m.id);
if (list.length === 0) throw new Error('No free models found in API response');
// Sort by provider priority; within the same provider sort alphabetically
function providerOf(id) { return id.split('/')[0]; }
function priorityOf(id) {
const idx = PROVIDER_PRIORITY.indexOf(providerOf(id));
return idx === -1 ? PROVIDER_PRIORITY.length : idx;
}
freeModels = list.sort((a, b) => {
const diff = priorityOf(a) - priorityOf(b);
return diff !== 0 ? diff : a.localeCompare(b);
});
console.log(`[models] ${freeModels.length} free models loaded from OpenRouter API.`);
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
const hasKey = Boolean(process.env.OPENROUTER_API_KEY);
throw new Error(
`[models] Failed to fetch free model list: ${reason}. ` +
(hasKey ? 'Check that your API key is valid and that network access to OpenRouter is available.'
: 'OPENROUTER_API_KEY is not set — copy .env.example to .env and add your key.')
);
}
return freeModels;
}
// List and exit (helper command)
async function cmdModels() {
const models = await loadFreeModels();
console.log(`\nFree models available on OpenRouter (${models.length} total):\n`);
models.forEach((id, i) => console.log(` ${String(i + 1).padStart(2)}. ${id}`));
console.log('');
}
// ---------------------------------------------------------------------------
// File helpers
// ---------------------------------------------------------------------------
function readFile(relPath) {
try { return fs.readFileSync(path.join(__dirname, relPath), 'utf-8'); }
catch { return null; }
}
function writeFile(relPath, content) {
const full = path.join(__dirname, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, 'utf-8');
}
function fileExists(relPath) {
return fs.existsSync(path.join(__dirname, relPath));
}
// ---------------------------------------------------------------------------
// OpenRouter API call — automatic model rotation with fallback
// ---------------------------------------------------------------------------
async function callOpenRouter(systemPrompt, userMessage) {
const key = process.env.OPENROUTER_API_KEY;
if (!key) {
throw new Error(
'OPENROUTER_API_KEY not found.\n' +
'Copy .env.example to .env and add your API key.\n' +
'Free key: https://openrouter.ai'
);
}
const pinnedModel = process.env.CAREER_OPS_MODEL;
if (pinnedModel) {
process.stdout.write(`[model] ${pinnedModel} (pinned) ... `);
const body = JSON.stringify({
model: pinnedModel,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
max_tokens: MAX_TOKENS,
});
const ctrl = new AbortController();
const timerId = setTimeout(() => ctrl.abort(), MODEL_TIMEOUT_MS);
try {
const resp = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/santifer/career-ops',
'X-Title': 'career-ops',
},
body,
signal: ctrl.signal,
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`HTTP ${resp.status}: ${t.slice(0, 120)}`);
}
const data = await resp.json();
if (data.error) throw new Error(data.error.message);
const content = data.choices?.[0]?.message?.content ?? '';
if (!content) throw new Error('Empty response');
console.log('OK');
return content;
} catch (e) {
if (e.name === 'AbortError') throw new Error(`Pinned model timed out after ${MODEL_TIMEOUT_MS / 1000}s`);
throw e;
} finally {
clearTimeout(timerId);
}
}
const models = await loadFreeModels();
let lastError;
if (models.length === 0) {
throw new Error(
'No free OpenRouter models are available. Model loading may have failed, or your account currently has no free models.'
);
}
// Build the active (non-blacklisted) model list in rotation order
const active = models.filter(m => !blacklistedModels.has(m));
if (active.length === 0) throw new Error('All loaded models have been blacklisted this session.');
for (let attempt = 0; attempt < active.length; attempt++) {
const model = active[(modelIndex % active.length + attempt) % active.length];
process.stdout.write(`[model] ${model} ... `);
try {
const body = JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }
],
max_tokens: MAX_TOKENS,
});
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), MODEL_TIMEOUT_MS);
let data;
try {
const resp = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/santifer/career-ops',
'X-Title': 'career-ops',
},
body,
signal: controller.signal,
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`HTTP ${resp.status}: ${t.slice(0, 120)}`);
}
data = await resp.json();
} catch (e) {
if (e.name === 'AbortError') throw new Error(`Timeout after ${MODEL_TIMEOUT_MS / 1000}s`);
throw e;
} finally {
clearTimeout(timerId);
}
if (data.error) throw new Error(data.error.message);
const content = data.choices?.[0]?.message?.content ?? '';
if (!content) throw new Error('Empty response');
modelIndex = (modelIndex + attempt + 1) % active.length;
console.log('OK');
return content;
} catch (e) {
lastError = e;
const msg = e.message.split('\n')[0];
const is403 = msg.includes('HTTP 403');
const isTimeout = msg.startsWith('Timeout');
const is429 = msg.includes('HTTP 429') || msg.includes('rate-li') || msg.includes('rate limit') || msg.includes('temporarily rate');
if (is403 || isTimeout) {
blacklistedModels.add(model);
saveBlacklist(blacklistedModels);
console.log(`SKIP (blacklisted: ${msg})`);
} else if (is429) {
rateLimitCounts[model] = (rateLimitCounts[model] ?? 0) + 1;
if (rateLimitCounts[model] >= 3) {
blacklistedModels.add(model);
saveBlacklist(blacklistedModels);
console.log(`SKIP (auto-blacklisted: persistent 429)`);
} else {
console.log(`FAILED (HTTP 429 [${rateLimitCounts[model]}/3])`);
await new Promise(r => setTimeout(r, 800));
}
} else {
console.log(`FAILED (${msg})`);
await new Promise(r => setTimeout(r, 800));
}
}
}
throw new Error(`All ${active.length} active models failed. Last error: ${lastError?.message}`);
}
// ---------------------------------------------------------------------------
// Context loading
// ---------------------------------------------------------------------------
function loadContext() {
return {
cv: readFile('cv.md') ?? 'CV not found.',
profile: readFile('config/profile.yml') ?? '',
shared: readFile('modes/_shared.md') ?? '',
profileMode: readFile('modes/_profile.md') ?? '',
};
}
function buildSystemPrompt(modeContent, ctx) {
return [
ctx.shared,
ctx.profileMode,
modeContent,
'---',
'CANDIDATE PROFILE (YAML):',
ctx.profile,
'---',
'CV (Markdown):',
ctx.cv,
].filter(Boolean).join('\n\n');
}
// ---------------------------------------------------------------------------
// Job page content fetcher (Playwright-first, plain fetch fallback)
// ---------------------------------------------------------------------------
// Reject unsafe fetch targets (SSRF defense-in-depth): http(s) only, never
// loopback / link-local / private / cloud-metadata hosts. URLs come from the
// user's own portals.yml / pipeline.md, but we still fail closed.
function assertSafeRemoteUrl(url) {
let u;
try { u = new URL(url); } catch { throw new Error(`Invalid URL: ${url}`); }
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new Error(`Refusing non-HTTP(S) URL: ${url}`);
}
const host = u.hostname.toLowerCase();
const blocked = host === 'localhost' || host === '::1' || host.endsWith('.local') ||
/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) ||
/^169\.254\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
if (blocked) throw new Error(`Refusing private/loopback host: ${host}`);
return u;
}
async function fetchJobPage(url) {
assertSafeRemoteUrl(url);
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
console.warn('[fetch] Playwright unavailable — falling back to plain fetch.');
}
if (chromium) {
let browser;
try {
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForTimeout(2000); // wait for SPA render
const text = await page.evaluate(() => {
document.querySelectorAll('script,style,nav,footer,header').forEach(el => el.remove());
return (document.body?.innerText || document.body?.textContent || '').replace(/\s+/g, ' ').trim();
});
return text.slice(0, 16_000);
} catch (e) {
console.warn(`[fetch] Playwright error: ${e.message} — falling back to plain fetch.`);
} finally {
if (browser) await browser.close().catch(() => {});
}
}
// Plain HTTP fallback
try {
const r = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; career-ops/1.0)' }
});
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
const html = await r.text();
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 16_000);
} catch (e) {
throw new Error(`Could not fetch job page: ${e.message}`);
}
}
// ---------------------------------------------------------------------------
// portals.yml parser — reads the canonical schema with js-yaml (same library and
// field names as scan.mjs: `title_filter.positive/negative` + `tracked_companies`),
// so it never drifts from the main scanner. The runner's no-CLI scan path covers
// companies that expose a direct JSON `api:`; careers_url-only / Playwright /
// search-query companies are handled by the full /career-ops scan pipeline.
// `rawOverride` lets tests feed YAML text directly (see test-all.mjs drift guard).
// ---------------------------------------------------------------------------
function normKeywords(v) {
if (!Array.isArray(v)) return [];
return v.map(x => String(x ?? '').toLowerCase().trim()).filter(Boolean);
}
export function parsePortals(rawOverride) {
const raw = rawOverride ?? readFile('portals.yml');
if (!raw) throw new Error('portals.yml not found');
const config = yaml.load(raw) || {};
const tf = config.title_filter || {};
const positive = normKeywords(tf.positive);
const negative = normKeywords(tf.negative);
function titleMatches(title) {
const t = String(title ?? '').toLowerCase();
return positive.some(k => t.includes(k)) && !negative.some(k => t.includes(k));
}
// Companies with a direct JSON `api:` endpoint (the no-CLI scan path).
const tracked = Array.isArray(config.tracked_companies) ? config.tracked_companies : [];
const companies = tracked
.filter(c => c && c.api && c.enabled !== false)
.map(c => ({ name: String(c.name ?? c.company ?? 'Unknown'), api: String(c.api).trim() }));
return { companies, titleMatches };
}
// ---------------------------------------------------------------------------
// pipeline.md management
// ---------------------------------------------------------------------------
function readPipeline() {
const content = readFile('data/pipeline.md') ?? '';
const pending = [];
for (const line of content.split('\n')) {
const m = line.match(/^- \[ \] (.+)/);
if (m) {
const parts = m[1].split(' | ');
pending.push({
url: parts[0]?.trim() ?? '',
company: parts[1]?.trim() ?? 'Unknown',
role: parts[2]?.trim() ?? 'Unknown',
});
}
}
return pending;
}
function markPipelineDone(url) {
let content = readFile('data/pipeline.md') ?? '';
const escaped = url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
content = content.replace(
new RegExp(`^(- \\[ \\] ${escaped}.*)$`, 'm'),
(ln) => ln.replace('- [ ]', '- [x]')
);
writeFile('data/pipeline.md', content);
}
function addToPipeline(entries) {
const history = readFile('data/scan-history.tsv') ?? 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\tlocation\n';
const seenUrls = new Set(history.split('\n').slice(1).map(l => l.split('\t')[0]).filter(Boolean));
const existingPipeline = readFile('data/pipeline.md') ?? '# Pipeline\n\n## Pending\n';
const existingApps = readFile('data/applications.md') ?? '';
// extract URLs already tracked in applications.md (mirrors scan.mjs dedup logic)
const appliedUrls = new Set(
existingApps.split('\n')
.map(l => l.match(/https?:\/\/[^\s|)]+/))
.filter(Boolean).map(m => m[0])
);
const newEntries = entries.filter(e => {
if (seenUrls.has(e.url)) return false;
if (appliedUrls.has(e.url)) return false;
// skip if already queued in pipeline
if (existingPipeline.includes(e.url)) return false;
return true;
});
if (newEntries.length === 0) return 0;
const today = new Date().toISOString().split('T')[0];
let pipeline = existingPipeline;
let hist = history;
for (const e of newEntries) {
pipeline += `- [ ] ${e.url} | ${e.company} | ${e.role}\n`;
hist += `${e.url}\t${today}\tscan\t${e.role}\t${e.company}\tadded\t${e.location ?? ''}\n`;
}
writeFile('data/pipeline.md', pipeline);
writeFile('data/scan-history.tsv', hist);
return newEntries.length;
}
// ---------------------------------------------------------------------------
// Report numbering
// ---------------------------------------------------------------------------
function nextReportNum() {
try {
const nums = fs.readdirSync(path.join(__dirname, 'reports'))
.map(f => parseInt(f.match(/^(\d+)/)?.[1] ?? '0', 10))
.filter(n => n > 0);
return nums.length ? Math.max(...nums) + 1 : 1;
} catch { return 1; }
}
function extractCompanySlug(text, url) {
// Try to extract from text (e.g. "Senior Engineer at Acme" or "Company: Acme")
const m = text.match(/(?:at|@|company[:\s]+)\s*([A-Z][A-Za-z0-9]{2,25})/);
if (m) return m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-');
// Fall back to URL hostname (e.g. job-boards.greenhouse.io/acme → acme)
if (url) {
try {
const parts = new URL(url).pathname.split('/').filter(Boolean);
const slug = parts[0] ?? 'company';
return slug.toLowerCase().replace(/[^a-z0-9]+/g, '-');
} catch { /* not a valid URL */ }
}
return 'company';
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
// -- SCAN --
async function cmdScan() {
console.log('Scanning Greenhouse portals...\n');
let portals;
try { portals = parsePortals(); }
catch (e) { console.error(e.message); return; }
const { companies, titleMatches } = portals;
console.log(`Greenhouse API companies: ${companies.length}\n`);
const found = [];
for (const c of companies) {
process.stdout.write(` ${c.name.padEnd(25)} → `);
try {
assertSafeRemoteUrl(c.api);
const r = await fetch(c.api);
if (!r.ok) { console.log(`HTTP ${r.status}`); continue; }
const data = await r.json();
const jobs = data.jobs ?? [];
const matched = jobs.filter(j => titleMatches(j.title));
console.log(`${jobs.length} listings, ${matched.length} matched`);
for (const j of matched) {
// Skip postings without a public URL — falling back to the API
// endpoint would write an unusable link into the pipeline.
if (!j.absolute_url) continue;
found.push({ url: j.absolute_url, company: c.name, role: j.title, location: j.location?.name ?? '' });
}
} catch (e) {
console.log(`ERROR: ${e.message}`);
}
}
const added = addToPipeline(found);
console.log(`\n✅ Scan complete. ${found.length} matches, ${added} new entries added to pipeline.md.`);
if (added > 0) {
console.log('\n→ node openrouter-runner.mjs pipeline\n to evaluate pending listings.\n');
}
}
// -- EVALUATE --
async function cmdEvaluate(input, ctx) {
const modeContent = readFile('modes/oferta.md') ?? readFile('modes/auto-pipeline.md') ?? '';
let jdText = input;
if (!input) {
// Interactive paste mode
console.log('Paste job description or URL, then press Enter on an empty line:\n');
const rl = readline.createInterface({ input: process.stdin });
const lines = [];
try {
for await (const line of rl) {
if (line === '') break;
lines.push(line);
}
} finally {
rl.close();
}
jdText = lines.join('\n');
if (!jdText.trim()) { console.log('No input provided.'); return null; }
} else if (input.startsWith('http')) {
console.log('Fetching job page...');
try {
const content = await fetchJobPage(input);
jdText = `URL: ${input}\n\n${content}`;
} catch (e) {
console.error(e.message);
return null;
}
}
console.log('\nEvaluating...');
const systemPrompt = buildSystemPrompt(modeContent, ctx);
let result;
try {
result = await callOpenRouter(systemPrompt, `Evaluate this job listing:\n\n${jdText}`);
} catch (e) {
console.error(`OpenRouter error: ${e.message}`);
return null;
}
// Save report
const today = new Date().toISOString().split('T')[0];
const num = nextReportNum();
const slug = extractCompanySlug(jdText, typeof input === 'string' ? input : null);
const numStr = String(num).padStart(3, '0');
const relPath = `reports/${numStr}-${slug}-${today}.md`;
// Extract Legitimacy from LLM output or fall back to placeholder
const legitMatch = result.match(/\*\*Legitimacy:\*\*\s*([^\n]+)/);
const legitLine = legitMatch ? `**Legitimacy:** ${legitMatch[1].trim()}` : '**Legitimacy:** unconfirmed';
writeFile(relPath, `**URL:** ${input || '(pasted)'}\n${legitLine}\n\n${result}`);
const scoreMatch = result.match(/(?:score|puntuaci[oó]n)[^\d]*(\d+\.?\d*)/i);
const scoreValue = scoreMatch ? parseFloat(scoreMatch[1]) : NaN;
const scoreStr = isFinite(scoreValue) ? `${scoreValue.toFixed(1)}/5` : '';
const companyName = slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const reportLink = `[${numStr}](reports/${numStr}-${slug}-${today}.md)`;
const tsvLine = `${num}\t${today}\t${companyName}\t(see report)\tEvaluated\t${scoreStr}\t❌\t${reportLink}\t\n`;
const tsvFile = `batch/tracker-additions/or-${numStr}-${slug}.tsv`;
writeFile(tsvFile, `num\tdate\tcompany\trole\tstatus\tscore\tpdf\treport\tnotes\n${tsvLine}`);
console.log(`\n✅ Report saved: ${relPath}`);
console.log('\n─── EVALUATION ──────────────────────────────────────\n');
console.log(result);
console.log('\n─────────────────────────────────────────────────────\n');
return relPath;
}
// -- PIPELINE --
async function cmdPipeline(ctx) {
const pending = readPipeline();
if (pending.length === 0) {
console.log('No pending listings in pipeline.md.');
return;
}
console.log(`Processing ${pending.length} pending listing(s) from pipeline.md...\n`);
for (let i = 0; i < pending.length; i++) {
const item = pending[i];
console.log(`\n[${i + 1}/${pending.length}] ${item.company}${item.role}`);
try {
const report = await cmdEvaluate(item.url, ctx);
if (report) markPipelineDone(item.url);
} catch (e) {
console.error(` Error: ${e.message}`);
}
if (i < pending.length - 1) {
await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY_MS));
}
}
console.log('\n✅ Pipeline processing complete.\n');
}
// -- APPLY --
async function cmdApply(ref, ctx) {
const modeContent = readFile('modes/apply.md') ?? '';
let reportContent;
if (fileExists(ref)) {
reportContent = readFile(ref);
} else {
const numStr = String(ref).padStart(3, '0');
const reportsDir = path.join(__dirname, 'reports');
const dirEntries = fs.existsSync(reportsDir) ? fs.readdirSync(reportsDir) : [];
const matches = dirEntries.filter(f => f.startsWith(numStr));
if (matches.length === 0) {
console.error(`Report not found: ${ref}`);
return;
}
reportContent = readFile(`reports/${matches[0]}`);
}
if (!reportContent) { console.error('Could not read report content.'); return; }
console.log('Generating application form answers...');
const systemPrompt = buildSystemPrompt(modeContent, ctx);
let result;
try {
result = await callOpenRouter(
systemPrompt,
`Generate application form answers based on this evaluation report:\n\n${reportContent}`
);
} catch (e) {
console.error(`OpenRouter error: ${e.message}`);
return;
}
console.log('\n─── APPLICATION ANSWERS ─────────────────────────────\n');
console.log(result);
console.log('\n─────────────────────────────────────────────────────\n');
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
// Only run the CLI when invoked directly (`node openrouter-runner.mjs ...`), so the
// module can be imported (e.g. by test-all.mjs) without executing a command.
const invokedDirectly = process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
const [,, command, ...args] = invokedDirectly ? process.argv : [];
const ctx = invokedDirectly ? loadContext() : null;
// Load free models list before running any AI command (skip when a model is pinned)
if (invokedDirectly && ['evaluate', 'eval', 'pipeline', 'apply', 'models'].includes(command) && !process.env.CAREER_OPS_MODEL) {
await loadFreeModels();
}
if (invokedDirectly) switch (command) {
case 'scan':
await cmdScan();
break;
case 'evaluate':
case 'eval':
await cmdEvaluate(args.join(' ').trim() || null, ctx);
break;
case 'pipeline':
await cmdPipeline(ctx);
break;
case 'apply':
if (!args[0]) { console.error('Usage: node openrouter-runner.mjs apply <report_num|report_path>'); break; }
await cmdApply(args[0], ctx);
break;
case 'models':
await cmdModels();
break;
default:
console.log(`
career-ops OpenRouter Runner
Auto-fetches free models from OpenRouter API and rotates through them with fallback.
COMMANDS:
node openrouter-runner.mjs scan → Scan Greenhouse APIs for new matching listings
node openrouter-runner.mjs evaluate <url> → Evaluate a listing by URL
node openrouter-runner.mjs evaluate → Paste a job description interactively
node openrouter-runner.mjs pipeline → Batch-evaluate all pending entries in pipeline.md
node openrouter-runner.mjs apply <report_no> → Generate application form answers from a report
node openrouter-runner.mjs models → List available free models from OpenRouter
SETUP:
1. Copy .env.example to .env
2. Add your key: OPENROUTER_API_KEY=sk-or-v1-...
3. Free API key: https://openrouter.ai
MODEL SELECTION:
- Free models are fetched automatically via the OpenRouter API at runtime.
- They are tried in sequence; if one fails the next is used automatically.
- Pin a model: CAREER_OPS_MODEL=deepseek/deepseek-r1:free node openrouter-runner.mjs eval <url>
`);
}