Files
2026-04-01 21:59:13 +08:00

190 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
const path = require("path");
const {
readJson,
readText,
writeJson,
ensureDir,
formatDateKey,
getWeekKey
} = require("./lib/io");
const ROOT = path.resolve(__dirname, "..");
const args = process.argv.slice(2);
function getArgValue(flag) {
const index = args.findIndex((a) => a === flag);
if (index === -1 || index === args.length - 1) {
return "";
}
return args[index + 1];
}
const registryPath = getArgValue("--registry");
const leadKeyArg = getArgValue("--lead-key");
const nameArg = getArgValue("--name");
const companyArg = getArgValue("--company");
const titleArg = getArgValue("--title");
const linkedinArg = getArgValue("--linkedin");
const signalArg = getArgValue("--signal");
const settings = readJson(path.join(ROOT, "state", "linkedin-settings.json"), null);
const state = readJson(path.join(ROOT, "state", "linkedin-system-state.json"), null);
const voiceProfile = readText(path.join(ROOT, "context", "brand-voice.md"), "");
if (!settings || !state) {
console.error("Missing state files. Run: node scripts/bootstrap-system.js");
process.exit(1);
}
if (!voiceProfile.trim()) {
console.error("Missing context/brand-voice.md. Run calibrate-voice first.");
process.exit(1);
}
function lower(v) {
return String(v || "").trim().toLowerCase();
}
function canonicalLeadKey(name, company, url) {
const urlPart = String(url || "").trim() || "missing_url";
return lower(`${String(name || "").trim()}|${String(company || "").trim()}|${urlPart}`);
}
function selectLeadFromRegistry() {
if (!registryPath) return null;
const fullPath = path.resolve(process.cwd(), registryPath);
const registry = readJson(fullPath, null);
if (!registry || !Array.isArray(registry.leads)) return null;
let lead = null;
if (leadKeyArg) {
lead = registry.leads.find((item) => item.lead_key === leadKeyArg);
}
if (!lead) {
const minScore = settings.qualification.minScoreToPrioritize || 3;
lead = registry.leads.find((item) => item.qualification_status === "qualified" && item.score >= minScore);
}
if (!lead) {
lead = registry.leads.find((item) => item.qualification_status === "qualified");
}
return lead;
}
let lead = selectLeadFromRegistry();
if (!lead) {
const manualKey = canonicalLeadKey(nameArg, companyArg, linkedinArg);
if (nameArg && companyArg && titleArg) {
lead = {
lead_key: manualKey,
name: nameArg,
company: companyArg,
title: titleArg,
linkedin_url: linkedinArg || "",
signal_type: signalArg || "none",
score: 1,
qualification_status: "qualified",
reason: "manual lead input"
};
}
}
if (!lead) {
console.error("No lead found. Provide --registry or manual --name --company --title.");
process.exit(1);
}
if (lead.qualification_status !== "qualified") {
console.error(`Lead is not qualified (${lead.qualification_status}).`);
process.exit(1);
}
const maxChars = settings.outreach.maxChars || 300;
const maxQuestions = settings.outreach.maxQuestions || 1;
const firstMessageNoPitch = settings.outreach.firstMessageNoPitch !== false;
const recentSignal = lead.signal_type && lead.signal_type !== "none"
? lead.signal_type
: "their current role";
// Keep generation deterministic and editable in skill prompts.
function draftFromLead(record, angle = "default") {
const opener = angle === "question"
? `${record.name.split(" ")[0]}, saw your work at ${record.company} around ${recentSignal}.`
: `${record.name.split(" ")[0]}, noticed your work at ${record.company} and your focus on ${record.title}.`;
const bridge = firstMessageNoPitch
? "Curious how your team is approaching priorities this quarter."
: "I think there may be a relevant fit worth comparing.";
const question = maxQuestions > 0
? "Open to a quick exchange?"
: "";
let text = `${opener} ${bridge} ${question}`.replace(/\s+/g, " ").trim();
if (text.length > maxChars) {
text = text.slice(0, maxChars - 1).trimEnd() + ".";
}
return text;
}
const draft = draftFromLead(lead, "default");
const variant = draftFromLead(lead, "question");
const now = new Date().toISOString();
const dateKey = formatDateKey();
const outDir = path.join(ROOT, "output", `outreach_${dateKey}`);
ensureDir(outDir);
const artifact = {
lead_key: lead.lead_key,
generated_at: now,
source_registry: registryPath ? path.resolve(process.cwd(), registryPath) : "manual",
draft,
variant,
signal_used: recentSignal,
char_count: draft.length,
voice_check: "references real role/company context and keeps first-touch tone concise",
watch_for: "replace generic signal with specific post/job change when available",
status: "drafted"
};
const safeLeadKey = lead.lead_key.replace(/[^a-z0-9_|-]/gi, "_");
const artifactPath = path.join(outDir, `draft_${safeLeadKey}_${dateKey}.json`);
writeJson(artifactPath, artifact);
const weekKey = getWeekKey();
if (state.weeklyMetrics.weekKey !== weekKey) {
state.weeklyMetrics = {
weekKey,
leads_processed: 0,
qualified_count: 0,
drafted_count: 0,
reply_drafts_count: 0,
replied_count: 0,
rejected_count: 0,
score3_conversion_count: 0
};
}
state.firstRunCompleted = true;
state.lastDraftRunAt = now;
state.lastSuccessfulRunAt = now;
state.weeklyMetrics.drafted_count += 1;
state.outreachRegistry = state.outreachRegistry || {};
state.outreachRegistry[lead.lead_key] = {
lastDraftAt: now,
lastDraftStatus: "drafted",
source: artifact.source_registry,
lastSignalType: recentSignal
};
writeJson(path.join(ROOT, "state", "linkedin-system-state.json"), state);
console.log(JSON.stringify({
output: artifactPath,
lead_key: lead.lead_key,
draft_chars: draft.length,
score: lead.score
}, null, 2));