openrouter-runner.mjs
Rama main · réplica de lectura del repositorio original.
#!/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 { loadYaml } from './lib/yaml-document.mjs';
import { outputLanguageInstruction, parseOutputLanguage } from './profile-language.mjs';
import { TSV_ADDITION_HEADER } from './tracker-parse.mjs';
import { normalizedTrackerScore } from './lib/tracker-addition.mjs';
import {
formatReportNumber, releaseReportNumbers, reserveReportNumbers,
} from './reserve-report-num.mjs';
import { TokenAccumulator, formatBreakdown, normalizeOpenAIUsage } from './utils/token-tracker.mjs';
import { anonymizeContext } from './utils/privacy-filter.mjs';
import { DEFAULT_USER_AGENT } from './user-agent.mjs';
import { buildTitleFilter } from './title-keywords.mjs';
import {
appendToPipeline, appendToScanHistory, atomicWriteFile, BROWSER_PROCESS_ENV,
} from './scan.mjs';
import { withPipelineLock } from './pipeline-lock.mjs';
import { createAdmissionGate, formatSkipCounts } from './lib/admission-gate.mjs';
import { localToday } from './lib/local-today.mjs';
import { getCareerOpsRoot, getProfilePath } from './path-resolver.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { withJdArchiveSection } from './lib/jd-archive-section.mjs';
import { locateApplyReport } from './apply-scope.mjs';
import { classifyLiveness, isUnreadableVerdict, JOB_ID_PATTERN } from './liveness-core.mjs';
import { fetchJdViaKnownApi } from './browser-extract.mjs';
import { HYDRATING_CODES, extractApplyControls, postingIdentity } from './liveness-browser.mjs';
import { installPiiInterceptor } from './lib/pii-interceptor.mjs';
import { assertLlmRequestAllowed } from './lib/llm-outbound.mjs';
import { browserLaunchOptions } from './lib/browser-launch.mjs';
// Ambient privacy guard over global fetch. Installed explicitly here (rather
// than relying on NODE_OPTIONS) so the OpenRouter path is guarded in a plain
// checkout. Idempotent — a second install is a no-op.
installPiiInterceptor();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const tracker = new TokenAccumulator();
let activeModel = null;
// ---------------------------------------------------------------------------
// .env loader
// ---------------------------------------------------------------------------
// Lazy: only runs when this file is the CLI entry point (`node
// openrouter-runner.mjs ...`). Importing the module (e.g. for buildSystemPrompt
// in test-all.mjs) must NOT mutate process.env — a module-level loader here
// leaked every .env key (including CAREER_OPS_CLI) into the importing process
// and broke later CLI-resolution tests.
function loadEnvFile() {
const envPath = path.join(__dirname, '.env');
if (!fs.existsSync(envPath)) return;
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 DEFAULT_MODEL_TIMEOUT_MS = 120_000;
// setTimeout() treats delays above 2^31-1 ms as 1 ms, so larger overrides are rejected.
const MAX_TIMEOUT_MS = 2_147_483_647;
const parseTimeoutOverride = (val) => {
if (typeof val !== 'string') return null;
const trimmed = val.trim();
if (!/^\d+$/.test(trimmed)) return null;
const parsed = Number.parseInt(trimmed, 10);
return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_TIMEOUT_MS ? parsed : null;
};
// Resolved on each call (not at import) so overrides loaded later from .env apply.
const getModelTimeoutMs = () =>
parseTimeoutOverride(process.env.CAREER_OPS_TIMEOUT_MS) ??
parseTimeoutOverride(process.env.OPENROUTER_TIMEOUT_MS) ??
DEFAULT_MODEL_TIMEOUT_MS;
// ---------------------------------------------------------------------------
// Quota & response validation errors
// ---------------------------------------------------------------------------
export class QuotaExhaustedError extends Error {
constructor(message = 'OpenRouter free-model daily quota exhausted') {
super(message);
this.name = 'QuotaExhaustedError';
}
}
export function isDailyQuotaExhausted(status, bodyText) {
if (status !== 429) return false;
const lower = String(bodyText || '').toLowerCase();
return lower.includes('free-models-per-day') ||
lower.includes('daily limit exceeded') ||
lower.includes('free model daily quota') ||
lower.includes('per-day limit') ||
lower.includes('daily quota exceeded');
}
export function validateEvaluationResponse(content) {
if (!content || typeof content !== 'string') {
return { ok: false, reason: 'empty_response' };
}
const text = content.trim();
if (text.length === 0) {
return { ok: false, reason: 'empty_response' };
}
// 1. Reject malformed tool-call markup
const toolCallPatterns = [
/<tool_call[\s>]/i,
/<\/tool_call>/i,
/<dots_function_call[\s>]/i,
/<\/dots_function_call>/i,
/<\|tool_call_start\|>/i,
/<\|tool_call_end\|>/i,
/<arg_key>/i,
/<arg_value>/i,
/<parameter[\s>]/i,
/<\/parameter>/i,
/<invoke[\s>]/i,
/<\/invoke>/i,
/playwright<param_key>/i,
];
for (const pat of toolCallPatterns) {
if (pat.test(text)) {
return { ok: false, reason: 'contains_tool_call_markup' };
}
}
// 2. Reject JSON tool invocation requests
if (/^\s*\[?\s*\{\s*"tool"\s*:/i.test(text) ||
/\{\s*"tool"\s*:\s*"(?:browser_navigate|browser_snapshot|bash|playwright)"/i.test(text) ||
/\{\s*"command"\s*:\s*"[^"]*"\s*,\s*"description"\s*:/i.test(text)) {
return { ok: false, reason: 'json_tool_request' };
}
// 3. Reject browser commands / session placeholders
if (/(?:browser_navigate|browser_snapshot)\s*[<{("']/i.test(text) ||
/(?:<arg_value>|["']url["']:\s*["'])https?:\/\/[^\s"']+.*?browser_navigate/i.test(text) ||
/^\s*Browser session opened\./im.test(text) ||
/^\s*visiting URL:\s*http/im.test(text)) {
return { ok: false, reason: 'browser_command_instruction' };
}
// 4. Reject safety stubs / extraction fallbacks
if (/^\s*User Safety:\s*safe/im.test(text)) {
return { ok: false, reason: 'safety_filter_placeholder' };
}
if (/You need to enable JavaScript to run this app/i.test(text)) {
return { ok: false, reason: 'unrendered_spa_error' };
}
if (/^\s*fetching job description from URL/im.test(text) && text.length < 600) {
return { ok: false, reason: 'incomplete_fetch_placeholder' };
}
// 5. Reject shell command instructions presented instead of report
if (/```(?:bash|sh)\s*\n\s*(?:node browser-extract\.mjs|cat \/workspace\/)/i.test(text) ||
/\bnode\s+reserve-report-num(?:\.mjs)?\b/i.test(text) ||
/node browser-extract\.mjs.*--mode jd/i.test(text) ||
/cat \/workspace\/data\/blacklist/i.test(text)) {
return { ok: false, reason: 'shell_command_instruction' };
}
// 6. Minimum content length for a genuine evaluation report
if (text.length < 350) {
return { ok: false, reason: 'content_too_short' };
}
// 7. Require essential sections from the A-G evaluation format
const blocks = [
['A', /(?:^|\n)#{1,4}\s*(?:A(?![A-Za-z0-9])[).:-]?\s*|Block A\b|Role Summary\b)/im],
['B', /(?:^|\n)#{1,4}\s*(?:B(?![A-Za-z0-9])[).:-]?\s*|Block B\b|Match with CV\b|CV Match\b)/im],
['C', /(?:^|\n)#{1,4}\s*(?:C(?![A-Za-z0-9])[).:-]?\s*|Block C\b|Level and Strategy\b)/im],
['D', /(?:^|\n)#{1,4}\s*(?:D(?![A-Za-z0-9])[).:-]?\s*|Block D\b|Comp and Demand\b)/im],
['E', /(?:^|\n)#{1,4}\s*(?:E(?![A-Za-z0-9])[).:-]?\s*|Block E\b|Customization Plan\b|Personalization Plan\b|Tailoring Plan\b)/im],
['F', /(?:^|\n)#{1,4}\s*(?:F(?![A-Za-z0-9])[).:-]?\s*|Block F\b|Interview Plan\b)/im],
['G', /(?:^|\n)#{1,4}\s*(?:G(?![A-Za-z0-9])[).:-]?\s*|Block G\b|Posting Legitimacy\b|Legitimacy\b)/im],
];
for (const [letter, pattern] of blocks) {
if (!pattern.test(text)) {
return { ok: false, reason: `missing_block_${letter}` };
}
}
// 8. Require Score or Decision
const hasScoreOrDecision = /(?:^|\n)\s*\*?\*?\s*(?:Score|Puntuaci[oó]n|Note|Bewertung)\s*:\*?\*?\s*\d/im.test(text) ||
/(?:^|\n)\s*\*?\*?\s*(?:Decision|D[eé]cision|Entscheidung)\s*:\*?\*?\s*\*?\*?(?:Apply|Consider|Research first|Skip)/im.test(text) ||
/(?:^|\n)\s*score:\s*\d+(?:\.\d+)?/im.test(text) ||
/(?:^|\n)\s*final_decision:\s*["']?(?:Apply|Consider|Research first|Skip)/im.test(text);
if (!hasScoreOrDecision) {
return { ok: false, reason: 'missing_score_or_decision' };
}
return { ok: true };
}
// 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. data/ is User Layer
// (gitignored runtime state), so it follows the data root like every other
// data/ path this runner touches.
const BLACKLIST_DIR = path.join(getCareerOpsRoot(), 'data');
const BLACKLIST_FILE = path.join(BLACKLIST_DIR, '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(BLACKLIST_DIR, { 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
// (a success or any other failure resets the count)
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);
if (diff !== 0) return diff;
if (providerOf(a) === 'minimax' && providerOf(b) === 'minimax') {
const flash = 'minimax/minimax-m3.1-flash:free';
if (a === flash) return b === flash ? 0 : -1;
if (b === flash) return 1;
}
return 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
// ---------------------------------------------------------------------------
// Anchored to the career-ops data root, not to __dirname. scan.mjs resolves
// every path it touches through getCareerOpsRoot(), so with CAREER_OPS_DATA_DIR
// set this module was reading a DIFFERENT data/scan-history.tsv than the shared
// writers it now delegates to — dedup would clear a URL the writer then found
// present, or skip one it had never seen. The default is unchanged: with no
// env and no .career-ops-data marker, getCareerOpsRoot() returns the same
// directory __dirname did.
const DATA_ROOT = getCareerOpsRoot();
// System-layer files ship with the code and never follow the data root: a data
// root outside the checkout holds cv.md and modes/_profile.md, but no
// modes/_shared.md or modes/oferta.md. Read through readFile() those came back
// null, fell through to '', and the evaluation ran on a system prompt with no
// shared rules and no evaluation mode (#5115). Same split as ollama-eval.mjs
// and gemini-eval.mjs.
const CODE_ROOT = __dirname;
function readFile(relPath) {
try { return fs.readFileSync(path.join(DATA_ROOT, relPath), 'utf-8'); }
catch { return null; }
}
function readProfile() {
try { return fs.readFileSync(getProfilePath(), 'utf-8'); }
catch { return null; }
}
function readSystemFile(relPath) {
try { return fs.readFileSync(path.join(CODE_ROOT, relPath), 'utf-8'); }
catch { return null; }
}
function writeFile(relPath, content) {
const full = path.join(DATA_ROOT, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, 'utf-8');
}
function fileExists(relPath) {
// Same root as readFile() above: a fileExists that disagrees with the reader
// is a split-brain waiting to happen under CAREER_OPS_DATA_DIR.
return fs.existsSync(path.join(DATA_ROOT, relPath));
}
// ---------------------------------------------------------------------------
// Prompt caching (#1709)
// ---------------------------------------------------------------------------
// The static system prefix (shared + profile + mode + cv, ~12K tokens) is
// byte-identical across every offer in a run, yet it was re-sent and re-billed
// on each call. Send it as a structured content block with an ephemeral
// `cache_control` breakpoint — OpenRouter's documented prompt-caching mechanism.
// Providers that support caching (Anthropic, Gemini, …) reuse the prefix across
// back-to-back calls within the cache TTL; providers that don't simply ignore
// the field, so this is a safe passthrough that never changes the prompt text.
export function buildCachedSystemMessage(systemPrompt) {
return {
role: 'system',
content: [
{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } },
],
};
}
// ---------------------------------------------------------------------------
// 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) {
activeModel = pinnedModel;
process.stdout.write(`[model] ${pinnedModel} (pinned) ... `);
const body = JSON.stringify({
model: pinnedModel,
messages: [
buildCachedSystemMessage(systemPrompt),
{ role: 'user', content: userMessage },
],
max_tokens: MAX_TOKENS,
});
const ctrl = new AbortController();
const timeoutMs = getModelTimeoutMs();
const timerId = setTimeout(() => ctrl.abort(), timeoutMs);
try {
// Privacy gate: the complete serialized request is inspected after prompt
// construction and before the network call. Throws (nothing sent) on a hit.
assertLlmRequestAllowed(OPENROUTER_API_URL, body);
const resp = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/career-ops-hq/career-ops',
'X-Title': 'career-ops',
},
body,
signal: ctrl.signal,
});
if (!resp.ok) {
const t = await resp.text();
if (isDailyQuotaExhausted(resp.status, t)) {
throw new QuotaExhaustedError(`OpenRouter daily free-tier quota exhausted (${t.slice(0, 120)})`);
}
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');
const usage = normalizeOpenAIUsage(data.usage);
return { content, usage };
} catch (e) {
if (e.name === 'AbortError') throw new Error(`Pinned model timed out after ${timeoutMs / 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];
activeModel = model;
process.stdout.write(`[model] ${model} ... `);
try {
const body = JSON.stringify({
model,
messages: [
buildCachedSystemMessage(systemPrompt),
{ role: 'user', content: userMessage },
],
max_tokens: MAX_TOKENS,
});
const controller = new AbortController();
const timeoutMs = getModelTimeoutMs();
const timerId = setTimeout(() => controller.abort(), timeoutMs);
let data;
try {
// Privacy gate (rotation path). Same rule as the pinned-model path above.
assertLlmRequestAllowed(OPENROUTER_API_URL, body);
const resp = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/career-ops-hq/career-ops',
'X-Title': 'career-ops',
},
body,
signal: controller.signal,
});
if (!resp.ok) {
const t = await resp.text();
if (isDailyQuotaExhausted(resp.status, t)) {
throw new QuotaExhaustedError(`OpenRouter daily free-tier quota exhausted (${t.slice(0, 120)})`);
}
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 ${timeoutMs / 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');
const usage = normalizeOpenAIUsage(data.usage);
modelIndex = (modelIndex + attempt + 1) % active.length;
delete rateLimitCounts[model];
console.log('OK');
return { content, usage };
} catch (e) {
if (e?.code === 'PRIVACY_GUARD_BLOCKED') throw e;
if (e instanceof QuotaExhaustedError || e.name === 'QuotaExhaustedError') {
console.log(`FAILED (${e.message})`);
throw 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) {
blacklistedModels.add(model);
saveBlacklist(blacklistedModels);
console.log(`SKIP (blacklisted: ${msg})`);
} else if (isTimeout) {
// Do not permanently disk-blacklist models on transient timeouts (Safeguard 8)
console.log(`FAILED (${msg})`);
await new Promise(r => setTimeout(r, Math.min(800 * Math.pow(1.5, attempt), 3000)));
} 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, Math.min(800 * Math.pow(1.5, attempt), 3000)));
}
} else {
delete rateLimitCounts[model];
console.log(`FAILED (${msg})`);
await new Promise(r => setTimeout(r, Math.min(800 * Math.pow(1.5, attempt), 3000)));
}
}
}
throw new Error(`All ${active.length} active models failed. Last error: ${lastError?.message}`);
}
// Exported so tests/openrouter-runner-429-reset.test.mjs and
// tests/openrouter-runner-validation.test.mjs can script fetch()
// against the real rotation and blacklist logic without a network or a CLI run.
export {
callOpenRouter,
rateLimitCounts,
blacklistedModels,
getModelTimeoutMs,
cmdEvaluate,
cmdPipeline,
};
// ---------------------------------------------------------------------------
// Context loading
// ---------------------------------------------------------------------------
function loadContext() {
// Opt-in (privacy.redact_contact_info in config/profile.yml): keep name and
// contact details out of the prompt sent to the model.
return anonymizeContext({
cv: readFile('cv.md') ?? 'CV not found.',
profile: readProfile() ?? '',
shared: readSystemFile('modes/_shared.md') ?? '',
profileMode: readFile('modes/_profile.md') ?? '',
});
}
// The mode files are written for Claude Code, which has Playwright, WebFetch,
// Bash and file reads. This runner sends no `tools`, so a model that follows
// them answers with tool calls and no evaluation (#5809). Say so before them,
// and drop the parts that tell the model to use tools.
function noToolsNote(evaluate) {
return [
'RUNNER NOTE: you have no tools in this run.',
// Only evaluate sends a posting; apply sends an evaluation report.
evaluate
? 'The job text is in the user message and the CV is below.'
: 'Everything you need is in the user message and the CV below.',
'Do not navigate, fetch, read files or run commands, and ignore any instruction below to do so.',
].join('\n');
}
// Matched by heading, not line number, so edits to modes/ don't move them. A
// section runs to the next heading of the same or a higher level, or to its
// `until` line. A heading that is gone leaves the text as it was.
const TOOL_SECTIONS = [
'## Liveness gate (URL inputs)',
'## Bounded Research Budget',
'### Tools',
// Stops before the agency-confirmation rule, which the runner keeps.
{ heading: '### Subagent delegation (cost guardrail)', until: '<!-- guardrail:agency-confirmation -->' },
'### Prior-contact FYI (non-scoring)',
'### 1b. Embed ATS keyword coverage',
'### 2. Record in tracker',
'### 3. Salary observations (desired ask only)',
];
// Single lines that tell the model to run a command or search, by how they start.
const TOOL_LINES = [
'1b. **First evaluation of each session:** Run `node cv-sync-check.mjs`',
'4. Use WebSearch for comp and company data',
'- Search: `"{company}"',
'- `{###}` = next sequential number',
];
const READ_CV_RULE = '**RULE: NEVER hardcode metrics from proof points.**';
export function stripToolInstructions(text) {
let lines = text.split('\n');
for (const section of TOOL_SECTIONS) {
const { heading, until } = typeof section === 'string' ? { heading: section } : section;
const start = lines.findIndex(l => l.trimEnd() === heading);
if (start === -1) continue;
const level = heading.match(/^#+/)[0].length;
const next = lines.findIndex((l, i) => i > start
&& (new RegExp(`^#{1,${level}} `).test(l) || (until !== undefined && l.trimEnd() === until)));
lines.splice(start, (next === -1 ? lines.length : next) - start);
}
lines = lines.filter(l => !TOOL_LINES.some(p => l.startsWith(p)));
lines = lines.map(l => (l.startsWith(READ_CV_RULE) ? `${READ_CV_RULE} Take them from the CV below.` : l));
return lines.join('\n');
}
export function buildSystemPrompt(modeContent, ctx, { evaluate = false } = {}) {
const languageInstruction = outputLanguageInstruction(parseOutputLanguage(ctx.profile));
const executionBoundary = [
'═══════════════════════════════════════════════════════',
'CRITICAL OPERATING RULES FOR THIS SESSION',
'═══════════════════════════════════════════════════════',
evaluate
? '1. You are a standalone evaluation report writer, NOT an interactive tool-using CLI agent.'
: '1. You generate application form answers from the supplied evaluation report, NOT an interactive tool-using CLI agent.',
'2. You do NOT have access to WebSearch, Playwright, bash, file-writing, or any external tools.',
'3. Do NOT emit function calls, tool calls (<tool_call>, <dots_function_call>, <|tool_call_start|>), JSON tool requests, or XML invocation tags.',
'4. Do NOT output shell commands or code snippets asking to execute tools (such as browser-extract, reserve-report-num, curl, or bash).',
'5. All needed job description text and candidate data are already provided below.',
...(evaluate ? [
'6. Generate the complete, standalone Markdown evaluation report covering Blocks A through G directly.',
] : []),
'═══════════════════════════════════════════════════════',
].join('\n');
return [
noToolsNote(evaluate),
executionBoundary,
stripToolInstructions(ctx.shared),
ctx.profileMode,
stripToolInstructions(modeContent),
'---',
'CANDIDATE PROFILE (YAML):',
ctx.profile,
'---',
'CANDIDATE CV:',
ctx.cv,
languageInstruction,
].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;
}
// Verdicts from the liveness check that mean the page is not an open posting
// that can be read: besides any `expired` one (404, a closed or listing page),
// a page too short to read (isUnreadableVerdict: nav/footer only, or not
// rendered yet, which classifyLiveness reports as `uncertain`), a bot wall or
// a redirect away from the posting.
const UNREADABLE_PAGE_CODES = new Set(['bot_challenge', 'redirected_off_posting']);
// Why a fetched page holds no job description to evaluate, or null when it
// does. The runner used to send any page to the model, so a 404, a bot wall or
// a Workday page that had not rendered yet came back as a report and a done
// pipeline row for a posting nobody read (#5809).
export function unreadableJobPage({ status = 0, requestedUrl = '', finalUrl = '', text = '' } = {}) {
if (status >= 400) return `HTTP ${status}`;
const verdict = classifyLiveness({ status, requestedUrl, finalUrl, bodyText: text });
if (verdict.result === 'expired' || isUnreadableVerdict(verdict) || UNREADABLE_PAGE_CODES.has(verdict.code)) {
return `${verdict.code}: ${verdict.reason}`;
}
return null;
}
// What the page read strips before the text is taken: it is not job content.
const NON_CONTENT_SELECTOR = 'script,style,nav,footer,header';
// Most text the runner keeps of one job description. Handed to the API fetcher
// too, so it does not cut the text short of what the slices below would keep.
const JD_TEXT_CAP = 16_000;
// Wait for a single-page app to render its job description, at most 15 s. The
// wait ends once the text the read will keep (the main content, minus
// nav/header/footer and what is not shown) holds more than 1500 chars. Counting
// the whole body let a long nav or a consent panel pass while the description
// still said "Loading...". The count leaves the live DOM alone; the read strips
// it afterwards. A page that stays shorter (a short posting) ends the wait at
// the first decisive liveness verdict instead, the rule liveness-browser.mjs
// polls by: any code outside HYDRATING_CODES. A decisive verdict ends it only
// for a closed posting, or when the text the read keeps passes
// unreadableJobPage: an apply control above a "Loading..." description is
// decisive but not yet readable. Not when the text stops changing, as a
// spinner holds it constant on a live page. An HTTP error status (`status` >=
// 400) has nothing to wait for; the caller reads the status.
export async function waitForJobText(page, { status = 0, url = '' } = {}) {
if (status >= 400) return;
const deadline = Date.now() + 15_000;
const requested = url ? postingIdentity(url) : { postingId: null };
for (;;) {
try {
const reading = await page.evaluate((selector) => {
const root = document.querySelector('main, [role="main"], article') || document.body || document;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let length = 0;
const parts = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const el = node.parentElement;
if (!el || el.closest(selector) || (typeof el.checkVisibility === 'function' && !el.checkVisibility({ visibilityProperty: true }))) continue;
length += node.nodeValue.trim().length;
if (length > 1500) return { long: true };
parts.push(node.nodeValue);
}
return { long: false, bodyText: document.body?.innerText ?? '', kept: parts.join(' ') };
}, NON_CONTENT_SELECTOR);
if (reading.long) return;
// The apply controls are read as liveness-browser's readPage reads them,
// with the same posting identity.
const finalUrl = page.url();
const landed = postingIdentity(finalUrl);
const anotherPosting = requested.postingId && landed.postingId !== requested.postingId;
const applyControls = await page.evaluate(extractApplyControls, {
...(anotherPosting ? { postingId: null, postingSegment: null } : landed),
pageUrl: finalUrl,
idPattern: JOB_ID_PATTERN,
});
const { bodyText, kept } = reading;
const verdict = classifyLiveness({ status, requestedUrl: url, finalUrl, bodyText, applyControls });
if (!HYDRATING_CODES.has(verdict.code)) {
// An apply control is a decisive verdict even over a description that
// still says "Loading...": the wait ends only on a closed posting or
// once the text the read keeps is readable.
if (verdict.result === 'expired') return;
const text = kept.replace(/\s+/g, ' ').trim();
if (!unreadableJobPage({ status, requestedUrl: url, finalUrl, text })) return;
}
} catch {
// the page navigated or rebuilt its DOM mid-read; try again
}
if (Date.now() + 250 > deadline) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
// `fetchApi` is a test seam for the ATS API fetch; the default is the real one.
export async function fetchJobPage(url, { fetchApi = fetchJdViaKnownApi } = {}) {
assertSafeRemoteUrl(url);
const viaApi = await fetchApi(url, JD_TEXT_CAP);
if (viaApi?.text) {
const { title, text } = viaApi;
// The title leads the content, as fetch-jd.mjs prints it: the normalizers
// keep it apart from the text and a description need not repeat it.
const unreadable = unreadableJobPage({ status: 200, requestedUrl: url, finalUrl: url, text });
if (!unreadable) return (title ? `${title}\n\n${text}` : text).slice(0, JD_TEXT_CAP);
// The ATS says the posting is closed (Workday canApply, SmartRecruiters
// active): a browser read would only lose that. Any other unreadable
// reason (too short ...) still falls through to the page.
if (classifyLiveness({ status: 200, requestedUrl: url, finalUrl: url, bodyText: text }).result === 'expired') {
// Left pending in pipeline.md like any unreadable page (#5814): cmdPipeline
// marks a row done only when cmdEvaluate returns a report.
throw new Error(`No job description to evaluate at ${url} (${unreadable})`);
}
}
let chromium;
if (!process.env.CAREER_OPS_NO_PLAYWRIGHT) {
try {
({ chromium } = await import('playwright'));
} catch {
console.warn('[fetch] Playwright unavailable — falling back to plain fetch.');
}
}
if (chromium) {
let browser;
let unreadable = null;
try {
browser = await chromium.launch(browserLaunchOptions(
{ headless: true },
{ projectRoot: DATA_ROOT, processEnv: BROWSER_PROCESS_ENV },
));
const page = await browser.newPage();
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await waitForJobText(page, { status: response?.status() ?? 0, url }); // wait for SPA render
const text = await page.evaluate((selector) => {
document.querySelectorAll(selector).forEach(el => el.remove());
return (document.body?.innerText || document.body?.textContent || '').replace(/\s+/g, ' ').trim();
}, NON_CONTENT_SELECTOR);
unreadable = unreadableJobPage({ status: response?.status() ?? 0, requestedUrl: url, finalUrl: page.url(), text });
if (!unreadable) return text.slice(0, JD_TEXT_CAP);
} catch (e) {
console.warn(`[fetch] Playwright error: ${e.message} — falling back to plain fetch.`);
} finally {
if (browser) await browser.close().catch(() => {});
}
// The browser read the page; a plain fetch would only see less of it.
if (unreadable) throw new Error(`No job description to evaluate at ${url} (${unreadable})`);
}
// Plain HTTP fallback
try {
const r = await fetch(url, {
headers: { 'User-Agent': DEFAULT_USER_AGENT }
});
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
const html = await r.text();
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const unreadable = unreadableJobPage({ status: r.status, requestedUrl: url, finalUrl: r.url, text });
if (unreadable) throw new Error(`no job description (${unreadable})`);
return text.slice(0, JD_TEXT_CAP);
} 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).
// ---------------------------------------------------------------------------
export function parsePortals(rawOverride) {
const raw = rawOverride ?? readFile('portals.yml');
if (!raw) throw new Error('portals.yml not found');
const config = loadYaml(raw) || {};
// The shared predicate rather than a second copy of the matching rules. This
// path kept its own `includes` loop, and the two had drifted three ways: an
// empty positive list accepted every title in scan.mjs and rejected every
// title here, AND-groups worked only in scan.mjs, and a non-string YAML entry
// was dropped there but coerced into a live keyword here. A `word:` prefix
// would have become the fourth — read as literal text, it would have matched
// nothing, so the shipped `word:Intern` would stop rejecting "Operations
// Intern" here while still working in scan.mjs.
//
// Side effect worth naming, since it changes this path's verdicts rather than
// just its structure: it now also gets the 2-3 char rule. Measured over 2324
// real titles that moves one verdict, and it moves it the permissive way —
// the negative "iOS" had been matching inside "Biosamples". Nothing becomes
// newly rejected.
const titleMatches = buildTitleFilter(config.title_filter);
// 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, config };
}
// ---------------------------------------------------------------------------
// 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;
}
// Tick the pending row whose URL is exactly `url`. The previous version matched
// `- [ ] {url}.*`, so with `.../jobs/12` still pending above `.../jobs/1`,
// finishing jobs/1 ticked jobs/12. It also rewrote the file with an unlocked,
// truncating writeFileSync, which erased any row a scanner appended through
// appendToPipeline in between. The URL is read the way readPipeline reads it.
export async function markPipelineDone(url, { pipelinePath = path.join(DATA_ROOT, 'data', 'pipeline.md') } = {}) {
await withPipelineLock(pipelinePath, async () => {
if (!fs.existsSync(pipelinePath)) return;
const lines = fs.readFileSync(pipelinePath, 'utf-8').split('\n');
const i = lines.findIndex(line => /^- \[ \] /.test(line) && line.slice(6).split(' | ')[0].trim() === url);
if (i === -1) return;
lines[i] = lines[i].replace('- [ ]', '- [x]');
atomicWriteFile(pipelinePath, lines.join('\n'));
});
}
// Both writes go through the shared writers in scan.mjs rather than this
// module's own read-modify-write. Those writers hold pipeline-lock.mjs on the
// file they touch, so this stops being a fourth, unlocked writer racing the
// three appendToPipeline already names. The previous version read each file
// whole, appended in memory, and wrote the whole thing back with a truncating
// writeFileSync — so any row another scanner appended in between was erased,
// silently, because every reader skips a malformed or missing row quietly.
//
// Delegating fixes three things at once that were all symptoms of hand-rolling
// the write: the lock, the row format (formatScanHistoryRow emits every trailing
// column; this module wrote seven and created a seven-column header), and the
// date (the shared path stamps the local day, this one stamped the UTC day —
// the defect #3240/#3241 fixed in the other scanners, which this module escaped
// because that census finds scanners by looking for appendToScanHistory calls).
//
// It also picks up CAREER_OPS_DATA_DIR support for free: the shared paths are
// DATA_ROOT-anchored, while the __dirname-relative paths here ignored it.
//
// Admission goes through the shared gate (#5478): the seen set compares
// normalized URLs across scan-history, the pipeline and the tracker, and the
// blacklist and location filter apply as they do in scan.mjs. The raw-URL sets
// this built before re-queued a posting whose URL differed by a tracking
// parameter, and nothing here read data/blacklist.md.
export async function addToPipeline(entries, config = {}, gate = createAdmissionGate({ config })) {
// The shared writers take {url, company, title, location}; this module calls
// the title `role`.
const offers = entries.map(e => ({
url: e.url,
company: e.company,
title: e.role,
location: typeof e.location === 'string' ? e.location : '',
source: 'openrouter scan',
}));
const { admitted } = gate.partition(offers);
const skipped = formatSkipCounts(gate.counts);
if (skipped) console.log(`Skipped: ${skipped}`);
if (admitted.length === 0) return 0;
await appendToPipeline(admitted);
await appendToScanHistory(admitted, localToday());
return admitted.length;
}
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() {
tracker.recordZeroToken('scan');
tracker.recordZeroToken('evaluation');
tracker.recordZeroToken('pdf payload');
console.log('Scanning Greenhouse portals...\n');
let portals;
try { portals = parsePortals(); }
catch (e) { console.error(e.message); return; }
const { companies, titleMatches, config } = 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 = await addToPipeline(found, config);
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) {
tracker.recordZeroToken('scan');
tracker.recordZeroToken('pdf payload');
const modeContent = readSystemFile('modes/oferta.md') ?? readSystemFile('modes/auto-pipeline.md') ?? '';
let jdText = input;
// The posting text alone, for the report's verbatim archive: jdText gains a
// `URL:` line for the model when the input is a link.
let archiveText = 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');
archiveText = jdText;
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}`;
archiveText = content;
} catch (e) {
console.error(e.message);
return null;
}
}
console.log('\nEvaluating...');
const systemPrompt = buildSystemPrompt(modeContent, ctx, { evaluate: true });
let resultObj;
try {
resultObj = await callOpenRouter(systemPrompt, `Evaluate this job listing:\n\n${jdText}`);
} catch (e) {
if (e.name === 'QuotaExhaustedError' || e instanceof QuotaExhaustedError) {
throw e;
}
console.error(`OpenRouter error: ${e.message}`);
return null;
}
tracker.record('evaluation', resultObj.usage);
const result = resultObj.content;
// A model that says it could not evaluate the posting has not evaluated it:
// save nothing, so the pipeline leaves the row pending (#5809).
const notEvaluated = result.match(/^---DEAD_POSTING---\s*$|^\s*(?:status:\s*)?needs_confirmation\b.*$/m);
if (notEvaluated) {
console.error(`Not evaluated, nothing saved. The model replied: ${notEvaluated[0].trim()}`);
return { success: false, reason: 'not_evaluated' };
}
// Validate the evaluation response BEFORE reserving a report number or writing anything!
const validation = validateEvaluationResponse(result);
if (!validation.ok) {
console.error(`\n❌ Evaluation rejected by validation: ${validation.reason}`);
console.error(` Output failed validation check (${validation.reason}). Report will NOT be marked successful.`);
return { success: false, reason: validation.reason };
}
let reservedNumbers;
try {
reservedNumbers = await reserveReportNumbers(1, {
rootDir: DATA_ROOT,
reportsDir: path.join(DATA_ROOT, 'reports'),
});
} catch (e) {
console.error(`Could not reserve a report number: ${e.message}`);
return null;
}
try {
// Save report
const today = localToday();
const num = reservedNumbers[0];
const slug = extractCompanySlug(jdText, typeof input === 'string' ? input : null);
const numStr = formatReportNumber(num);
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${withJdArchiveSection(result, archiveText)}`);
// Capture the DENOMINATOR when the model writes one. The old pattern took
// only the numeric prefix, so `Score: 8/10` yielded `8` and the cell became
// `8.0/5` -- a ten-point score reinterpreted as a five-point one. That
// satisfies SCORE_CELL_RE, so it merged as a genuine score and fed
// stats.mjs's averages. normalizedTrackerScore refuses a denominator that
// is not 5, but only if it is handed one (#3796).
//
// The capture runs to END OF LINE rather than stopping at a denominator
// adjacent to the number. Requiring adjacency read `Score: 4.2 (strong
// fit)/10` -- a ten-point score with an annotation -- as a bare 4.2 and
// wrote `4.2/5`, the same wrong number the numeric prefix used to produce.
// The cost is that an unrelated fraction later in the line (`Score: 4.2 --
// matched 3/4 axes`) is refused as N/A rather than guessed at. That is the
// trade the shared helper already documents and the gemini path already
// pins: N/A is recoverable, a wrong score is not.
const scoreMatch = result.match(/(?:score|puntuaci[oó]n)[^\d]*(\d+(?:\.\d+)?[^\r\n]*)/i);
// An unparseable score used to become the EMPTY string, and merge-tracker
// refuses a blank required cell ("use the documented sentinel rather than a
// blank cell") -- so the evaluation was skipped whole, the same loss #3796
// documents for the drifted copies. The shared helper returns the `N/A`
// sentinel (#1799), which merges as an unscored row instead of as nothing.
const scoreStr = normalizedTrackerScore(scoreMatch ? scoreMatch[1] : '');
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`;
// Header row, then the single data row. merge-tracker.mjs resolves the
// fields by NAME when the header is present (#3517), so this row cannot be
// read into the wrong columns. (Headerless files still work; they are the
// legacy form, and they are the ones that can hit the undecidable
// score-vs-status case.)
writeFile(tsvFile, `${TSV_ADDITION_HEADER}\n${tsvLine}`);
console.log(`\n✅ Report saved: ${relPath}`);
console.log('\n─── EVALUATION ──────────────────────────────────────\n');
console.log(result);
console.log('\n─────────────────────────────────────────────────────\n');
return { success: true, relPath, score: scoreStr };
} finally {
try {
await releaseReportNumbers(reservedNumbers, { reportsDir: path.join(DATA_ROOT, 'reports') });
} catch (e) {
console.warn(`Could not release report reservation: ${e.message}`);
}
}
}
// -- PIPELINE --
async function cmdPipeline(ctx, options = {}) {
const pending = readPipeline();
if (pending.length === 0) {
console.log('No pending listings in pipeline.md.');
return;
}
const isOne = options.one === true || options.limit === 1;
const toProcess = isOne ? pending.slice(0, 1) : pending;
console.log(`Processing ${toProcess.length} pending listing(s) from pipeline.md${isOne ? ' (one-job mode)' : ''}...\n`);
for (let i = 0; i < toProcess.length; i++) {
const item = toProcess[i];
console.log(`\n[${i + 1}/${toProcess.length}] ${item.company} — ${item.role}`);
try {
const res = await cmdEvaluate(item.url, ctx);
if (res && res.success === true) {
await markPipelineDone(item.url);
console.log(` Marked complete in pipeline: ${item.company} — ${item.role}`);
} else {
console.warn(` Evaluation incomplete or failed. Listing preserved as pending in pipeline.md for retry.`);
}
} catch (e) {
if (e.name === 'QuotaExhaustedError' || e instanceof QuotaExhaustedError) {
console.error(`\n🛑 OpenRouter daily quota exhausted: ${e.message}`);
console.error('Stopping pipeline immediately to avoid wasting requests.');
break;
}
console.error(` Error: ${e.message}`);
}
if (i < toProcess.length - 1) {
await new Promise(r => setTimeout(r, RATE_LIMIT_DELAY_MS));
}
}
console.log('\n✅ Pipeline processing finished.\n');
}
// -- APPLY --
// Returns false when no answers were produced (no report, unreadable report, or
// a failed request) and true otherwise, including when the user declines a
// low-score report: that is their choice, not a failure.
async function cmdApply(ref, ctx) {
tracker.recordZeroToken('scan');
tracker.recordZeroToken('evaluation');
tracker.recordZeroToken('pdf payload');
const modeContent = readSystemFile('modes/apply.md') ?? '';
let reportContent;
let reportPath;
try {
const located = locateApplyReport(DATA_ROOT, ref, {
exists: (p) => fs.existsSync(p),
list: (dir) => (fs.existsSync(dir) ? fs.readdirSync(dir) : []),
});
if (!located.ok) {
console.error(`Report not found: ${ref}`);
return false;
}
reportPath = located.path;
} catch (e) {
console.error(e instanceof Error ? e.message : String(e));
return false;
}
try {
reportContent = fs.readFileSync(reportPath, 'utf8');
} catch {
reportContent = null;
}
if (!reportContent) { console.error('Could not read report content.'); return false; }
// Score-gate: warn and confirm before applying to low-fit roles (AGENTS.md Ethical Use)
const scoreMatch = reportContent.match(/^\s*\*?\*?\s*(?:score|puntuaci[oó]n)\s*:\s*\*?\*?\s*(\d+(?:\.\d+)?)\s*\/\s*5/im);
const scoreValue = scoreMatch ? parseFloat(scoreMatch[1]) : NaN;
if (isFinite(scoreValue) && scoreValue < 4.0) {
console.log(`\n⚠️ This report scored ${scoreValue.toFixed(1)}/5 — below the 4.0/5 threshold.`);
console.log('Strongly discourage low-fit applications. Your time and the recruiter\'s time are both valuable.');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise(resolve => {
rl.question('Proceed anyway? (yes/no): ', resolve);
});
rl.close();
if (answer.trim().toLowerCase() !== 'yes') {
console.log('Aborted.');
return true;
}
}
console.log('Generating application form answers...');
const systemPrompt = buildSystemPrompt(modeContent, ctx);
let resultObj;
try {
resultObj = await callOpenRouter(
systemPrompt,
`Generate application form answers based on this evaluation report:\n\n${reportContent}`
);
} catch (e) {
console.error(`OpenRouter error: ${e.message}`);
return false;
}
tracker.record('apply', resultObj.usage);
const result = resultObj.content;
console.log('\n─── APPLICATION ANSWERS ─────────────────────────────\n');
console.log(result);
console.log('\n─────────────────────────────────────────────────────\n');
return true;
}
// ---------------------------------------------------------------------------
// 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 = isMainModule(import.meta.url);
const [, , command, ...args] = invokedDirectly ? process.argv : [];
if (invokedDirectly) loadEnvFile();
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();
}
// A command that did not do its job exits 1: a mistyped command, `apply`
// without a report, an evaluation that saved no report, answers that were not
// generated. A script or scheduler that checks the status read each of them as
// a success.
if (invokedDirectly) switch (command) {
case 'scan':
await cmdScan();
break;
case 'evaluate':
case 'eval': {
const res = await cmdEvaluate(args.join(' ').trim() || null, ctx);
if (res?.success !== true) process.exitCode = 1;
break;
}
case 'pipeline':
const isOneJob = args.includes('--one') || args.includes('-1') || args.includes('--limit=1');
await cmdPipeline(ctx, { one: isOneJob });
break;
case 'apply':
if (!args[0]) {
console.error('Usage: node openrouter-runner.mjs apply <report_num|report_path>');
process.exitCode = 1;
break;
}
if (!(await cmdApply(args[0], ctx))) process.exitCode = 1;
break;
case 'models':
await cmdModels();
break;
default: {
const usage = `
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 pipeline --one → Batch-evaluate only 1 pending entry (test mode)
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>
`;
if (command === undefined || ['help', '--help', '-h'].includes(command)) {
console.log(usage);
} else {
// A mistyped command (`evalute`) ran nothing, so it must not exit 0.
console.error(`openrouter-runner.mjs: unknown command "${command}"\n${usage}`);
process.exitCode = 1;
}
}
}
if (invokedDirectly && ['scan', 'evaluate', 'eval', 'pipeline', 'apply'].includes(command)) {
const modelName = process.env.CAREER_OPS_MODEL || activeModel || 'free-rotation';
console.log('\n' + formatBreakdown(tracker, modelName, 'openrouter'));
}