toolsProvider.js
"use strict";
/**
* Job Search Plugin — toolsProvider
*
* Tools:
* Search · search_jobs, search_company, fetch_job_page
* Hidden Market · find_hidden_jobs, find_recently_funded_companies
* Applications · add_application, update_application, list_applications, delete_application
* Analysis · analyze_job_description, match_resume_to_job, verify_company_legitimacy
* Timing · get_application_urgency, list_followups, detect_stale_applications, calculate_total_comp
* Resume · read_resume
* Generation · generate_cover_letter, generate_resume_bullets, prepare_interview_questions
* Analytics · application_stats, analyze_rejection_patterns
* Saved Search · save_search, run_saved_searches, list_saved_searches, delete_saved_search
* Interviews · add_interview_note
* Contacts · manage_contacts
* Export · export_applications_csv
* Briefing · daily_briefing
* Comparison · compare_jobs
* Batch · batch_update_applications
* Networking · add_network_contact, update_network_contact, list_network, log_network_interaction, delete_network_contact, find_referrers_for_company
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.toolsProvider = void 0;
const sdk_1 = require("@lmstudio/sdk");
const promises_1 = require("fs/promises");
const pdf_parse_1 = __importDefault(require("pdf-parse"));
const path_1 = require("path");
const os_1 = require("os");
const search_1 = require("./search");
const zod_1 = require("zod");
const config_1 = require("./config");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function json(obj) {
return JSON.stringify(obj, null, 2);
}
function safe_impl(name, fn) {
return async (params) => {
try {
return await fn(params);
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return JSON.stringify({
tool_error: true,
tool: name,
error: msg,
hint: "Read the error above, fix the parameter causing the issue, and retry the tool call.",
}, null, 2);
}
};
}
function stripHtml(html) {
return html
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
.replace(/ /g, " ").replace(/"/g, '"').replace(/'/g, "'")
.replace(/[ \t]+/g, " ")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function getDataDir(configPath) {
return configPath.trim() || (0, path_1.join)((0, os_1.homedir)(), "job-search-data");
}
// Expand leading ~/ to the user's home directory (Node's readFile doesn't do this)
function expandPath(p) {
if (p.startsWith("~/"))
return (0, path_1.join)((0, os_1.homedir)(), p.slice(2));
if (p === "~")
return (0, os_1.homedir)();
return p;
}
// Read a resume file — supports plain text (.txt, .md) and PDF
async function readResumeFile(filePath) {
const buf = await (0, promises_1.readFile)(filePath);
if (filePath.toLowerCase().endsWith(".pdf")) {
const parsed = await (0, pdf_parse_1.default)(buf);
return parsed.text;
}
return buf.toString("utf8");
}
function dbPath(dataDir) {
return (0, path_1.join)(dataDir, "applications.json");
}
async function loadDB(dataDir) {
try {
const raw = await (0, promises_1.readFile)(dbPath(dataDir), "utf8");
const db = JSON.parse(raw);
if (!db.applications)
db.applications = [];
if (!db.savedSearches)
db.savedSearches = [];
if (!db.network)
db.network = [];
// Hydrate fields added after initial release so old records don't crash
for (const app of db.applications) {
app.postedDate ??= "";
app.followUpDate ??= "";
app.totalComp ??= "";
// Migrate old string[] contacts to new structured format
if (Array.isArray(app.contacts) && typeof app.contacts[0] === "string") {
app.contacts = app.contacts.map((c) => ({
name: c, role: "", email: "", linkedIn: "", notes: "",
}));
}
app.contacts ??= [];
app.country ??= "";
app.isInternational ??= false;
app.workPermitStatus ??= "unknown";
app.workPermitNotes ??= "";
app.legitimacyScore ??= -1;
app.legitimacyFlags ??= [];
app.interviewNotes ??= [];
app.rejectionReason ??= "";
app.statusHistory ??= [];
}
return db;
}
catch {
return { applications: [], savedSearches: [], network: [] };
}
}
async function saveDB(dataDir, db) {
await (0, promises_1.mkdir)(dataDir, { recursive: true });
await (0, promises_1.writeFile)(dbPath(dataDir), JSON.stringify(db, null, 2), "utf8");
}
function makeId() {
return crypto.randomUUID();
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
/** Set appliedDate and followUpDate (+7 days) if not already set. Mutates in place. */
function stampAppliedDate(app) {
if (!app.appliedDate) {
app.appliedDate = todayStr();
}
if (!app.followUpDate) {
const fu = new Date();
fu.setDate(fu.getDate() + 7);
app.followUpDate = fu.toISOString().slice(0, 10);
}
}
/** Build sets for dedup filtering against already-tracked applications. */
function buildTrackedSets(apps) {
return {
urls: new Set(apps.map((a) => a.url.toLowerCase()).filter(Boolean)),
keys: new Set(apps.map((a) => `${a.company.toLowerCase().replace(/[^a-z0-9]/g, "")}::${a.role.toLowerCase().replace(/[^a-z0-9]/g, "")}`)),
};
}
/** Filter out search hits that match already-tracked applications by URL or fuzzy company+role. */
function filterTracked(hits, tracked, limit) {
const filtered = hits.filter((h) => {
if (tracked.urls.has(h.url.toLowerCase()))
return false;
const titleNorm = h.title.toLowerCase().replace(/[^a-z0-9]/g, "");
for (const key of tracked.keys) {
const [comp, role] = key.split("::");
if (comp.length >= 3 && role.length >= 3 && titleNorm.includes(comp) && titleNorm.includes(role))
return false;
}
return true;
});
return { results: filtered.slice(0, limit), skipped: hits.length - filtered.length };
}
// ---------------------------------------------------------------------------
// Tools Provider
// ---------------------------------------------------------------------------
const toolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(config_1.pluginConfigSchematics);
const dataDir = () => getDataDir(cfg.get("dataPath"));
const maxResults = () => cfg.get("maxSearchResults");
const resumePath = () => expandPath(cfg.get("resumePath").trim());
const preferredLocation = () => cfg.get("preferredLocation").trim() || "India";
const homeCountry = () => cfg.get("homeCountry").trim() || "India";
const citizenship = () => cfg.get("citizenship").trim() || "Indian";
const openToInternational = () => cfg.get("openToInternational");
// Convert a comma-separated domain list to a DuckDuckGo site: filter string
function toSiteFilter(domains, fallback) {
const parts = (domains.trim() || fallback)
.split(",")
.map((d) => `site:${d.trim()}`)
.filter((s) => s !== "site:");
return parts.join(" OR ");
}
const homeBoards = () => toSiteFilter(cfg.get("homeJobBoards"), "naukri.com,linkedin.com,foundit.in,instahyre.com,iimjobs.com,wellfound.com,shine.com");
const globalBoards = () => toSiteFilter(cfg.get("globalJobBoards"), "linkedin.com,indeed.com,glassdoor.com,lever.co,greenhouse.io,wellfound.com");
const searxng = () => cfg.get("searxngUrl").trim() || undefined;
const searchWindow = () => {
const v = cfg.get("searchRecencyWindow").trim().toLowerCase();
return (["day", "week", "month", "year"].includes(v) ? v : undefined);
};
const webSearch = (query, max, timeRange) => (0, search_1.webSearch)(query, max, 10_000, searxng(), timeRange ?? searchWindow());
// Returns true when the job country differs from the user's home country
function isInternationalJob(jobCountry) {
if (!jobCountry)
return false;
return jobCountry.toLowerCase() !== homeCountry().toLowerCase();
}
// Normalise a raw location string to a country name best-effort
function extractCountry(location) {
const l = location.toLowerCase();
if (/\bindia\b|bengaluru|bangalore|mumbai|delhi|hyderabad|pune|chennai|kolkata|noida|gurugram|gurgaon/.test(l))
return "India";
if (/\busa\b|\bunited states\b|\bamerican\b|new york|san francisco|seattle|boston|austin|chicago|remote us/.test(l))
return "USA";
if (/\buk\b|\bunited kingdom\b|london|manchester|edinburgh/.test(l))
return "UK";
if (/\bcanada\b|toronto|vancouver|montreal/.test(l))
return "Canada";
if (/\baustralia\b|sydney|melbourne|brisbane/.test(l))
return "Australia";
if (/\bgermany\b|berlin|munich|hamburg/.test(l))
return "Germany";
if (/\bsingapore\b/.test(l))
return "Singapore";
if (/\buae\b|dubai|abu dhabi/.test(l))
return "UAE";
if (/\bnetherlands\b|amsterdam/.test(l))
return "Netherlands";
if (/\bfrance\b|paris/.test(l))
return "France";
if (/\bjapan\b|tokyo/.test(l))
return "Japan";
if (/\bremote\b/.test(l))
return "Remote";
return location; // Return as-is if unrecognised
}
const tools = [
// =========================================================================
// SEARCH
// =========================================================================
(0, sdk_1.tool)({
name: "search_jobs",
description: (0, sdk_1.text) `
Search for job listings. Defaults to the location configured in plugin settings (India by default).
For India searches, uses Indian job boards: Naukri, Foundit, Instahyre, IIMJobs, LinkedIn India.
For international searches, uses global boards and automatically flags work permit requirements.
Pass location explicitly to override the default (e.g. "Bangalore", "Remote India", "USA", "Singapore").
`,
parameters: {
query: zod_1.z.string().describe("Role, skills, or keywords (e.g. 'Senior Backend Engineer', 'Product Manager fintech')"),
location: zod_1.z.string().default("")
.describe("Location to search in. Leave blank to use configured default. Examples: 'Bangalore', 'Remote India', 'USA', 'Singapore', 'Remote'"),
includeInternational: zod_1.z.coerce.boolean().optional()
.describe("Override the international toggle for this search. Leave blank to use plugin setting."),
jobType: zod_1.z.enum(["any", "full_time", "part_time", "contract", "internship", "remote"]).default("any")
.describe("Filter by employment type"),
max: zod_1.z.coerce.number().int().min(1).max(20).optional()
.describe("Max results (default: uses plugin setting)"),
},
implementation: safe_impl("search_jobs", async ({ query, location, includeInternational, jobType, max }) => {
const limit = max ?? maxResults();
const loc = location.trim() || preferredLocation();
const country = extractCountry(loc);
const international = isInternationalJob(country) && country !== "Remote";
// Block international search if toggle is off (unless overridden per-call)
const intlAllowed = includeInternational ?? openToInternational();
if (international && !intlAllowed) {
return json({
blocked: true,
reason: `International search (${country}) is disabled. Enable "Open to International Roles" in plugin settings, or pass includeInternational: true to override.`,
suggestion: `To search locally, try location="${preferredLocation()}" or leave it blank.`,
});
}
// Build location-aware query
const typeTag = jobType !== "any" ? ` ${jobType.replace("_", " ")}` : "";
const locTag = loc ? ` ${loc}` : "";
const baseQuery = `${query}${typeTag}${locTag} job`;
// Use home boards when searching in the user's configured home country, global boards otherwise
const isHomeSearch = extractCountry(loc).toLowerCase() === homeCountry().toLowerCase();
const sites = isHomeSearch ? homeBoards() : globalBoards();
// Fetch more than needed so we can filter out already-tracked jobs
const fetchLimit = limit + 10;
let hits = await webSearch(`${baseQuery} ${sites}`, fetchLimit, searchWindow());
if (hits.length === 0) {
// Fallback: broader search without site filter
hits = await webSearch(baseQuery, fetchLimit, searchWindow());
}
// Filter out already-tracked jobs
const db = await loadDB(dataDir());
const tracked = buildTrackedSets(db.applications);
const { results: filtered, skipped } = filterTracked(hits, tracked, limit);
return json({
query,
location: loc,
country,
isInternational: international,
workPermitNote: international
? `This is an international search (${country}). Run check_work_permit with country="${country}" to see visa/permit requirements for ${citizenship()} citizens.`
: null,
jobBoards: sites.split(" OR ").map((s) => s.replace("site:", "")),
results: filtered,
alreadyTracked: skipped > 0
? `${skipped} result(s) hidden because you've already saved/applied to them.`
: null,
});
}),
}),
(0, sdk_1.tool)({
name: "fetch_job_page",
description: (0, sdk_1.text) `
Fetch and parse a job posting page from a URL.
Returns the cleaned text content of the page (HTML stripped).
Use this to get the full job description from a URL found via search_jobs.
`,
parameters: {
url: zod_1.z.string().url().describe("URL of the job posting page"),
},
implementation: safe_impl("fetch_job_page", async ({ url }) => {
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; job-search-plugin/1.0)" },
signal: AbortSignal.timeout(15000),
});
if (!res.ok)
throw new Error(`HTTP ${res.status} fetching ${url}`);
const html = await res.text();
const text = stripHtml(html).slice(0, 8000);
return json({ url, content: text });
}),
}),
(0, sdk_1.tool)({
name: "search_company",
description: (0, sdk_1.text) `
Search for information about a company: culture, reviews, recent news,
funding, size, and tech stack. Useful before applying or for interview prep.
`,
parameters: {
company: zod_1.z.string().describe("Company name to research"),
focus: zod_1.z.string().optional()
.describe("Optional focus area: 'reviews', 'news', 'tech stack', 'funding', etc."),
},
implementation: safe_impl("search_company", async ({ company, focus }) => {
const q = focus
? `${company} company ${focus}`
: `${company} company culture reviews tech stack ${new Date().getFullYear()}`;
const hits = await webSearch(q, maxResults(), "year");
return json({ company, focus: focus ?? "general", results: hits });
}),
}),
// =========================================================================
// APPLICATION TRACKER
// =========================================================================
(0, sdk_1.tool)({
name: "add_application",
description: (0, sdk_1.text) `
Add a new job application to the tracker.
Returns the new application with its generated ID.
`,
parameters: {
company: zod_1.z.string().describe("Company name"),
role: zod_1.z.string().describe("Job title / role"),
url: zod_1.z.string().default("").describe("Job posting URL"),
location: zod_1.z.string().default("").describe("Location or 'Remote'"),
status: zod_1.z.enum(["saved", "applied", "interview", "offer", "rejected", "withdrawn"])
.default("saved").describe("Current status"),
jobDescription: zod_1.z.string().default("").describe("Paste the job description here"),
notes: zod_1.z.string().default("").describe("Personal notes about this role"),
salary: zod_1.z.string().default("").describe("Salary range or compensation info"),
nextStep: zod_1.z.string().default("").describe("Next action to take"),
},
implementation: safe_impl("add_application", async (params) => {
const db = await loadDB(dataDir());
const country = extractCountry(params.location);
const international = isInternationalJob(country) && country !== "Remote";
// Duplicate detection: warn if same company+role already tracked
const companyLower = params.company.toLowerCase();
const roleNorm = params.role.toLowerCase().replace(/[^a-z0-9]/g, "");
const duplicate = db.applications.find((a) => a.company.toLowerCase() === companyLower &&
a.role.toLowerCase().replace(/[^a-z0-9]/g, "") === roleNorm);
const app = {
id: makeId(),
company: params.company,
role: params.role,
url: params.url,
location: params.location,
country,
isInternational: international,
status: params.status,
appliedDate: "",
postedDate: "",
followUpDate: "",
jobDescription: params.jobDescription,
notes: params.notes,
salary: params.salary,
totalComp: "",
nextStep: params.nextStep,
contacts: [],
workPermitStatus: international ? "unknown" : "not_required",
workPermitNotes: international
? `International role in ${country}. Run check_work_permit for ${citizenship()} visa requirements.`
: "",
legitimacyScore: -1,
legitimacyFlags: [],
interviewNotes: [],
rejectionReason: "",
statusHistory: [{ status: params.status, date: todayStr() }],
updatedAt: new Date().toISOString(),
};
if (params.status === "applied")
stampAppliedDate(app);
const previousFlag = db.applications.find((a) => a.company.toLowerCase() === companyLower && a.legitimacyScore >= 0 && a.legitimacyScore < 50);
db.applications.push(app);
await saveDB(dataDir(), db);
return json({
success: true,
application: app,
duplicateWarning: duplicate
? `⚠Possible duplicate: you already have "${duplicate.company} — ${duplicate.role}" tracked (ID: ${duplicate.id}, status: ${duplicate.status}). This new entry was still added — delete it if it's a duplicate.`
: null,
internationalAlert: international
? `âš International role detected (${country}). Use check_work_permit to verify visa requirements.`
: null,
followUpReminder: app.followUpDate
? `Follow-up reminder set for ${app.followUpDate} (7 days after applying).`
: null,
legitimacyWarning: previousFlag
? `âš WARNING: ${params.company} was previously flagged as suspicious (score ${previousFlag.legitimacyScore}/100). Red flags: ${previousFlag.legitimacyFlags.join("; ")}. Run verify_company_legitimacy before proceeding.`
: null,
});
}),
}),
(0, sdk_1.tool)({
name: "update_application",
description: (0, sdk_1.text) `
Update fields on an existing job application by ID.
Only supply the fields you want to change.
Use list_applications to find application IDs.
`,
parameters: {
id: zod_1.z.string().describe("Application ID"),
company: zod_1.z.string().optional(),
role: zod_1.z.string().optional(),
url: zod_1.z.string().optional(),
location: zod_1.z.string().optional(),
status: zod_1.z.enum(["saved", "applied", "interview", "offer", "rejected", "withdrawn"]).optional(),
jobDescription: zod_1.z.string().optional(),
notes: zod_1.z.string().optional().describe("Notes to APPEND (not overwrite). New text is added below existing notes."),
salary: zod_1.z.string().optional(),
totalComp: zod_1.z.string().optional().describe("Total compensation summary"),
nextStep: zod_1.z.string().optional(),
postedDate: zod_1.z.string().optional().describe("Date the job was posted (YYYY-MM-DD)"),
followUpDate: zod_1.z.string().optional().describe("When to follow up (YYYY-MM-DD)"),
workPermitStatus: zod_1.z.enum(["not_required", "eligible", "requires_sponsorship", "not_eligible", "unknown"]).optional(),
workPermitNotes: zod_1.z.string().optional().describe("Work permit / visa notes for this role"),
rejectionReason: zod_1.z.string().optional().describe("Why the application was rejected"),
},
implementation: safe_impl("update_application", async ({ id, ...updates }) => {
const db = await loadDB(dataDir());
const idx = db.applications.findIndex((a) => a.id === id);
if (idx === -1)
throw new Error(`Application ID '${id}' not found.`);
const app = db.applications[idx];
const merged = {
...app,
...(updates.company !== undefined && { company: updates.company }),
...(updates.role !== undefined && { role: updates.role }),
...(updates.url !== undefined && { url: updates.url }),
...(updates.location !== undefined && { location: updates.location }),
...(updates.status !== undefined && { status: updates.status }),
...(updates.jobDescription !== undefined && { jobDescription: updates.jobDescription }),
...(updates.notes !== undefined && { notes: app.notes ? `${app.notes}\n${updates.notes}` : updates.notes }),
...(updates.salary !== undefined && { salary: updates.salary }),
...(updates.totalComp !== undefined && { totalComp: updates.totalComp }),
...(updates.nextStep !== undefined && { nextStep: updates.nextStep }),
...(updates.postedDate !== undefined && { postedDate: updates.postedDate }),
...(updates.followUpDate !== undefined && { followUpDate: updates.followUpDate }),
...(updates.workPermitStatus !== undefined && { workPermitStatus: updates.workPermitStatus }),
...(updates.workPermitNotes !== undefined && { workPermitNotes: updates.workPermitNotes }),
...(updates.rejectionReason !== undefined && { rejectionReason: updates.rejectionReason }),
updatedAt: new Date().toISOString(),
};
if (updates.status === "applied")
stampAppliedDate(merged);
if (updates.status && updates.status !== app.status) {
merged.statusHistory.push({ status: updates.status, date: todayStr() });
}
db.applications[idx] = merged;
await saveDB(dataDir(), db);
return json({ success: true, application: merged });
}),
}),
(0, sdk_1.tool)({
name: "list_applications",
description: (0, sdk_1.text) `
List all tracked job applications, optionally filtered by status.
Returns a summary table: ID, company, role, status, next step, updated date.
`,
parameters: {
status: zod_1.z.enum(["saved", "applied", "interview", "offer", "rejected", "withdrawn", "all"])
.default("all").describe("Filter by status, or 'all' for everything"),
search: zod_1.z.string().default("").describe("Search keyword in company or role name"),
internationalOnly: zod_1.z.coerce.boolean().default(false)
.describe("Show only international / cross-border applications"),
workPermitStatus: zod_1.z.enum(["not_required", "eligible", "requires_sponsorship", "not_eligible", "unknown", "all"])
.default("all").describe("Filter by work permit status"),
},
implementation: safe_impl("list_applications", async ({ status, search, internationalOnly, workPermitStatus }) => {
const db = await loadDB(dataDir());
let apps = db.applications;
if (status !== "all")
apps = apps.filter((a) => a.status === status);
if (internationalOnly)
apps = apps.filter((a) => a.isInternational);
if (workPermitStatus !== "all")
apps = apps.filter((a) => a.workPermitStatus === workPermitStatus);
if (search) {
const kw = search.toLowerCase();
apps = apps.filter((a) => a.company.toLowerCase().includes(kw) || a.role.toLowerCase().includes(kw));
}
// Sort: most recently updated first
apps = apps.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
const summary = apps.map((a) => ({
id: a.id,
company: a.company,
role: a.role,
location: a.location,
country: a.country,
isInternational: a.isInternational,
status: a.status,
appliedDate: a.appliedDate,
salary: a.salary,
workPermitStatus: a.workPermitStatus,
nextStep: a.nextStep,
updatedAt: a.updatedAt.slice(0, 10),
}));
const stats = {
total: db.applications.length,
saved: db.applications.filter((a) => a.status === "saved").length,
applied: db.applications.filter((a) => a.status === "applied").length,
interview: db.applications.filter((a) => a.status === "interview").length,
offer: db.applications.filter((a) => a.status === "offer").length,
rejected: db.applications.filter((a) => a.status === "rejected").length,
international: db.applications.filter((a) => a.isInternational).length,
pendingWorkPermitCheck: db.applications.filter((a) => a.isInternational && a.workPermitStatus === "unknown").length,
};
return json({ stats, filter: { status, search, internationalOnly, workPermitStatus }, applications: summary });
}),
}),
(0, sdk_1.tool)({
name: "get_application",
description: (0, sdk_1.text) `
Get the full details of a single job application by ID, including
the stored job description and all notes.
`,
parameters: {
id: zod_1.z.string().describe("Application ID"),
},
implementation: safe_impl("get_application", async ({ id }) => {
const db = await loadDB(dataDir());
const app = db.applications.find((a) => a.id === id);
if (!app)
throw new Error(`Application ID '${id}' not found.`);
return json(app);
}),
}),
(0, sdk_1.tool)({
name: "delete_application",
description: (0, sdk_1.text) `
Permanently delete a job application by ID.
This cannot be undone — confirm the ID with list_applications first.
`,
parameters: {
id: zod_1.z.string().describe("Application ID to delete"),
},
implementation: safe_impl("delete_application", async ({ id }) => {
const db = await loadDB(dataDir());
const before = db.applications.length;
db.applications = db.applications.filter((a) => a.id !== id);
if (db.applications.length === before)
throw new Error(`Application ID '${id}' not found.`);
await saveDB(dataDir(), db);
return json({ success: true, deleted: id });
}),
}),
// =========================================================================
// ANALYSIS
// =========================================================================
(0, sdk_1.tool)({
name: "analyze_job_description",
description: (0, sdk_1.text) `
Analyze a job description and extract structured information:
required skills, nice-to-have skills, responsibilities, seniority level,
red flags, and key questions to ask the interviewer.
Paste the raw job description text as input.
`,
parameters: {
jobDescription: zod_1.z.string().min(50)
.describe("Full job description text (paste it in directly)"),
role: zod_1.z.string().default("").describe("Job title (optional, improves analysis)"),
},
implementation: safe_impl("analyze_job_description", async ({ jobDescription, role }) => {
// Structure the raw text into a prompt-ready format for the model.
// We return the JD along with analysis hints — the LLM will do the synthesis.
const wordCount = jobDescription.split(/\s+/).length;
const hasRemote = /remote|hybrid/i.test(jobDescription);
const hasSalary = /\$[\d,]+|\d+k\b|salary|compensation/i.test(jobDescription);
const techMatches = jobDescription.match(/\b(TypeScript|JavaScript|Python|Go|Rust|Java|C\+\+|React|Vue|Angular|Node\.js|AWS|GCP|Azure|Docker|Kubernetes|PostgreSQL|MySQL|MongoDB|Redis|GraphQL|REST|gRPC|Terraform|Kafka|Spark|ML|LLM|AI)\b/gi);
const techs = [...new Set((techMatches ?? []).map((t) => t.toLowerCase()))];
return json({
role: role || "Unknown",
wordCount,
mentionsRemote: hasRemote,
mentionsSalary: hasSalary,
detectedTechnologies: techs,
jobDescription,
instructions: "Using the jobDescription above, extract and present: " +
"(1) Required skills, (2) Nice-to-have skills, (3) Key responsibilities, " +
"(4) Seniority level (IC1–IC6), (5) Any red flags, " +
"(6) Top 5 questions to ask the interviewer.",
});
}),
}),
(0, sdk_1.tool)({
name: "match_resume_to_job",
description: (0, sdk_1.text) `
Compare the user's resume against a job description and produce a fit score (0–100),
list of matched skills, gaps, and prioritized suggestions to improve fit.
Reads the resume from the path configured in plugin settings, or accepts inline text.
`,
parameters: {
jobDescription: zod_1.z.string().min(20).describe("Full job description text"),
resumeText: zod_1.z.string().default("")
.describe("Inline resume text. If blank, reads from the configured Resume File Path."),
},
implementation: safe_impl("match_resume_to_job", async ({ jobDescription, resumeText }) => {
let resume = resumeText.trim();
if (!resume) {
const rp = resumePath();
if (!rp)
throw new Error("No resume text provided and Resume File Path is not configured in plugin settings.");
resume = await readResumeFile(rp);
}
// Extract tech/skill tokens from both for a quick overlap check
const extractSkills = (text) => {
const m = text.match(/\b(TypeScript|JavaScript|Python|Go|Rust|Java|C\+\+|React|Vue|Angular|Node\.js|AWS|GCP|Azure|Docker|Kubernetes|PostgreSQL|MySQL|MongoDB|Redis|GraphQL|REST|gRPC|Terraform|Kafka|Spark|ML|LLM|AI|Machine Learning|Data Science|Product Management|Agile|Scrum|CI\/CD|DevOps)\b/gi);
return [...new Set((m ?? []).map((s) => s.toLowerCase()))];
};
const jobSkills = extractSkills(jobDescription);
const resumeSkills = extractSkills(resume);
const matched = jobSkills.filter((s) => resumeSkills.includes(s));
const gaps = jobSkills.filter((s) => !resumeSkills.includes(s));
const roughScore = jobSkills.length > 0
? Math.round((matched.length / jobSkills.length) * 100)
: 50;
return json({
roughFitScore: roughScore,
matchedSkills: matched,
skillGaps: gaps,
resumeSkillsDetected: resumeSkills,
jobSkillsDetected: jobSkills,
resume: resume.slice(0, 8000),
jobDescription: jobDescription.slice(0, 4000),
instructions: "Using the resume and job description above, produce a structured analysis: " +
"(1) Overall fit score 0–100 with rationale. " +
"(2) Strengths — what already matches well (be specific, quote relevant resume lines). " +
"(3) Skill gaps — list each missing keyword/skill from the JD that is absent from the resume. " +
"(4) Editing guide — for EACH gap, give a concrete action: " +
" - If the user likely has this experience but hasn't mentioned it: say WHERE in the resume to add it (e.g., 'Under your [Company X] role, add a bullet like: Designed and deployed a RAG pipeline using LangChain and Pinecone...'). " +
" - If the skill is genuinely missing: say so and suggest a quick way to demonstrate it (side project, certification, open-source contribution). " +
"(5) Keyword injection — list 5–8 exact keywords from the JD the resume should include verbatim (ATS optimization). " +
"(6) Recommendation: Apply now / Apply after minor edits / Significant rework needed — with a one-line reason.",
});
}),
}),
// =========================================================================
// GENERATION
// =========================================================================
(0, sdk_1.tool)({
name: "generate_cover_letter",
description: (0, sdk_1.text) `
Generate a tailored cover letter draft for a specific job.
Provide the job description and basic info about the applicant.
The model will write a compelling, concise cover letter (3–4 paragraphs).
`,
parameters: {
company: zod_1.z.string().describe("Company name"),
role: zod_1.z.string().describe("Job title"),
jobDescription: zod_1.z.string().min(20).describe("Job description text"),
applicantName: zod_1.z.string().default("").describe("Your full name"),
applicantBackground: zod_1.z.string().default("")
.describe("Brief background: years of experience, key skills, notable achievements"),
tone: zod_1.z.enum(["professional", "conversational", "enthusiastic"]).default("professional")
.describe("Tone of the letter"),
resumeText: zod_1.z.string().default("")
.describe("Optional resume text; if blank, reads from configured Resume File Path"),
},
implementation: safe_impl("generate_cover_letter", async (params) => {
let resume = params.resumeText.trim();
if (!resume) {
const rp = resumePath();
if (rp) {
resume = await readResumeFile(rp); // let errors surface — don't silently drop resume
}
}
return json({
company: params.company,
role: params.role,
applicantName: params.applicantName,
applicantBackground: params.applicantBackground,
tone: params.tone,
jobDescription: params.jobDescription.slice(0, 4000),
resume: resume.slice(0, 6000),
instructions: `Write a ${params.tone} cover letter for ${params.applicantName || "the applicant"} ` +
`applying to ${params.role} at ${params.company}. ` +
"Structure: (1) Hook opening that shows genuine interest, " +
"(2) Why this role / company excites them (specific), " +
"(3) Top 2-3 relevant achievements with numbers where possible, " +
"(4) Confident closing. Keep it under 350 words. No fluff.",
});
}),
}),
(0, sdk_1.tool)({
name: "generate_resume_bullets",
description: (0, sdk_1.text) `
Generate strong, achievement-oriented resume bullet points for a role or experience.
Follows the "Accomplished X by doing Y, resulting in Z" pattern with metrics.
`,
parameters: {
role: zod_1.z.string().describe("Job title / role (e.g. 'Senior Backend Engineer')"),
company: zod_1.z.string().default("").describe("Company or project name"),
responsibilities: zod_1.z.string()
.describe("Describe what you did in this role (rough notes are fine)"),
targetRole: zod_1.z.string().default("")
.describe("Target job title you are applying to (helps tailor the bullets)"),
count: zod_1.z.coerce.number().int().min(2).max(8).default(4)
.describe("Number of bullet points to generate"),
},
implementation: safe_impl("generate_resume_bullets", async (params) => {
return json({
sourceRole: params.role,
company: params.company,
targetRole: params.targetRole,
responsibilities: params.responsibilities,
count: params.count,
instructions: `Generate ${params.count} strong resume bullet points for ${params.role}` +
(params.company ? ` at ${params.company}` : "") + ". " +
"Each bullet: start with a powerful action verb, include a specific metric or outcome, " +
"keep under 20 words. " +
(params.targetRole ? `Tailor for a ${params.targetRole} position. ` : "") +
"Format: '• <bullet>' per line.",
});
}),
}),
(0, sdk_1.tool)({
name: "prepare_interview_questions",
description: (0, sdk_1.text) `
Generate a tailored set of interview questions and suggested answers
based on the job description and the applicant's background.
Covers behavioral, technical, and situational questions.
`,
parameters: {
jobDescription: zod_1.z.string().min(20).describe("Job description text"),
role: zod_1.z.string().describe("Job title"),
interviewStage: zod_1.z.enum(["phone_screen", "technical", "behavioral", "system_design", "final"])
.default("behavioral").describe("Stage of interview to prepare for"),
applicantBackground: zod_1.z.string().default("")
.describe("Brief background to tailor questions"),
questionCount: zod_1.z.coerce.number().int().min(3).max(20).default(8)
.describe("Number of questions to generate"),
},
implementation: safe_impl("prepare_interview_questions", async (params) => {
return json({
role: params.role,
interviewStage: params.interviewStage,
questionCount: params.questionCount,
applicantBackground: params.applicantBackground,
jobDescription: params.jobDescription.slice(0, 2000),
instructions: `Generate ${params.questionCount} ${params.interviewStage.replace("_", " ")} ` +
`interview questions for a ${params.role} role, based on the job description above. ` +
"For each question, provide: " +
"(Q) The question, (Why) Why interviewers ask it, " +
"(A) A strong answer framework / sample answer outline. " +
"Focus on what actually matters for this specific role.",
});
}),
}),
(0, sdk_1.tool)({
name: "export_applications_report",
description: (0, sdk_1.text) `
Export a formatted Markdown report of all job applications to a file.
The report includes a pipeline summary and per-application details.
Returns the file path where the report was saved.
`,
parameters: {
outputPath: zod_1.z.string().default("")
.describe("File path to save the report. Defaults to <dataPath>/report-<date>.md"),
},
implementation: safe_impl("export_applications_report", async ({ outputPath }) => {
const db = await loadDB(dataDir());
const date = new Date().toISOString().slice(0, 10);
const outPath = outputPath.trim() || (0, path_1.join)(dataDir(), `report-${date}.md`);
const statuses = ["saved", "applied", "interview", "offer", "rejected", "withdrawn"];
let md = `# Job Search Report — ${date}\n\n`;
md += `## Pipeline Summary\n\n`;
for (const s of statuses) {
const count = db.applications.filter((a) => a.status === s).length;
md += `- **${s.charAt(0).toUpperCase() + s.slice(1)}**: ${count}\n`;
}
md += `\n**Total tracked**: ${db.applications.length}\n\n`;
md += `---\n\n`;
for (const s of statuses) {
const apps = db.applications.filter((a) => a.status === s);
if (apps.length === 0)
continue;
md += `## ${s.charAt(0).toUpperCase() + s.slice(1)} (${apps.length})\n\n`;
for (const a of apps) {
md += `### ${a.company} — ${a.role}\n`;
if (a.location)
md += `**Location**: ${a.location} \n`;
if (a.salary)
md += `**Salary**: ${a.salary} \n`;
if (a.url)
md += `**URL**: ${a.url} \n`;
if (a.appliedDate)
md += `**Applied**: ${a.appliedDate} \n`;
if (a.nextStep)
md += `**Next Step**: ${a.nextStep} \n`;
if (a.contacts.length > 0)
md += `**Contacts**: ${a.contacts.map((c) => c.name + (c.role ? ` (${c.role})` : "")).join(", ")} \n`;
if (a.notes)
md += `\n> ${a.notes.replace(/\n/g, "\n> ")}\n`;
md += `\n`;
}
}
await (0, promises_1.mkdir)(dataDir(), { recursive: true });
await (0, promises_1.writeFile)(outPath, md, "utf8");
return json({ success: true, path: outPath, applicationCount: db.applications.length });
}),
}),
// =========================================================================
// TIMING & URGENCY
// =========================================================================
(0, sdk_1.tool)({
name: "get_application_urgency",
description: (0, sdk_1.text) `
Analyze the timing urgency of a job application based on when it was posted.
Returns a priority score and recommendation on whether to apply now, soon, or skip.
Research shows:
- Day 0–2 after posting: ~2–3× higher callback rate (apply ASAP)
- Day 3–7: still good odds, apply today
- Day 8–14: odds dropping, but worth applying if strong match
- Day 15–30: late, hiring may already be advanced
- Day 30+: likely filled or position changed
Also sets a follow-up reminder date if the application is already tracked.
`,
parameters: {
postedDate: zod_1.z.string().default("")
.describe("Date the job was posted (YYYY-MM-DD or natural language like 'posted 3 days ago')"),
applicationId: zod_1.z.string().default("")
.describe("Optional: application ID to update with postedDate and followUpDate"),
role: zod_1.z.string().default("").describe("Role name (for context)"),
company: zod_1.z.string().default("").describe("Company name (for context)"),
},
implementation: safe_impl("get_application_urgency", async ({ postedDate, applicationId, role, company }) => {
const today = new Date();
let parsedDate = null;
// Parse "X days ago" patterns
const daysAgoMatch = postedDate.match(/(\d+)\s*day/i);
const weeksAgoMatch = postedDate.match(/(\d+)\s*week/i);
const monthsAgoMatch = postedDate.match(/(\d+)\s*month/i);
if (daysAgoMatch) {
parsedDate = new Date(today);
parsedDate.setDate(parsedDate.getDate() - parseInt(daysAgoMatch[1]));
}
else if (weeksAgoMatch) {
parsedDate = new Date(today);
parsedDate.setDate(parsedDate.getDate() - parseInt(weeksAgoMatch[1]) * 7);
}
else if (monthsAgoMatch) {
parsedDate = new Date(today);
parsedDate.setDate(parsedDate.getDate() - parseInt(monthsAgoMatch[1]) * 30);
}
else if (/^\d{4}-\d{2}-\d{2}$/.test(postedDate)) {
parsedDate = new Date(postedDate);
}
else if (postedDate.toLowerCase().includes("today") || postedDate.toLowerCase().includes("just posted")) {
parsedDate = new Date(today);
}
else if (postedDate.toLowerCase().includes("yesterday")) {
parsedDate = new Date(today);
parsedDate.setDate(parsedDate.getDate() - 1);
}
const daysOld = parsedDate
? Math.floor((today.getTime() - parsedDate.getTime()) / (1000 * 60 * 60 * 24))
: null;
let urgency;
let score;
let recommendation;
let callbackMultiplier;
if (daysOld === null) {
urgency = "unknown";
score = 5;
recommendation = "Could not parse date. Apply as soon as possible to be safe.";
callbackMultiplier = "unknown";
}
else if (daysOld <= 2) {
urgency = "CRITICAL";
score = 10;
recommendation = "Apply within the next few hours. Early applicants get 2–3× more callbacks.";
callbackMultiplier = "2–3×";
}
else if (daysOld <= 7) {
urgency = "HIGH";
score = 8;
recommendation = "Apply today. First week applicants still have strong odds.";
callbackMultiplier = "1.5–2×";
}
else if (daysOld <= 14) {
urgency = "MEDIUM";
score = 5;
recommendation = "Apply soon. Odds are declining but still worth it for a strong match.";
callbackMultiplier = "0.8–1×";
}
else if (daysOld <= 30) {
urgency = "LOW";
score = 3;
recommendation = "Late application. Callback odds are lower, but worth applying if you're a strong match — referrals help but are not required.";
callbackMultiplier = "0.3–0.6×";
}
else {
urgency = "STALE";
score = 1;
recommendation = "Job likely filled or no longer active. Check if still open before applying.";
callbackMultiplier = "~0Ă—";
}
// Calculate follow-up date (7 days after applying for early apps, 5 for late)
const followUpDays = (daysOld ?? 0) <= 7 ? 7 : 5;
const followUpDate = new Date(today);
followUpDate.setDate(followUpDate.getDate() + followUpDays);
const followUpDateStr = followUpDate.toISOString().slice(0, 10);
const postedDateStr = parsedDate ? parsedDate.toISOString().slice(0, 10) : "";
// Update the application record if ID provided
if (applicationId) {
const db = await loadDB(dataDir());
const idx = db.applications.findIndex((a) => a.id === applicationId);
if (idx !== -1) {
if (postedDateStr)
db.applications[idx].postedDate = postedDateStr;
db.applications[idx].followUpDate = followUpDateStr;
db.applications[idx].updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
}
}
return json({
role: role || "Unknown",
company: company || "Unknown",
postedDate: postedDateStr || postedDate,
daysOld,
urgency,
urgencyScore: score,
callbackMultiplier,
recommendation,
followUpDate: followUpDateStr,
timeframeGuide: {
"0–2 days": "Apply immediately — peak window",
"3–7 days": "Apply today — still hot",
"8–14 days": "Apply this week — fading",
"15–30 days": "Late — referral recommended",
"30+ days": "Likely stale — verify it's still open",
},
});
}),
}),
(0, sdk_1.tool)({
name: "list_followups",
description: (0, sdk_1.text) `
List all applications that need a follow-up action today or in the near future.
Includes applications where followUpDate has passed or is within the next N days.
Sorted by urgency (overdue first).
`,
parameters: {
daysAhead: zod_1.z.coerce.number().int().min(0).max(30).default(7)
.describe("Show follow-ups due within this many days"),
},
implementation: safe_impl("list_followups", async ({ daysAhead }) => {
const db = await loadDB(dataDir());
const today = new Date();
const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() + daysAhead);
const todayStr = today.toISOString().slice(0, 10);
const cutoffStr = cutoff.toISOString().slice(0, 10);
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
const active = db.applications.filter((a) => a.status !== "rejected" &&
a.status !== "withdrawn" &&
dateRe.test(a.followUpDate ?? "") &&
a.followUpDate <= cutoffStr);
active.sort((a, b) => a.followUpDate.localeCompare(b.followUpDate));
const result = active.map((a) => {
const overdue = a.followUpDate < todayStr;
const daysUntil = Math.floor((new Date(a.followUpDate).getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
return {
id: a.id,
company: a.company,
role: a.role,
status: a.status,
followUpDate: a.followUpDate,
daysUntil,
overdue,
urgency: overdue ? "OVERDUE" : daysUntil === 0 ? "TODAY" : `in ${daysUntil}d`,
nextStep: a.nextStep,
};
});
const overdue = result.filter((r) => r.overdue).length;
const dueToday = result.filter((r) => !r.overdue && r.daysUntil === 0).length;
return json({
summary: { overdue, dueToday, upcoming: result.length - overdue - dueToday },
followUps: result,
});
}),
}),
(0, sdk_1.tool)({
name: "detect_stale_applications",
description: (0, sdk_1.text) `
Find applications that have gone silent — no status update in N days — and suggest next actions.
Filters to active statuses (saved, applied, interview) and flags those with no recent update.
Returns recommended action for each stale application: follow up, ghost or withdraw, or reassess.
`,
parameters: {
staleDays: zod_1.z.coerce.number().int().min(1).max(90).default(14)
.describe("Consider an application stale if not updated in this many days"),
includeStatuses: zod_1.z.array(zod_1.z.enum(["saved", "applied", "interview"])).default(["applied", "interview"])
.describe("Which statuses to check (saved = pre-application, applied, interview)"),
},
implementation: safe_impl("detect_stale_applications", async ({ staleDays, includeStatuses }) => {
const db = await loadDB(dataDir());
const now = new Date();
const cutoff = new Date(now.getTime() - staleDays * 24 * 60 * 60 * 1000);
const stale = db.applications
.filter((a) => includeStatuses.includes(a.status))
.filter((a) => new Date(a.updatedAt) < cutoff)
.map((a) => {
const daysSinceUpdate = Math.floor((now.getTime() - new Date(a.updatedAt).getTime()) / (1000 * 60 * 60 * 24));
const appliedDaysAgo = a.appliedDate
? Math.floor((now.getTime() - new Date(a.appliedDate).getTime()) / (1000 * 60 * 60 * 24))
: null;
let recommendedAction;
if (a.status === "interview") {
recommendedAction = "Send a polite follow-up email to the hiring manager or recruiter";
}
else if (a.status === "applied" && appliedDaysAgo !== null && appliedDaysAgo > 30) {
recommendedAction = daysSinceUpdate > 45
? "Likely ghosted — consider withdrawing or marking as rejected"
: "Send one follow-up email; if no response in 7 days, consider it closed";
}
else if (a.status === "applied") {
recommendedAction = "Send a polite follow-up if you haven't already";
}
else {
recommendedAction = "Reassess whether to apply or move on";
}
return {
id: a.id,
company: a.company,
role: a.role,
status: a.status,
daysSinceUpdate,
appliedDaysAgo,
legitimacyScore: a.legitimacyScore >= 0 ? a.legitimacyScore : null,
legitimacyWarning: a.legitimacyScore >= 0 && a.legitimacyScore < 50
? `âš Previously flagged as suspicious (${a.legitimacyScore}/100)`
: null,
recommendedAction,
};
})
.sort((a, b) => b.daysSinceUpdate - a.daysSinceUpdate);
return json({
summary: {
staleCount: stale.length,
staleDaysThreshold: staleDays,
likelyghosted: stale.filter((a) => a.daysSinceUpdate > 45).length,
},
staleApplications: stale,
tip: stale.length === 0
? "No stale applications found — your pipeline is up to date."
: "Act on these today. Ghosting is common; one follow-up email is professional and expected.",
});
}),
}),
(0, sdk_1.tool)({
name: "calculate_total_comp",
description: (0, sdk_1.text) `
Calculate and compare total compensation for a job offer.
Goes beyond base salary to include bonus, equity, benefits, and cost-of-living adjustments.
Returns a breakdown and annualized total comp figure.
Optionally saves the result to an application record.
`,
parameters: {
applicationId: zod_1.z.string().default("").describe("Optional application ID to save result to"),
baseSalary: zod_1.z.coerce.number().describe("Annual base salary"),
currency: zod_1.z.string().default("USD").describe("Currency (USD, EUR, GBP, INR, etc.)"),
bonusPercent: zod_1.z.coerce.number().min(0).max(100).default(0)
.describe("Target annual bonus as % of base salary"),
equityValue: zod_1.z.coerce.number().min(0).default(0)
.describe("Total equity grant value (e.g. total RSU value at grant)"),
equityVestYears: zod_1.z.coerce.number().int().min(1).max(10).default(4)
.describe("Equity vesting period in years (typically 4)"),
signOnBonus: zod_1.z.coerce.number().min(0).default(0).describe("One-time sign-on bonus"),
annualBenefitsValue: zod_1.z.coerce.number().min(0).default(0)
.describe("Estimated annual value of benefits: health, 401k match, etc."),
remoteStipend: zod_1.z.coerce.number().min(0).default(0)
.describe("Annual remote work stipend / home office allowance"),
location: zod_1.z.string().default("").describe("Job location (for COL context)"),
currentTotalComp: zod_1.z.coerce.number().min(0).default(0)
.describe("Your current total comp for comparison (0 to skip)"),
},
implementation: safe_impl("calculate_total_comp", async (params) => {
const annualBonus = params.baseSalary * (params.bonusPercent / 100);
const annualEquity = params.equityVestYears > 0
? params.equityValue / params.equityVestYears
: 0;
const firstYearComp = params.baseSalary + annualBonus + annualEquity +
params.signOnBonus + params.annualBenefitsValue + params.remoteStipend;
const steadyStateComp = params.baseSalary + annualBonus + annualEquity +
params.annualBenefitsValue + params.remoteStipend;
const changeVsCurrent = params.currentTotalComp > 0
? {
absoluteDiff: steadyStateComp - params.currentTotalComp,
percentDiff: Math.round(((steadyStateComp - params.currentTotalComp) / params.currentTotalComp) * 100),
}
: null;
const summary = [
`Base: ${params.currency} ${params.baseSalary.toLocaleString()}`,
annualBonus > 0 ? `Bonus: ${params.currency} ${Math.round(annualBonus).toLocaleString()} (${params.bonusPercent}%)` : null,
annualEquity > 0 ? `Equity/yr: ${params.currency} ${Math.round(annualEquity).toLocaleString()} (${params.equityVestYears}yr vest)` : null,
params.signOnBonus > 0 ? `Sign-on: ${params.currency} ${params.signOnBonus.toLocaleString()} (yr1 only)` : null,
params.annualBenefitsValue > 0 ? `Benefits: ${params.currency} ${params.annualBenefitsValue.toLocaleString()}/yr` : null,
params.remoteStipend > 0 ? `Stipend: ${params.currency} ${params.remoteStipend.toLocaleString()}/yr` : null,
].filter(Boolean).join(" | ");
if (params.applicationId) {
const db = await loadDB(dataDir());
const idx = db.applications.findIndex((a) => a.id === params.applicationId);
if (idx !== -1) {
db.applications[idx].salary = `${params.currency} ${params.baseSalary.toLocaleString()} base`;
db.applications[idx].totalComp = `${params.currency} ${Math.round(steadyStateComp).toLocaleString()} TC`;
db.applications[idx].updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
}
}
return json({
location: params.location || "Not specified",
breakdown: {
baseSalary: params.baseSalary,
annualBonus: Math.round(annualBonus),
annualEquity: Math.round(annualEquity),
signOnBonus: params.signOnBonus,
annualBenefitsValue: params.annualBenefitsValue,
remoteStipend: params.remoteStipend,
},
firstYearTotalComp: Math.round(firstYearComp),
steadyStateTotalComp: Math.round(steadyStateComp),
currency: params.currency,
summaryLine: summary,
changeVsCurrent,
notes: [
"Equity value is pre-tax and assumes full vesting",
"Bonus is target (not guaranteed)",
"Consider cost-of-living if comparing offers across cities",
],
});
}),
}),
// =========================================================================
// SALARY MARKET DATA
// =========================================================================
(0, sdk_1.tool)({
name: "check_salary_market",
description: (0, sdk_1.text) `
Look up current market salary data for a role and location.
Searches Glassdoor, LinkedIn Salary, levels.fyi, and industry surveys
to give you a realistic salary range before negotiating or evaluating an offer.
Use this before applying (to set expectations) or after receiving an offer (to negotiate).
`,
parameters: {
role: zod_1.z.string().describe("Job title to look up (e.g. 'Senior Software Engineer', 'ML Engineer', 'Product Manager')"),
location: zod_1.z.string().default("").describe("Location or market (e.g. 'Bangalore', 'San Francisco', 'Remote US', 'London')"),
yearsOfExperience: zod_1.z.coerce.number().int().min(0).max(40).default(0)
.describe("Years of experience — 0 to skip (affects percentile shown)"),
currency: zod_1.z.string().default("").describe("Currency to look for (e.g. INR, USD, GBP) — leave blank to infer from location"),
},
implementation: safe_impl("check_salary_market", async ({ role, location, yearsOfExperience, currency }) => {
const yr = new Date().getFullYear();
const loc = location.trim() || preferredLocation();
const expTag = yearsOfExperience > 0 ? ` ${yearsOfExperience} years experience` : "";
const curr = currency.trim();
const searches = [
{ angle: "glassdoor", query: `${role} salary ${loc}${expTag} ${yr} site:glassdoor.com` },
{ angle: "linkedin", query: `${role} salary ${loc} ${yr} site:linkedin.com/salary` },
{ angle: "levels_fyi", query: `${role} compensation ${loc} ${yr} site:levels.fyi` },
{ angle: "survey", query: `${role} salary range ${loc}${expTag} ${curr} median ${yr} survey` },
{ angle: "reddit", query: `${role} salary ${loc}${expTag} ${yr} site:reddit.com` },
];
const results = [];
for (const s of searches) {
try {
const hits = await webSearch(s.query, 5, searchWindow());
results.push({ angle: s.angle, query: s.query, hits });
}
catch {
results.push({ angle: s.angle, query: s.query, hits: [] });
}
}
return json({
role,
location: loc,
yearsOfExperience: yearsOfExperience || "not specified",
currency: curr || "inferred from location",
year: yr,
market_data: results,
instructions: `From the market_data search results above, provide a salary benchmark for ${role} in ${loc}${expTag ? ` with ${expTag}` : ""}. ` +
"Present what the data shows — do not fill gaps with assumptions. " +
"Include: (1) Low / median / high range — cite each figure's specific source, " +
"(2) Total comp range if equity/bonus data is present in the results, " +
"(3) What the data shows about market direction — quote sources, do not editorialize, " +
"(4) Any meaningful disagreement between sources (e.g. Glassdoor vs Levels.fyi vs Reddit survey). " +
"Flag explicitly when data is sparse, old, or from potentially biased sources. " +
"Do not present a single number as 'the' salary — always show the range and which sources disagree.",
});
}),
}),
// =========================================================================
// HIDDEN JOB MARKET
// =========================================================================
(0, sdk_1.tool)({
name: "find_hidden_jobs",
description: (0, sdk_1.text) `
Search for unadvertised / hidden job market opportunities:
- "We're hiring" posts on LinkedIn, Twitter/X, and Hacker News
- Direct outreach targets (people who recently changed roles)
- Company career pages not indexed by job boards
- Warm referral targets
The hidden job market accounts for ~70-80% of all filled positions.
Returns search results and an outreach strategy.
`,
parameters: {
role: zod_1.z.string().describe("Target role (e.g. 'Senior Backend Engineer', 'Product Manager')"),
industry: zod_1.z.string().default("").describe("Target industry (e.g. 'fintech', 'healthcare AI')"),
location: zod_1.z.string().default("").describe("Location or 'remote'"),
strategy: zod_1.z.enum(["all", "hiring_posts", "career_pages", "referral_network", "hn_whoishiring"])
.default("all").describe("Which hidden market channel to search"),
},
implementation: safe_impl("find_hidden_jobs", async ({ role, industry, location, strategy }) => {
const loc = location ? ` ${location}` : "";
const ind = industry ? ` ${industry}` : "";
const results = {};
const searches = [
{
key: "hiring_posts",
query: `"we're hiring" OR "we are hiring" OR "join our team" ${role}${ind}${loc} site:linkedin.com OR site:twitter.com`,
active: strategy === "all" || strategy === "hiring_posts",
},
{
key: "career_pages",
query: `${role}${ind}${loc} "careers" OR "jobs" -site:linkedin.com -site:indeed.com -site:glassdoor.com`,
active: strategy === "all" || strategy === "career_pages",
},
{
key: "hn_whoishiring",
query: `site:news.ycombinator.com "Who is Hiring" ${role}${ind}`,
active: strategy === "all" || strategy === "hn_whoishiring",
},
{
key: "referral_network",
query: `${role}${ind}${loc} "looking for" OR "open to referrals" OR "DM me" hiring`,
active: strategy === "all" || strategy === "referral_network",
},
];
for (const s of searches) {
if (!s.active)
continue;
try {
results[s.key] = await webSearch(s.query, maxResults(), searchWindow());
}
catch {
results[s.key] = [];
}
}
return json({
role,
industry: industry || "any",
location: location || "any",
results,
hiddenMarketStrategies: {
"1_direct_outreach": "Find hiring managers on LinkedIn → connect with a personalized note referencing their work → ask for a 15-min informational call. Don't ask for a job directly.",
"2_referrals": "Ask your network: 'I'm exploring X roles at Y-type companies. Do you know anyone there?' Referred candidates are hired at 4Ă— the rate of cold applicants.",
"3_company_career_pages": "Bookmark 20–30 target company career pages and check weekly. Many companies post internally first.",
"4_hn_who_is_hiring": "news.ycombinator.com — search the monthly 'Who is Hiring' thread. Tech companies post directly, often with direct contact info.",
"5_alumni_network": "LinkedIn: filter your alumni for people at target companies → reach out for intro calls.",
"6_newsletters": "Substack and niche industry newsletters often have job sections before roles are formally posted.",
"7_slack_communities": "Join 2–3 industry Slack communities (e.g., Tech Ladies, DataTalks, SWE Slack) — job channels are often unlisted roles.",
},
});
}),
}),
(0, sdk_1.tool)({
name: "find_recently_funded_companies",
description: (0, sdk_1.text) `
Search for recently funded startups in a specific sector that are likely hiring.
Funded companies typically hire aggressively in the 3–12 months post-funding.
Returns company names, funding details, and links to explore further.
`,
parameters: {
sector: zod_1.z.string().describe("Industry/sector (e.g. 'AI', 'climate tech', 'fintech', 'healthcare')"),
stage: zod_1.z.enum(["seed", "series_a", "series_b", "series_c", "any"]).default("any")
.describe("Funding stage to look for"),
location: zod_1.z.string().default("").describe("Geographic focus (optional)"),
},
implementation: safe_impl("find_recently_funded_companies", async ({ sector, stage, location }) => {
const stageLabel = stage === "any" ? "" : ` ${stage.replace("_", " ")}`;
const loc = location ? ` ${location}` : "";
const year = new Date().getFullYear();
const q = `${sector}${stageLabel} startup funded ${year - 1} OR ${year}${loc} "million" hiring`;
const results = await webSearch(q, maxResults(), "year");
// Also search Crunchbase/TechCrunch specifically
const tcQuery = `site:techcrunch.com ${sector}${stageLabel} raised funding ${year - 1} OR ${year}`;
const techCrunchResults = await webSearch(tcQuery, 5, searchWindow());
return json({
sector,
stage,
location: location || "global",
generalResults: results,
techCrunchResults,
why: "Companies typically hire 30–60% more in the 6 months after a funding round.",
nextSteps: [
"Check each company's LinkedIn and careers page",
"Search the company on Crunchbase for team size and investor info",
"Find the hiring manager on LinkedIn and reach out directly",
"Reference the funding in your outreach: 'Congrats on the Series A — excited by what you're building'",
],
});
}),
}),
// =========================================================================
// COMPANY LEGITIMACY VERIFICATION
// =========================================================================
(0, sdk_1.tool)({
name: "verify_company_legitimacy",
description: (0, sdk_1.text) `
Verify whether a company and job posting are legitimate by checking multiple signals.
Detects common job scam patterns, ghost jobs, and red flags.
Returns a legitimacy score (0–100), red flags found, and green flags.
Common scams: fake companies, too-good-to-pay roles, asking for money/equipment upfront,
vague job descriptions, no verifiable web presence, mismatched domains.
`,
parameters: {
company: zod_1.z.string().describe("Company name to verify"),
jobTitle: zod_1.z.string().default("").describe("Job title from the posting"),
jobDescription: zod_1.z.string().default("").describe("Paste job description for red flag analysis"),
recruitingEmail: zod_1.z.string().default("").describe("Email or domain of the recruiter contact"),
jobUrl: zod_1.z.string().default("").describe("URL of the job posting"),
salary: zod_1.z.string().default("").describe("Stated salary (for outlier detection)"),
},
implementation: safe_impl("verify_company_legitimacy", async (params) => {
const redFlags = [];
const greenFlags = [];
let score = 50; // Neutral baseline — evidence moves the score in either direction
// --- JD analysis ---
if (params.jobDescription) {
const jd = params.jobDescription.toLowerCase();
// Scam red flags in JD
if (/work from home|be your own boss|unlimited earning/i.test(jd)) {
redFlags.push("JD uses MLM/pyramid-scheme language ('be your own boss', 'unlimited earning')");
score -= 20;
}
if (/send.*equipment|purchase.*equipment|reimburse.*gift card|wire transfer/i.test(jd)) {
redFlags.push("JD mentions buying equipment or gift cards — classic advance-fee scam");
score -= 30;
}
if (/no experience (required|needed)|anyone can do/i.test(jd)) {
redFlags.push("JD claims no experience required for what sounds like a skilled role");
score -= 10;
}
if (params.jobDescription.length < 150) {
redFlags.push("Job description is suspiciously short (< 150 chars) — ghost job or scam");
score -= 15;
}
if (/immediately|urgent|asap|start today/i.test(jd)) {
redFlags.push("Unusual urgency in the posting — pressure tactic common in scams");
score -= 5;
}
if (/gmail\.com|yahoo\.com|hotmail\.com/.test(jd)) {
redFlags.push("JD contains a free email domain — legitimate companies use corporate email");
score -= 20;
}
// Green flags in JD
if (/interview process|interview stages|hiring manager/i.test(jd)) {
greenFlags.push("JD mentions a structured interview process");
score += 5;
}
if (/team of \d+|company of \d+|\d+ employees/i.test(jd)) {
greenFlags.push("JD mentions specific team/company size");
score += 5;
}
}
// --- Email domain check ---
if (params.recruitingEmail) {
const emailLower = params.recruitingEmail.toLowerCase();
if (/gmail\.com|yahoo\.com|hotmail\.com|outlook\.com/.test(emailLower)) {
redFlags.push(`Recruiter using free email (${emailLower}) — legitimate companies use corporate email`);
score -= 25;
}
else {
const emailDomain = emailLower.split("@")[1] || "";
const companyDomain = params.company.toLowerCase().replace(/[^a-z0-9]/g, "");
// Only match when we have enough characters to avoid false positives on short names
const matchPrefix = companyDomain.slice(0, Math.min(companyDomain.length, 8));
if (emailDomain && matchPrefix.length >= 4 && !emailDomain.includes(matchPrefix)) {
redFlags.push(`Email domain (${emailDomain}) doesn't match company name — could be impersonation`);
score -= 10;
}
else if (emailDomain) {
greenFlags.push(`Recruiter email domain (${emailDomain}) matches company`);
score += 10;
}
}
}
// --- Salary outlier check ---
if (params.salary) {
// Extract the first number from ranges like "$120,000 - $150,000"
const salaryMatch = params.salary.match(/[\d,]+/);
const salaryNum = salaryMatch ? parseFloat(salaryMatch[0].replace(/,/g, "")) : 0;
if (salaryNum > 0) {
if (salaryNum > 500000) {
redFlags.push(`Salary of ${params.salary} is unusually high — verify this is not bait`);
score -= 10;
}
else if (salaryNum > 10000 && salaryNum < 200000) {
greenFlags.push(`Salary range (${params.salary}) is within normal market bounds`);
score += 5;
}
}
}
// --- Web search verification ---
const searchResults = [];
try {
const verifyQuery = `"${params.company}" company legitimacy reviews OR glassdoor OR linkedin`;
const hits = await webSearch(verifyQuery, 6, searchWindow());
let foundGlassdoor = false, foundLinkedin = false, foundCrunchbase = false;
for (const hit of hits) {
searchResults.push({ title: hit.title, url: hit.url, snippet: hit.snippet });
const url = (hit.url || "").toLowerCase();
if (!foundGlassdoor && url.includes("glassdoor")) {
greenFlags.push("Found Glassdoor listing — company has verifiable employee reviews");
score += 10;
foundGlassdoor = true;
}
if (!foundLinkedin && url.includes("linkedin.com/company")) {
greenFlags.push("Company LinkedIn page found — verifiable company profile");
score += 10;
foundLinkedin = true;
}
if (!foundCrunchbase && url.includes("crunchbase")) {
greenFlags.push("Company on Crunchbase — startup funding history verifiable");
score += 5;
foundCrunchbase = true;
}
if (/scam|fraud|fake|warning/i.test(hit.title + hit.snippet)) {
redFlags.push(`Web search found potential warning: "${hit.title}"`);
score -= 20;
}
}
if (hits.length === 0) {
redFlags.push("No web results found for company — no verifiable online presence");
score -= 20;
}
}
catch { /* web search failed — don't penalize */ }
// Clamp score
score = Math.max(0, Math.min(100, score));
let verdict;
if (score >= 75)
verdict = "LIKELY LEGITIMATE — proceed with normal caution";
else if (score >= 50)
verdict = "UNCERTAIN — verify before sharing personal info";
else if (score >= 25)
verdict = "SUSPICIOUS — multiple red flags, research thoroughly";
else
verdict = "HIGH RISK — likely a scam, do not proceed without verification";
// Persist flags to any matching application records
try {
const db = await loadDB(dataDir());
const companyLower = params.company.toLowerCase();
let saved = false;
for (const app of db.applications) {
if (app.company.toLowerCase() === companyLower) {
app.legitimacyScore = score;
app.legitimacyFlags = redFlags;
app.updatedAt = new Date().toISOString();
saved = true;
}
}
if (saved)
await saveDB(dataDir(), db);
}
catch { /* non-fatal */ }
return json({
company: params.company,
jobTitle: params.jobTitle || "Unknown",
legitimacyScore: score,
verdict,
redFlags,
greenFlags,
webSearchResults: searchResults,
verificationChecklist: [
"âś“ Search the company on LinkedIn and verify employee count matches claims",
"âś“ Check Glassdoor for employee reviews (scams rarely have real reviews)",
"âś“ Verify the company domain was registered > 1 year ago (use WHOIS)",
"âś“ Search '[company name] scam' or '[company name] fraud'",
"âś“ Never pay for training, equipment, or background checks upfront",
"âś“ Video interview with real people (not just text chat) is a good sign",
"âś“ Verify the office address on Google Maps Street View",
],
});
}),
}),
// =========================================================================
// LOCATION
// =========================================================================
(0, sdk_1.tool)({
name: "get_current_location",
description: (0, sdk_1.text) `
Detect the user's current location using IP-based geolocation.
Returns city, region, country, and coordinates.
Note: LM Studio plugins run server-side with no GPS access — this uses the
public IP address, so VPNs and proxies will affect the result.
Use this to auto-fill your location in searches or update plugin settings.
`,
parameters: {},
implementation: safe_impl("get_current_location", async () => {
// Use ip-api.com — free, no key, HTTPS on paid but HTTP works for non-commercial
const res = await fetch("http://ip-api.com/json/?fields=status,country,regionName,city,lat,lon,isp,query", {
signal: AbortSignal.timeout(8000),
});
if (!res.ok)
throw new Error(`IP geolocation service returned HTTP ${res.status}`);
const data = await res.json();
if (data.status !== "success")
throw new Error("IP geolocation failed — check network or try again");
const country = String(data.country ?? "");
const city = String(data.city ?? "");
const region = String(data.regionName ?? "");
const isHome = country.toLowerCase() === homeCountry().toLowerCase();
return json({
city,
region,
country,
coordinates: { lat: data.lat, lon: data.lon },
isp: data.isp,
ip: data.query,
isHomeCountry: isHome,
suggestedSearchLocation: city && region ? `${city}, ${region}` : country,
note: "Location is based on your public IP address. VPNs will affect accuracy.",
configHint: isHome
? `You appear to be in ${country} — matches your configured home country.`
: `You appear to be in ${country} which differs from your configured home country (${homeCountry()}). Consider updating plugin settings if you've relocated.`,
});
}),
}),
(0, sdk_1.tool)({
name: "toggle_international_search",
description: (0, sdk_1.text) `
Enable or disable international job search mode.
When international mode is OFF (default), searches stay within your configured location (India by default)
and international results are blocked to keep the search focused.
When ON, searches can include jobs anywhere in the world with work permit warnings.
This updates the plugin's setting for the current session.
To make it permanent, change "Open to International Roles" in plugin settings.
`,
parameters: {
enable: zod_1.z.coerce.boolean().describe("true to enable international search, false to disable"),
},
implementation: safe_impl("toggle_international_search", async ({ enable }) => {
// We can't write to plugin config at runtime — inform the user of the current state
// and guide them. The per-call override on search_jobs handles runtime toggling.
return json({
requested: enable ? "enable international search" : "disable international search",
currentSetting: openToInternational(),
message: enable
? "To enable international search: set 'Open to International Roles' to ON in plugin settings. " +
"Or pass includeInternational: true on any individual search_jobs call to override for that search."
: "To disable international search: set 'Open to International Roles' to OFF in plugin settings. " +
"Or pass includeInternational: false on any individual search_jobs call.",
perCallOverride: "You can always override per search: search_jobs({ query, location, includeInternational: true/false }). " +
"This overrides the plugin setting for that single call only.",
locationSettings: {
preferredLocation: preferredLocation(),
homeCountry: homeCountry(),
openToInternational: openToInternational(),
},
});
}),
}),
// =========================================================================
// WORK PERMIT & VISA
// =========================================================================
(0, sdk_1.tool)({
name: "check_work_permit",
description: (0, sdk_1.text) `
Check work permit and visa requirements for working in a destination country
as an Indian citizen (or the citizenship configured in plugin settings).
Covers the most common destination countries for Indian professionals:
USA, UK, Canada, Australia, Germany, Singapore, UAE, Netherlands, Japan, and more.
Returns: visa types available, whether employer sponsorship is needed,
typical processing time, eligibility conditions, salary thresholds,
restrictions (e.g. H-1B lottery, skill shortages), and a practical action plan.
Also does a live web search for any recent policy changes.
`,
parameters: {
destinationCountry: zod_1.z.string()
.describe("Country where the job is located (e.g. 'USA', 'UK', 'Germany', 'Singapore')"),
role: zod_1.z.string().default("").describe("Job title — some visas are role/skill specific"),
annualSalary: zod_1.z.string().default("").describe("Expected salary in destination currency — affects some visa thresholds"),
applicationId: zod_1.z.string().default("").describe("Optional: application ID to save the work permit result to"),
},
implementation: safe_impl("check_work_permit", async ({ destinationCountry, role, annualSalary, applicationId }) => {
const userCitizenship = citizenship();
const dest = destinationCountry.trim();
const yr = new Date().getFullYear();
const roleStr = role ? ` ${role}` : "";
const salaryStr = annualSalary ? ` salary ${annualSalary}` : "";
// Fully dynamic — no static database. Immigration rules change too often to hardcode.
const searches = [
{ angle: "visa_types", query: `work visa ${dest} for ${userCitizenship} citizens ${yr} types requirements` },
{ angle: "sponsorship", query: `${dest} work permit employer sponsorship requirements${roleStr} ${yr}` },
{ angle: "salary_threshold", query: `${dest} work visa minimum salary threshold${roleStr}${salaryStr} ${yr}` },
{ angle: "recent_changes", query: `${dest} immigration policy changes ${yr} tech workers ${userCitizenship}` },
{ angle: "official_source", query: `${dest} official immigration website work permit application process` },
];
const webResults = [];
for (const s of searches) {
try {
const hits = await webSearch(s.query, 6, searchWindow());
webResults.push({ angle: s.angle, query: s.query, results: hits });
}
catch {
webResults.push({ angle: s.angle, query: s.query, results: [] });
}
}
// Mark application record if ID provided (status stays unknown until LLM confirms from search)
if (applicationId) {
try {
const db = await loadDB(dataDir());
const idx = db.applications.findIndex((a) => a.id === applicationId);
if (idx !== -1) {
db.applications[idx].workPermitStatus = "unknown";
db.applications[idx].workPermitNotes = `Work permit research for ${dest} pending — see check_work_permit results. Use update_application to save confirmed status.`;
db.applications[idx].updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
}
}
catch { /* non-fatal */ }
}
return json({
destinationCountry: dest,
citizenship: userCitizenship,
role: role || "any",
salaryContext: annualSalary || null,
web_research: webResults,
instructions: `Based on the live web_research above, provide a complete work permit guide for a ${userCitizenship} citizen working in ${dest}${roleStr}. ` +
"Include: (1) available visa types with current requirements and processing times, " +
"(2) whether employer sponsorship is needed, " +
"(3) current salary thresholds (note: these change annually — cite the source), " +
"(4) key restrictions or quota systems, " +
"(5) a practical 3-step action plan. " +
"ALWAYS cite which search result each fact comes from. " +
"Flag any information that may be outdated and direct the user to the official government source found in official_source results. " +
"If after the user confirms the permit status, instruct them to call update_application to save workPermitStatus and workPermitNotes.",
disclaimer: "Immigration rules change frequently. Always verify with official government sources before applying.",
});
}),
}),
// =========================================================================
// RESUME READING
// =========================================================================
(0, sdk_1.tool)({
name: "read_resume",
description: (0, sdk_1.text) `
Read and parse a resume file. Accepts an absolute file path (PDF, TXT, or MD).
If no path is provided, reads from the configured Resume File Path in plugin settings.
Use this tool FIRST when the user provides a resume path or asks you to look at their resume
before searching for jobs or doing any analysis.
`,
parameters: {
filePath: zod_1.z.string().default("")
.describe("Absolute path to the resume file (e.g. /Users/john/resume.pdf). If blank, uses the configured Resume File Path."),
},
implementation: safe_impl("read_resume", async ({ filePath }) => {
let rp = filePath.trim();
if (!rp) {
rp = resumePath();
}
else {
rp = expandPath(rp);
}
if (!rp)
throw new Error("No file path provided and Resume File Path is not configured in plugin settings.");
const content = await readResumeFile(rp);
if (!content.trim())
throw new Error("Resume file is empty.");
return json({
filePath: rp,
charCount: content.length,
resumeText: content.slice(0, 10000),
instructions: "You have now read the user's resume. Summarize the key details: " +
"name, current role, years of experience, core skills, industries, and notable achievements. " +
"Then ask the user what they'd like to do — search for matching jobs, analyze fit against a JD, or generate cover letters.",
});
}),
}),
// =========================================================================
// PIPELINE ANALYTICS
// =========================================================================
(0, sdk_1.tool)({
name: "application_stats",
description: (0, sdk_1.text) `
Get a full pipeline dashboard: counts per status, conversion rates
(applied→interview, interview→offer), average days in each stage,
and overall pipeline health score.
`,
parameters: {},
implementation: safe_impl("application_stats", async () => {
const db = await loadDB(dataDir());
const apps = db.applications;
if (apps.length === 0)
return json({ message: "No applications tracked yet." });
const now = new Date();
const byStatus = {};
for (const a of apps)
byStatus[a.status] = (byStatus[a.status] ?? 0) + 1;
// Conversion rates
const applied = apps.filter((a) => ["applied", "interview", "offer", "rejected", "withdrawn"].includes(a.status)).length;
const interviewed = apps.filter((a) => ["interview", "offer"].includes(a.status) || (a.status === "rejected" && a.interviewNotes.length > 0)).length;
const offers = apps.filter((a) => a.status === "offer").length;
const rejected = apps.filter((a) => a.status === "rejected").length;
const applyToInterview = applied > 0 ? Math.round((interviewed / applied) * 100) : 0;
const interviewToOffer = interviewed > 0 ? Math.round((offers / interviewed) * 100) : 0;
const overallConversion = applied > 0 ? Math.round((offers / applied) * 100) : 0;
// Average days in each stage
// Use statusHistory for accurate stage duration when available
function avgDaysInStatus(status) {
const durations = [];
for (const a of apps) {
const history = a.statusHistory ?? [];
if (history.length >= 2) {
// Find when this status started and ended in the history
for (let i = 0; i < history.length; i++) {
if (history[i].status === status) {
const start = new Date(history[i].date);
const end = i + 1 < history.length ? new Date(history[i + 1].date) : now;
durations.push(Math.max(0, Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))));
}
}
}
else if (a.status === status && a.appliedDate) {
// Fallback for old records without history
const start = new Date(a.appliedDate);
const end = new Date(a.updatedAt);
durations.push(Math.max(0, Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))));
}
}
if (durations.length === 0)
return null;
return Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
}
// Active applications needing attention
const staleCount = apps.filter((a) => {
if (!["applied", "interview"].includes(a.status))
return false;
const daysSince = Math.floor((now.getTime() - new Date(a.updatedAt).getTime()) / (1000 * 60 * 60 * 24));
return daysSince > 14;
}).length;
const overdueFollowUps = apps.filter((a) => {
if (["rejected", "withdrawn", "offer"].includes(a.status))
return false;
return a.followUpDate && a.followUpDate < now.toISOString().slice(0, 10);
}).length;
return json({
total: apps.length,
byStatus,
conversionRates: {
appliedToInterview: `${applyToInterview}%`,
interviewToOffer: `${interviewToOffer}%`,
overallAppliedToOffer: `${overallConversion}%`,
},
avgDaysInStage: {
applied: avgDaysInStatus("applied"),
interview: avgDaysInStatus("interview"),
offer: avgDaysInStatus("offer"),
},
health: {
staleApplications: staleCount,
overdueFollowUps,
totalRejections: rejected,
activeApplications: (byStatus["applied"] ?? 0) + (byStatus["interview"] ?? 0),
},
instructions: "Present this as a visual dashboard. Highlight conversion rates and flag any stale/overdue items. " +
"If conversion rates are low, suggest concrete improvements (resume tweaks, targeting different roles, etc.).",
});
}),
}),
// =========================================================================
// SAVED SEARCHES
// =========================================================================
(0, sdk_1.tool)({
name: "save_search",
description: (0, sdk_1.text) `
Save a job search query so it can be re-run later to find new postings.
Useful for recurring searches — save once, run periodically.
`,
parameters: {
name: zod_1.z.string().describe("A label for this saved search (e.g. 'Backend Engineer Bangalore')"),
query: zod_1.z.string().describe("Search query (role, skills, keywords)"),
location: zod_1.z.string().default("").describe("Location filter"),
jobType: zod_1.z.enum(["any", "full_time", "part_time", "contract", "internship", "remote"]).default("any"),
includeInternational: zod_1.z.coerce.boolean().default(false),
},
implementation: safe_impl("save_search", async (params) => {
const db = await loadDB(dataDir());
const search = {
id: makeId(),
name: params.name,
query: params.query,
location: params.location,
jobType: params.jobType,
includeInternational: params.includeInternational,
createdAt: new Date().toISOString(),
lastRunAt: "",
lastResultCount: 0,
};
db.savedSearches.push(search);
await saveDB(dataDir(), db);
return json({ success: true, savedSearch: search });
}),
}),
(0, sdk_1.tool)({
name: "run_saved_searches",
description: (0, sdk_1.text) `
Re-run one or all saved searches and return new results.
Filters out already-tracked jobs automatically.
Pass a search ID to run one, or leave blank to run all.
`,
parameters: {
searchId: zod_1.z.string().default("").describe("ID of a specific saved search to run. Leave blank to run all."),
},
implementation: safe_impl("run_saved_searches", async ({ searchId }) => {
const db = await loadDB(dataDir());
if (db.savedSearches.length === 0)
throw new Error("No saved searches. Use save_search to create one.");
const searches = searchId
? db.savedSearches.filter((s) => s.id === searchId)
: db.savedSearches;
if (searches.length === 0)
throw new Error(`Saved search ID '${searchId}' not found.`);
const tracked = buildTrackedSets(db.applications);
const allResults = [];
for (const s of searches) {
const loc = s.location || preferredLocation();
const isHome = extractCountry(loc).toLowerCase() === homeCountry().toLowerCase();
const sites = isHome ? homeBoards() : globalBoards();
const typeTag = s.jobType !== "any" ? ` ${s.jobType.replace("_", " ")}` : "";
const baseQuery = `${s.query}${typeTag} ${loc} job`;
let hits = await webSearch(`${baseQuery} ${sites}`, maxResults() + 10, searchWindow());
if (hits.length === 0)
hits = await webSearch(baseQuery, maxResults() + 10, searchWindow());
const { results: filtered } = filterTracked(hits, tracked, maxResults());
// Update last run info
const idx = db.savedSearches.findIndex((ss) => ss.id === s.id);
if (idx !== -1) {
db.savedSearches[idx].lastRunAt = new Date().toISOString();
db.savedSearches[idx].lastResultCount = filtered.length;
}
allResults.push({ searchName: s.name, searchId: s.id, results: filtered, newCount: filtered.length });
}
await saveDB(dataDir(), db);
return json({ searchesRun: allResults.length, results: allResults });
}),
}),
(0, sdk_1.tool)({
name: "list_saved_searches",
description: (0, sdk_1.text) `List all saved job searches with their last run info.`,
parameters: {},
implementation: safe_impl("list_saved_searches", async () => {
const db = await loadDB(dataDir());
return json({
count: db.savedSearches.length,
searches: db.savedSearches.map((s) => ({
id: s.id,
name: s.name,
query: s.query,
location: s.location,
jobType: s.jobType,
lastRunAt: s.lastRunAt || "never",
lastResultCount: s.lastResultCount,
})),
});
}),
}),
(0, sdk_1.tool)({
name: "delete_saved_search",
description: (0, sdk_1.text) `Delete a saved search by ID.`,
parameters: {
searchId: zod_1.z.string().describe("ID of the saved search to delete"),
},
implementation: safe_impl("delete_saved_search", async ({ searchId }) => {
const db = await loadDB(dataDir());
const before = db.savedSearches.length;
db.savedSearches = db.savedSearches.filter((s) => s.id !== searchId);
if (db.savedSearches.length === before)
throw new Error(`Saved search ID '${searchId}' not found.`);
await saveDB(dataDir(), db);
return json({ success: true, deleted: searchId });
}),
}),
// =========================================================================
// INTERVIEW NOTES
// =========================================================================
(0, sdk_1.tool)({
name: "add_interview_note",
description: (0, sdk_1.text) `
Log an interview round for a tracked application.
Records the round type, date, interviewer, questions asked, feedback, and outcome.
Builds a per-application interview history for prep and pattern analysis.
`,
parameters: {
applicationId: zod_1.z.string().describe("Application ID"),
round: zod_1.z.string().describe("Interview round name (e.g. 'Phone Screen', 'Technical Round 1', 'System Design', 'Final / Bar Raiser')"),
date: zod_1.z.string().default("").describe("Date of interview (YYYY-MM-DD). Defaults to today."),
interviewerName: zod_1.z.string().default("").describe("Name of the interviewer"),
interviewerRole: zod_1.z.string().default("").describe("Role/title of the interviewer (e.g. 'Engineering Manager')"),
questions: zod_1.z.array(zod_1.z.string()).default([]).describe("Key questions asked during this round"),
feedback: zod_1.z.string().default("").describe("Your notes on how it went, what was discussed, vibes"),
outcome: zod_1.z.enum(["passed", "failed", "pending", "unknown"]).default("pending"),
},
implementation: safe_impl("add_interview_note", async (params) => {
const db = await loadDB(dataDir());
const idx = db.applications.findIndex((a) => a.id === params.applicationId);
if (idx === -1)
throw new Error(`Application ID '${params.applicationId}' not found.`);
const note = {
round: params.round,
date: params.date || new Date().toISOString().slice(0, 10),
interviewerName: params.interviewerName,
interviewerRole: params.interviewerRole,
questions: params.questions,
feedback: params.feedback,
outcome: params.outcome,
};
db.applications[idx].interviewNotes.push(note);
// Auto-update status to interview if still "applied"
if (db.applications[idx].status === "applied") {
db.applications[idx].status = "interview";
}
db.applications[idx].updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
return json({
success: true,
application: db.applications[idx].company + " — " + db.applications[idx].role,
totalRounds: db.applications[idx].interviewNotes.length,
latestNote: note,
tip: "Use prepare_interview_questions before the next round to prep based on your interview history.",
});
}),
}),
// =========================================================================
// CONTACTS MANAGEMENT
// =========================================================================
(0, sdk_1.tool)({
name: "manage_contacts",
description: (0, sdk_1.text) `
Add, remove, or list contacts for a tracked application.
Track recruiters, hiring managers, referrals, and networking connections.
`,
parameters: {
applicationId: zod_1.z.string().describe("Application ID"),
action: zod_1.z.enum(["add", "remove", "list"]).describe("Action to perform"),
name: zod_1.z.string().default("").describe("Contact name (required for add/remove)"),
role: zod_1.z.string().default("").describe("Contact's role/title (e.g. 'Recruiter', 'Hiring Manager')"),
email: zod_1.z.string().default("").describe("Contact email"),
linkedIn: zod_1.z.string().default("").describe("LinkedIn profile URL"),
notes: zod_1.z.string().default("").describe("Notes about this contact"),
},
implementation: safe_impl("manage_contacts", async (params) => {
const db = await loadDB(dataDir());
const idx = db.applications.findIndex((a) => a.id === params.applicationId);
if (idx === -1)
throw new Error(`Application ID '${params.applicationId}' not found.`);
const app = db.applications[idx];
if (params.action === "list") {
return json({ company: app.company, role: app.role, contacts: app.contacts });
}
if (params.action === "add") {
if (!params.name.trim())
throw new Error("Contact name is required.");
app.contacts.push({
name: params.name,
role: params.role,
email: params.email,
linkedIn: params.linkedIn,
notes: params.notes,
});
app.updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
return json({ success: true, action: "added", contact: params.name, totalContacts: app.contacts.length });
}
if (params.action === "remove") {
if (!params.name.trim())
throw new Error("Contact name is required to remove.");
const before = app.contacts.length;
app.contacts = app.contacts.filter((c) => c.name.toLowerCase() !== params.name.toLowerCase());
if (app.contacts.length === before)
throw new Error(`Contact '${params.name}' not found.`);
app.updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
return json({ success: true, action: "removed", contact: params.name, totalContacts: app.contacts.length });
}
throw new Error(`Unknown action: ${params.action}`);
}),
}),
// =========================================================================
// REJECTION PATTERN ANALYSIS
// =========================================================================
(0, sdk_1.tool)({
name: "analyze_rejection_patterns",
description: (0, sdk_1.text) `
Analyze patterns across rejected applications to identify trends and improvement areas.
Looks at: rejection stage, role types, company sizes, skill gaps, time-to-rejection.
Requires at least 3 rejected applications for meaningful analysis.
`,
parameters: {},
implementation: safe_impl("analyze_rejection_patterns", async () => {
const db = await loadDB(dataDir());
const rejected = db.applications.filter((a) => a.status === "rejected");
if (rejected.length < 3) {
return json({
message: `Only ${rejected.length} rejected application(s). Need at least 3 for pattern analysis.`,
tip: "Keep tracking — patterns emerge after enough data points.",
});
}
// Stage analysis: where in the pipeline do rejections happen?
const withInterviews = rejected.filter((a) => a.interviewNotes.length > 0);
const preInterview = rejected.filter((a) => a.interviewNotes.length === 0);
const byLastRound = {};
for (const a of withInterviews) {
const lastRound = a.interviewNotes[a.interviewNotes.length - 1]?.round || "unknown";
byLastRound[lastRound] = (byLastRound[lastRound] ?? 0) + 1;
}
// Role type analysis
const byRole = {};
for (const a of rejected) {
const roleNorm = a.role.toLowerCase()
.replace(/senior|junior|lead|staff|principal|sr\.?|jr\.?/gi, "")
.trim();
byRole[roleNorm] = (byRole[roleNorm] ?? 0) + 1;
}
// Time-to-rejection
const rejectionTimes = rejected
.filter((a) => a.appliedDate)
.map((a) => Math.floor((new Date(a.updatedAt).getTime() - new Date(a.appliedDate).getTime()) / (1000 * 60 * 60 * 24)));
const avgRejectionDays = rejectionTimes.length > 0
? Math.round(rejectionTimes.reduce((a, b) => a + b, 0) / rejectionTimes.length)
: null;
// Rejection reasons (user-entered)
const reasons = {};
for (const a of rejected) {
if (a.rejectionReason) {
const r = a.rejectionReason.toLowerCase();
reasons[r] = (reasons[r] ?? 0) + 1;
}
}
// Location analysis
const byCountry = {};
for (const a of rejected) {
const c = a.country || "unknown";
byCountry[c] = (byCountry[c] ?? 0) + 1;
}
return json({
totalRejected: rejected.length,
stageBreakdown: {
preInterview: preInterview.length,
postInterview: withInterviews.length,
byLastInterviewRound: byLastRound,
},
byRoleType: byRole,
byCountry,
timing: {
avgDaysToRejection: avgRejectionDays,
fastest: rejectionTimes.length > 0 ? Math.min(...rejectionTimes) : null,
slowest: rejectionTimes.length > 0 ? Math.max(...rejectionTimes) : null,
},
rejectionReasons: Object.keys(reasons).length > 0 ? reasons : "No rejection reasons recorded. Use update_application(id, rejectionReason='...') to track.",
instructions: "Analyze these rejection patterns and provide actionable insights: " +
"(1) Where in the pipeline are most rejections happening? If pre-interview → resume/targeting issue. If post-interview → interview prep issue. " +
"(2) Are certain role types getting rejected more? Should the user pivot? " +
"(3) Is timing a factor — are quick rejections (< 3 days) suggesting auto-filtering? " +
"(4) Give 3 specific, actionable recommendations to improve success rate.",
});
}),
}),
// =========================================================================
// CSV EXPORT
// =========================================================================
(0, sdk_1.tool)({
name: "export_applications_csv",
description: (0, sdk_1.text) `
Export all tracked applications to a CSV file for use in spreadsheets.
Returns the file path where the CSV was saved.
`,
parameters: {
outputPath: zod_1.z.string().default("")
.describe("File path to save the CSV. Defaults to <dataPath>/applications-<date>.csv"),
},
implementation: safe_impl("export_applications_csv", async ({ outputPath }) => {
const db = await loadDB(dataDir());
const date = new Date().toISOString().slice(0, 10);
const outPath = outputPath.trim() || (0, path_1.join)(dataDir(), `applications-${date}.csv`);
const headers = [
"ID", "Company", "Role", "Location", "Country", "Status",
"Applied Date", "Posted Date", "Follow-Up Date", "Salary", "Total Comp",
"URL", "Next Step", "Contacts", "Interview Rounds",
"Work Permit Status", "Legitimacy Score", "Rejection Reason", "Notes", "Updated At",
];
function csvEscape(s) {
if (s.includes(",") || s.includes('"') || s.includes("\n")) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
const rows = db.applications.map((a) => [
a.id,
a.company,
a.role,
a.location,
a.country,
a.status,
a.appliedDate,
a.postedDate,
a.followUpDate,
a.salary,
a.totalComp,
a.url,
a.nextStep,
a.contacts.map((c) => c.name).join("; "),
String(a.interviewNotes.length),
a.workPermitStatus,
a.legitimacyScore >= 0 ? String(a.legitimacyScore) : "",
a.rejectionReason,
a.notes,
a.updatedAt.slice(0, 10),
].map(csvEscape).join(","));
const csv = [headers.join(","), ...rows].join("\n");
await (0, promises_1.mkdir)(dataDir(), { recursive: true });
await (0, promises_1.writeFile)(outPath, csv, "utf8");
return json({ success: true, path: outPath, applicationCount: db.applications.length });
}),
}),
// =========================================================================
// DAILY BRIEFING
// =========================================================================
(0, sdk_1.tool)({
name: "daily_briefing",
description: (0, sdk_1.text) `
Morning check-in: runs follow-ups, stale detection, saved searches, and pipeline stats
in one call. Returns a combined briefing with action items.
Use when the user says "morning briefing", "what's new", "daily update", "check-in".
`,
parameters: {
staleDays: zod_1.z.coerce.number().int().min(1).max(90).default(14)
.describe("Days before considering an application stale"),
followUpDays: zod_1.z.coerce.number().int().min(0).max(30).default(7)
.describe("Show follow-ups due within this many days"),
},
implementation: safe_impl("daily_briefing", async ({ staleDays, followUpDays }) => {
const db = await loadDB(dataDir());
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const apps = db.applications;
// --- Pipeline stats ---
const byStatus = {};
for (const a of apps)
byStatus[a.status] = (byStatus[a.status] ?? 0) + 1;
const applied = apps.filter((a) => ["applied", "interview", "offer", "rejected", "withdrawn"].includes(a.status)).length;
const interviewed = apps.filter((a) => ["interview", "offer"].includes(a.status) || (a.status === "rejected" && a.interviewNotes.length > 0)).length;
const offers = apps.filter((a) => a.status === "offer").length;
// --- Follow-ups ---
const cutoffDate = new Date(now);
cutoffDate.setDate(cutoffDate.getDate() + followUpDays);
const cutoffStr = cutoffDate.toISOString().slice(0, 10);
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
const followUps = apps
.filter((a) => !["rejected", "withdrawn"].includes(a.status) && dateRe.test(a.followUpDate ?? "") && a.followUpDate <= cutoffStr)
.map((a) => ({
id: a.id, company: a.company, role: a.role, status: a.status,
followUpDate: a.followUpDate,
overdue: a.followUpDate < todayStr,
nextStep: a.nextStep,
}))
.sort((a, b) => a.followUpDate.localeCompare(b.followUpDate));
// --- Stale applications ---
const staleCutoff = new Date(now.getTime() - staleDays * 24 * 60 * 60 * 1000);
const stale = apps
.filter((a) => ["applied", "interview"].includes(a.status) && new Date(a.updatedAt) < staleCutoff)
.map((a) => ({
id: a.id, company: a.company, role: a.role, status: a.status,
daysSinceUpdate: Math.floor((now.getTime() - new Date(a.updatedAt).getTime()) / (1000 * 60 * 60 * 24)),
}));
// --- Saved searches ---
const searchResults = [];
const tracked = buildTrackedSets(apps);
for (const s of db.savedSearches) {
const loc = s.location || preferredLocation();
const isHome = extractCountry(loc).toLowerCase() === homeCountry().toLowerCase();
const sites = isHome ? homeBoards() : globalBoards();
const typeTag = s.jobType !== "any" ? ` ${s.jobType.replace("_", " ")}` : "";
const baseQuery = `${s.query}${typeTag} ${loc} job`;
try {
let hits = await webSearch(`${baseQuery} ${sites}`, maxResults() + 5, searchWindow());
if (hits.length === 0)
hits = await webSearch(baseQuery, maxResults() + 5, searchWindow());
const { results: filtered } = filterTracked(hits, tracked, maxResults());
const idx = db.savedSearches.findIndex((ss) => ss.id === s.id);
if (idx !== -1) {
db.savedSearches[idx].lastRunAt = now.toISOString();
db.savedSearches[idx].lastResultCount = filtered.length;
}
searchResults.push({ name: s.name, newCount: filtered.length, results: filtered });
}
catch {
searchResults.push({ name: s.name, newCount: 0, results: [] });
}
}
if (db.savedSearches.length > 0)
await saveDB(dataDir(), db);
// --- Network follow-ups ---
const networkOverdue = db.network
.filter((c) => c.followUpDate && c.followUpDate <= todayStr)
.map((c) => ({
id: c.id, name: c.name, company: c.company, relationship: c.relationship,
followUpDate: c.followUpDate, lastContactDate: c.lastContactDate,
}));
return json({
date: todayStr,
pipeline: {
total: apps.length,
byStatus,
conversionRate: applied > 0 ? `${Math.round((offers / applied) * 100)}% applied→offer` : "no data",
},
followUps: {
count: followUps.length,
overdue: followUps.filter((f) => f.overdue).length,
items: followUps,
},
staleApplications: {
count: stale.length,
items: stale,
},
networkFollowUps: {
overdueCount: networkOverdue.length,
contacts: networkOverdue,
},
newJobResults: {
searchesRun: searchResults.length,
totalNewJobs: searchResults.reduce((s, r) => s + r.newCount, 0),
bySearch: searchResults,
},
instructions: "Present this as a morning briefing. Lead with urgent items (overdue follow-ups, stale apps, network follow-ups). " +
"Then show new job results from saved searches. End with pipeline health. " +
"Give concrete action items: 'Send follow-up to X', 'Withdraw stale app at Y', 'Reach out to Z', 'Check out 3 new postings'.",
});
}),
}),
// =========================================================================
// JOB COMPARISON
// =========================================================================
(0, sdk_1.tool)({
name: "compare_jobs",
description: (0, sdk_1.text) `
Side-by-side comparison of 2–5 tracked applications.
Compares salary, location, status, fit indicators, legitimacy, contacts,
interview progress, and any other tracked data.
Pass application IDs to compare.
`,
parameters: {
applicationIds: zod_1.z.array(zod_1.z.string()).min(2).max(5)
.describe("Array of 2–5 application IDs to compare"),
},
implementation: safe_impl("compare_jobs", async ({ applicationIds }) => {
const db = await loadDB(dataDir());
const apps = applicationIds.map((id) => {
const app = db.applications.find((a) => a.id === id);
if (!app)
throw new Error(`Application ID '${id}' not found.`);
return app;
});
const comparison = apps.map((a) => ({
id: a.id,
company: a.company,
role: a.role,
location: a.location,
country: a.country,
isInternational: a.isInternational,
status: a.status,
salary: a.salary || "not specified",
totalComp: a.totalComp || "not calculated",
appliedDate: a.appliedDate || "not applied",
interviewRounds: a.interviewNotes.length,
lastInterviewOutcome: a.interviewNotes.length > 0
? a.interviewNotes[a.interviewNotes.length - 1].outcome
: "n/a",
contactCount: a.contacts.length,
legitimacyScore: a.legitimacyScore >= 0 ? a.legitimacyScore : "not checked",
workPermitStatus: a.workPermitStatus,
nextStep: a.nextStep || "none",
followUpDate: a.followUpDate || "not set",
}));
return json({
count: comparison.length,
jobs: comparison,
instructions: "Present this as a side-by-side comparison table. Highlight: " +
"(1) Which has the best compensation. " +
"(2) Which is furthest along in the pipeline. " +
"(3) Any red flags (low legitimacy, missing salary, no contacts). " +
"(4) A recommendation on which to prioritize and why.",
});
}),
}),
// =========================================================================
// BATCH OPERATIONS
// =========================================================================
(0, sdk_1.tool)({
name: "batch_update_applications",
description: (0, sdk_1.text) `
Bulk-update multiple applications at once. Useful for:
- Withdrawing all stale applications
- Marking multiple as rejected
- Cleaning up the pipeline
Pass an array of application IDs and the fields to update on all of them.
`,
parameters: {
applicationIds: zod_1.z.array(zod_1.z.string()).min(1)
.describe("Array of application IDs to update"),
status: zod_1.z.enum(["saved", "applied", "interview", "offer", "rejected", "withdrawn"]).optional()
.describe("New status for all selected applications"),
rejectionReason: zod_1.z.string().optional()
.describe("Rejection reason (when bulk-marking as rejected)"),
notes: zod_1.z.string().optional()
.describe("Notes to append (not replace) on all selected applications"),
},
implementation: safe_impl("batch_update_applications", async ({ applicationIds, status, rejectionReason, notes }) => {
if (!status && !rejectionReason && !notes)
throw new Error("Nothing to update. Provide at least one of: status, rejectionReason, notes.");
const db = await loadDB(dataDir());
const updated = [];
const notFound = [];
for (const id of applicationIds) {
const idx = db.applications.findIndex((a) => a.id === id);
if (idx === -1) {
notFound.push(id);
continue;
}
const app = db.applications[idx];
if (status && status !== app.status) {
app.statusHistory.push({ status, date: todayStr() });
app.status = status;
if (status === "applied")
stampAppliedDate(app);
}
if (rejectionReason)
app.rejectionReason = rejectionReason;
if (notes)
app.notes = app.notes ? `${app.notes}\n${notes}` : notes;
app.updatedAt = new Date().toISOString();
updated.push(`${app.company} — ${app.role}`);
}
await saveDB(dataDir(), db);
return json({
success: true,
updatedCount: updated.length,
updated,
notFound: notFound.length > 0 ? notFound : null,
changes: { status: status ?? "unchanged", rejectionReason: rejectionReason ?? "unchanged", notesAppended: !!notes },
});
}),
}),
// =========================================================================
// NETWORKING
// =========================================================================
(0, sdk_1.tool)({
name: "add_network_contact",
description: (0, sdk_1.text) `
Add someone to your professional network tracker.
Use this for people who are NOT tied to a specific application yet —
potential referrers, hiring managers you're warming up to, alumni, ex-colleagues.
For contacts tied to a specific application, use manage_contacts instead.
`,
parameters: {
name: zod_1.z.string().describe("Contact's full name"),
company: zod_1.z.string().default("").describe("Where they work"),
role: zod_1.z.string().default("").describe("Their job title"),
email: zod_1.z.string().default(""),
linkedIn: zod_1.z.string().default("").describe("LinkedIn profile URL"),
relationship: zod_1.z.enum(["cold", "warm", "strong", "referrer"]).default("cold")
.describe("How well you know them. cold=never spoken, warm=had a conversation, strong=regular contact, referrer=actively referring you"),
source: zod_1.z.string().default("").describe("How you met or found them (e.g. 'LinkedIn', 'conference', 'alumni', 'ex-colleague')"),
targetCompanies: zod_1.z.array(zod_1.z.string()).default([])
.describe("Companies they could refer you to"),
notes: zod_1.z.string().default(""),
tags: zod_1.z.array(zod_1.z.string()).default([])
.describe("Tags for filtering (e.g. 'engineering', 'hiring-manager', 'ex-coworker')"),
},
implementation: safe_impl("add_network_contact", async (params) => {
const db = await loadDB(dataDir());
const contact = {
id: makeId(),
name: params.name,
company: params.company,
role: params.role,
email: params.email,
linkedIn: params.linkedIn,
relationship: params.relationship,
source: params.source,
targetCompanies: params.targetCompanies,
lastContactDate: todayStr(),
followUpDate: "",
notes: params.notes,
tags: params.tags,
linkedApplicationIds: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
db.network.push(contact);
await saveDB(dataDir(), db);
return json({ success: true, contact });
}),
}),
(0, sdk_1.tool)({
name: "update_network_contact",
description: (0, sdk_1.text) `
Update an existing network contact by ID.
Only supply the fields you want to change.
Use log_network_interaction to record a conversation (updates lastContactDate automatically).
`,
parameters: {
id: zod_1.z.string().describe("Network contact ID"),
name: zod_1.z.string().optional(),
company: zod_1.z.string().optional(),
role: zod_1.z.string().optional(),
email: zod_1.z.string().optional(),
linkedIn: zod_1.z.string().optional(),
relationship: zod_1.z.enum(["cold", "warm", "strong", "referrer"]).optional(),
source: zod_1.z.string().optional(),
targetCompanies: zod_1.z.array(zod_1.z.string()).optional(),
followUpDate: zod_1.z.string().optional().describe("When to follow up next (YYYY-MM-DD)"),
notes: zod_1.z.string().optional().describe("Notes to APPEND"),
tags: zod_1.z.array(zod_1.z.string()).optional(),
},
implementation: safe_impl("update_network_contact", async ({ id, ...updates }) => {
const db = await loadDB(dataDir());
const idx = db.network.findIndex((c) => c.id === id);
if (idx === -1)
throw new Error(`Network contact ID '${id}' not found.`);
const c = db.network[idx];
if (updates.name !== undefined)
c.name = updates.name;
if (updates.company !== undefined)
c.company = updates.company;
if (updates.role !== undefined)
c.role = updates.role;
if (updates.email !== undefined)
c.email = updates.email;
if (updates.linkedIn !== undefined)
c.linkedIn = updates.linkedIn;
if (updates.relationship !== undefined)
c.relationship = updates.relationship;
if (updates.source !== undefined)
c.source = updates.source;
if (updates.targetCompanies !== undefined)
c.targetCompanies = updates.targetCompanies;
if (updates.followUpDate !== undefined)
c.followUpDate = updates.followUpDate;
if (updates.notes !== undefined)
c.notes = c.notes ? `${c.notes}\n${updates.notes}` : updates.notes;
if (updates.tags !== undefined)
c.tags = updates.tags;
c.updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
return json({ success: true, contact: c });
}),
}),
(0, sdk_1.tool)({
name: "list_network",
description: (0, sdk_1.text) `
List your professional network, optionally filtered by relationship strength,
company, tag, or overdue follow-ups. Sorted by follow-up urgency.
`,
parameters: {
relationship: zod_1.z.enum(["cold", "warm", "strong", "referrer", "all"]).default("all"),
company: zod_1.z.string().default("").describe("Filter by company name"),
tag: zod_1.z.string().default("").describe("Filter by tag"),
overdueOnly: zod_1.z.coerce.boolean().default(false)
.describe("Show only contacts with overdue follow-ups"),
search: zod_1.z.string().default("").describe("Search keyword in name, company, or notes"),
},
implementation: safe_impl("list_network", async ({ relationship, company, tag, overdueOnly, search }) => {
const db = await loadDB(dataDir());
let contacts = db.network;
if (relationship !== "all")
contacts = contacts.filter((c) => c.relationship === relationship);
if (company) {
const kw = company.toLowerCase();
contacts = contacts.filter((c) => c.company.toLowerCase().includes(kw));
}
if (tag) {
const kw = tag.toLowerCase();
contacts = contacts.filter((c) => c.tags.some((t) => t.toLowerCase().includes(kw)));
}
if (search) {
const kw = search.toLowerCase();
contacts = contacts.filter((c) => c.name.toLowerCase().includes(kw) ||
c.company.toLowerCase().includes(kw) ||
c.notes.toLowerCase().includes(kw));
}
const today = todayStr();
if (overdueOnly) {
contacts = contacts.filter((c) => c.followUpDate && c.followUpDate <= today);
}
// Sort: overdue first, then by followUpDate, then by lastContactDate (oldest first = needs attention)
contacts = contacts.slice().sort((a, b) => {
const aOverdue = a.followUpDate && a.followUpDate <= today ? 0 : 1;
const bOverdue = b.followUpDate && b.followUpDate <= today ? 0 : 1;
if (aOverdue !== bOverdue)
return aOverdue - bOverdue;
if (a.followUpDate && b.followUpDate)
return a.followUpDate.localeCompare(b.followUpDate);
return a.lastContactDate.localeCompare(b.lastContactDate);
});
const summary = contacts.map((c) => ({
id: c.id,
name: c.name,
company: c.company,
role: c.role,
relationship: c.relationship,
lastContactDate: c.lastContactDate,
followUpDate: c.followUpDate || "not set",
overdue: c.followUpDate ? c.followUpDate <= today : false,
targetCompanies: c.targetCompanies,
tags: c.tags,
linkedApplications: c.linkedApplicationIds.length,
}));
const stats = {
total: db.network.length,
cold: db.network.filter((c) => c.relationship === "cold").length,
warm: db.network.filter((c) => c.relationship === "warm").length,
strong: db.network.filter((c) => c.relationship === "strong").length,
referrer: db.network.filter((c) => c.relationship === "referrer").length,
overdueFollowUps: db.network.filter((c) => c.followUpDate && c.followUpDate <= today).length,
};
return json({ stats, filter: { relationship, company, tag, overdueOnly, search }, contacts: summary });
}),
}),
(0, sdk_1.tool)({
name: "log_network_interaction",
description: (0, sdk_1.text) `
Log a conversation or interaction with a network contact.
Automatically updates lastContactDate and optionally sets next follow-up.
Use this after every call, coffee chat, email exchange, or LinkedIn message.
`,
parameters: {
id: zod_1.z.string().describe("Network contact ID"),
summary: zod_1.z.string().describe("What happened — what you discussed, any outcomes, next steps"),
upgadeRelationship: zod_1.z.coerce.boolean().default(false)
.describe("Upgrade relationship level (cold→warm→strong→referrer)"),
followUpInDays: zod_1.z.coerce.number().int().min(0).max(90).default(0)
.describe("Set follow-up reminder N days from now. 0 = no reminder."),
linkApplicationId: zod_1.z.string().default("")
.describe("Optional: link this contact to a specific application ID"),
},
implementation: safe_impl("log_network_interaction", async ({ id, summary, upgadeRelationship, followUpInDays, linkApplicationId }) => {
const db = await loadDB(dataDir());
const idx = db.network.findIndex((c) => c.id === id);
if (idx === -1)
throw new Error(`Network contact ID '${id}' not found.`);
const c = db.network[idx];
c.lastContactDate = todayStr();
c.notes = c.notes ? `${c.notes}\n[${todayStr()}] ${summary}` : `[${todayStr()}] ${summary}`;
if (upgadeRelationship) {
const levels = ["cold", "warm", "strong", "referrer"];
const current = levels.indexOf(c.relationship);
if (current < levels.length - 1)
c.relationship = levels[current + 1];
}
if (followUpInDays > 0) {
const fu = new Date();
fu.setDate(fu.getDate() + followUpInDays);
c.followUpDate = fu.toISOString().slice(0, 10);
}
if (linkApplicationId) {
if (!c.linkedApplicationIds.includes(linkApplicationId)) {
c.linkedApplicationIds.push(linkApplicationId);
}
}
c.updatedAt = new Date().toISOString();
await saveDB(dataDir(), db);
return json({
success: true,
contact: { name: c.name, company: c.company, relationship: c.relationship },
followUpDate: c.followUpDate || "none",
linkedApplications: c.linkedApplicationIds.length,
});
}),
}),
(0, sdk_1.tool)({
name: "delete_network_contact",
description: (0, sdk_1.text) `Delete a network contact by ID.`,
parameters: {
id: zod_1.z.string().describe("Network contact ID to delete"),
},
implementation: safe_impl("delete_network_contact", async ({ id }) => {
const db = await loadDB(dataDir());
const before = db.network.length;
db.network = db.network.filter((c) => c.id !== id);
if (db.network.length === before)
throw new Error(`Network contact ID '${id}' not found.`);
await saveDB(dataDir(), db);
return json({ success: true, deleted: id });
}),
}),
(0, sdk_1.tool)({
name: "find_referrers_for_company",
description: (0, sdk_1.text) `
Find people in your network who could refer you to a specific company.
Checks targetCompanies and current company fields.
Use this before applying to see if you have a warm intro.
`,
parameters: {
company: zod_1.z.string().describe("Company name to find referrers for"),
},
implementation: safe_impl("find_referrers_for_company", async ({ company }) => {
const db = await loadDB(dataDir());
const kw = company.toLowerCase();
const matches = db.network.filter((c) => c.company.toLowerCase().includes(kw) ||
c.targetCompanies.some((t) => t.toLowerCase().includes(kw)));
if (matches.length === 0) {
return json({
company,
found: 0,
message: `No network contacts at or connected to ${company}. Consider using find_hidden_jobs or searching LinkedIn for alumni/connections there.`,
suggestion: "Add contacts with add_network_contact and set targetCompanies to track referral potential.",
});
}
return json({
company,
found: matches.length,
contacts: matches.map((c) => ({
id: c.id,
name: c.name,
company: c.company,
role: c.role,
relationship: c.relationship,
lastContactDate: c.lastContactDate,
canRefer: c.company.toLowerCase().includes(kw),
connectedTo: c.targetCompanies.filter((t) => t.toLowerCase().includes(kw)),
})),
instructions: "Present referral options by relationship strength. " +
"For 'strong' or 'referrer' contacts: suggest asking for a direct referral. " +
"For 'warm' contacts: suggest a catch-up first, then ask. " +
"For 'cold' contacts: suggest warming up with a value-add message before asking.",
});
}),
}),
];
return tools;
};
exports.toolsProvider = toolsProvider;