careervivid 1.12.36 → 1.12.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/instructions.d.ts.map +1 -1
- package/dist/agent/instructions.js +10 -3
- package/dist/agent/tools/jobOpenings.d.ts +21 -0
- package/dist/agent/tools/jobOpenings.d.ts.map +1 -0
- package/dist/agent/tools/jobOpenings.js +795 -0
- package/dist/commands/agent/repl.d.ts.map +1 -1
- package/dist/commands/agent/repl.js +4 -1
- package/dist/commands/agent/toolRegistry.d.ts.map +1 -1
- package/dist/commands/agent/toolRegistry.js +5 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../../src/agent/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,aAAa,QAalB,CAAC;AAMT,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAgBnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,
|
|
1
|
+
{"version":3,"file":"instructions.d.ts","sourceRoot":"","sources":["../../src/agent/instructions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,eAAO,MAAM,aAAa,QAalB,CAAC;AAMT,eAAO,MAAM,cAAc,QAcnB,CAAC;AAMT,eAAO,MAAM,cAAc,QAgBnB,CAAC;AAMT,eAAO,MAAM,kBAAkB,QAwCvB,CAAC;AAMT,eAAO,MAAM,YAAY,QA2CjB,CAAC;AAMT,eAAO,MAAM,iBAAiB,QActB,CAAC;AAMT;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE;IACzC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,MAAM,CAyBT"}
|
|
@@ -75,7 +75,10 @@ export const JOBS_TOOLS_SECTION = `
|
|
|
75
75
|
- **delete_resume** — Permanently delete a resume (ask for confirmation first).
|
|
76
76
|
|
|
77
77
|
### Job Search
|
|
78
|
-
- **search_jobs** — Search for
|
|
78
|
+
- **search_jobs** — Search for NEW companies/roles NOT yet in the tracker. Returns results for review (dry_run by default — does NOT auto-save). Use for discovery only.
|
|
79
|
+
- **openings_scan** ⭐ — Drill into companies ALREADY in jobs.csv to find their SPECIFIC open roles with direct apply links (uses Greenhouse/Ashby/Lever APIs). Use this instead of search_jobs when the user wants roles at known tracked companies.
|
|
80
|
+
- **openings_list** — List saved job openings found by openings_scan.
|
|
81
|
+
- **openings_apply** — Mark a specific opening as Applied (requires explicit date).
|
|
79
82
|
|
|
80
83
|
### CSV Pipeline Tracker (tracker_*)
|
|
81
84
|
These tools read/write jobs.csv — the local career-ops pipeline spreadsheet.
|
|
@@ -110,9 +113,10 @@ export const JOBS_HARNESS = `
|
|
|
110
113
|
## Autonomy Rules (Non-Negotiable)
|
|
111
114
|
|
|
112
115
|
### What you may do freely (no confirmation needed)
|
|
113
|
-
- Call any read-only tool (tracker_list_jobs, tracker_dashboard, tracker_rank_priority, tracker_find_stale, tracker_inspect_quality, tracker_recheck_urls, search_jobs, get_resume, etc.)
|
|
116
|
+
- Call any read-only tool (tracker_list_jobs, tracker_dashboard, tracker_rank_priority, tracker_find_stale, tracker_inspect_quality, tracker_recheck_urls, openings_scan, openings_list, search_jobs, get_resume, etc.)
|
|
114
117
|
- Add a new company/job entry via tracker_add_job (when the user explicitly names a company or approves a search result)
|
|
115
118
|
- Update non-status fields (attention_score, excitement, notes, follow_up_date, etc.) via tracker_update_job
|
|
119
|
+
- Run openings_scan to fetch real job postings from tracked companies
|
|
116
120
|
|
|
117
121
|
### What requires explicit user confirmation
|
|
118
122
|
- Changing status to "Applied", "Rejected", or "Ghosted" — present the proposed change and wait for "yes"
|
|
@@ -144,7 +148,10 @@ If it does, call tracker_update_job instead — never create a duplicate row.
|
|
|
144
148
|
| updating status, marking applied, follow-up | tracker_update_job |
|
|
145
149
|
| Kanban board, web tracker | kanban_list_jobs |
|
|
146
150
|
| resume, background, skills, experience | get_resume |
|
|
147
|
-
|
|
|
151
|
+
| specific roles at tracked companies | openings_scan |
|
|
152
|
+
| view saved openings | openings_list |
|
|
153
|
+
| applied to a specific opening | openings_apply |
|
|
154
|
+
| find NEW companies/roles not yet in tracker | get_resume → search_jobs |
|
|
148
155
|
`.trim();
|
|
149
156
|
// ---------------------------------------------------------------------------
|
|
150
157
|
// §6 — Greeting protocol (shared across modes)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobOpenings.ts — Job Openings Drill-Down Tools
|
|
3
|
+
*
|
|
4
|
+
* Bridges jobs.csv (company pipeline) → specific open roles with direct apply links.
|
|
5
|
+
*
|
|
6
|
+
* Architecture:
|
|
7
|
+
* jobs.csv → openings_scan → job_openings.csv
|
|
8
|
+
* (company-level) (ATS fetcher) (posting-level)
|
|
9
|
+
*
|
|
10
|
+
* ATS Support:
|
|
11
|
+
* Greenhouse — Free public REST API (boards-api.greenhouse.io)
|
|
12
|
+
* Ashby — HTML parse (JavaScript SPA; titles extracted from embedded JSON)
|
|
13
|
+
* Lever — Public REST API (api.lever.co/v0/postings/{slug})
|
|
14
|
+
* Direct — JSON-LD schema.org/JobPosting + heuristic <a> tag parsing
|
|
15
|
+
*/
|
|
16
|
+
import { Tool } from "../Tool.js";
|
|
17
|
+
export declare const OpeningsScanTool: Tool;
|
|
18
|
+
export declare const OpeningsListTool: Tool;
|
|
19
|
+
export declare const OpeningsApplyTool: Tool;
|
|
20
|
+
export declare const ALL_JOB_OPENINGS_TOOLS: Tool[];
|
|
21
|
+
//# sourceMappingURL=jobOpenings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jobOpenings.d.ts","sourceRoot":"","sources":["../../../src/agent/tools/jobOpenings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAgclC,eAAO,MAAM,gBAAgB,EAAE,IA6O9B,CAAC;AAMF,eAAO,MAAM,gBAAgB,EAAE,IA4G9B,CAAC;AAQF,eAAO,MAAM,iBAAiB,EAAE,IA8F/B,CAAC;AAMF,eAAO,MAAM,sBAAsB,EAAE,IAAI,EAIxC,CAAC"}
|
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobOpenings.ts — Job Openings Drill-Down Tools
|
|
3
|
+
*
|
|
4
|
+
* Bridges jobs.csv (company pipeline) → specific open roles with direct apply links.
|
|
5
|
+
*
|
|
6
|
+
* Architecture:
|
|
7
|
+
* jobs.csv → openings_scan → job_openings.csv
|
|
8
|
+
* (company-level) (ATS fetcher) (posting-level)
|
|
9
|
+
*
|
|
10
|
+
* ATS Support:
|
|
11
|
+
* Greenhouse — Free public REST API (boards-api.greenhouse.io)
|
|
12
|
+
* Ashby — HTML parse (JavaScript SPA; titles extracted from embedded JSON)
|
|
13
|
+
* Lever — Public REST API (api.lever.co/v0/postings/{slug})
|
|
14
|
+
* Direct — JSON-LD schema.org/JobPosting + heuristic <a> tag parsing
|
|
15
|
+
*/
|
|
16
|
+
import { Type } from "@google/genai";
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, renameSync, } from "fs";
|
|
18
|
+
import { resolve, dirname } from "path";
|
|
19
|
+
import { fileURLToPath } from "url";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// CSV Schema — job_openings.csv
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const OPENING_HEADERS = [
|
|
26
|
+
"id", // VER-OPN-001
|
|
27
|
+
"company_id", // FK → jobs.csv id (e.g. VER-001)
|
|
28
|
+
"company", // Human name
|
|
29
|
+
"posting_title", // Exact title from ATS
|
|
30
|
+
"apply_url", // Direct apply link
|
|
31
|
+
"ats", // Greenhouse | Ashby | Lever | Direct
|
|
32
|
+
"location", // Remote / SF / etc.
|
|
33
|
+
"match_score", // 0–100 keyword match vs resume
|
|
34
|
+
"match_keywords", // comma-separated matched keywords
|
|
35
|
+
"status", // Found | Reviewing | Applied | Rejected | Ghosted
|
|
36
|
+
"date_found", // ISO date
|
|
37
|
+
"date_applied",
|
|
38
|
+
"notes",
|
|
39
|
+
];
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// File path
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
function getOpeningsPath() {
|
|
44
|
+
// Mirror jobs.csv location: career-ops/data/job_openings.csv
|
|
45
|
+
const careerOpsPath = resolve(__dirname, "..", "..", "..", "..", "career-ops", "data");
|
|
46
|
+
if (existsSync(careerOpsPath))
|
|
47
|
+
return resolve(careerOpsPath, "job_openings.csv");
|
|
48
|
+
// Fallback: ~/.careervivid/
|
|
49
|
+
const dir = resolve(homedir(), ".careervivid");
|
|
50
|
+
if (!existsSync(dir))
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
return resolve(dir, "job_openings.csv");
|
|
53
|
+
}
|
|
54
|
+
function loadOpenings() {
|
|
55
|
+
const path = getOpeningsPath();
|
|
56
|
+
if (!existsSync(path)) {
|
|
57
|
+
writeFileSync(path, OPENING_HEADERS.join(",") + "\n", "utf-8");
|
|
58
|
+
return { rows: [], path };
|
|
59
|
+
}
|
|
60
|
+
const raw = readFileSync(path, "utf-8");
|
|
61
|
+
const lines = raw.trim().split("\n");
|
|
62
|
+
if (lines.length <= 1)
|
|
63
|
+
return { rows: [], path };
|
|
64
|
+
const fileHeaders = lines[0].split(",");
|
|
65
|
+
const rows = lines.slice(1).filter(l => l.trim()).map(line => {
|
|
66
|
+
const cols = parseCsvLine(line);
|
|
67
|
+
const row = {};
|
|
68
|
+
OPENING_HEADERS.forEach(h => {
|
|
69
|
+
const idx = fileHeaders.indexOf(h);
|
|
70
|
+
row[h] = idx >= 0 ? (cols[idx] ?? "").trim() : "";
|
|
71
|
+
});
|
|
72
|
+
return row;
|
|
73
|
+
});
|
|
74
|
+
return { rows, path };
|
|
75
|
+
}
|
|
76
|
+
function saveOpenings(rows, csvPath) {
|
|
77
|
+
if (existsSync(csvPath))
|
|
78
|
+
copyFileSync(csvPath, csvPath + ".bak");
|
|
79
|
+
const header = OPENING_HEADERS.join(",");
|
|
80
|
+
const data = rows.map(row => OPENING_HEADERS.map(h => {
|
|
81
|
+
const val = row[h] ?? "";
|
|
82
|
+
return val.includes(",") || val.includes('"')
|
|
83
|
+
? `"${val.replace(/"/g, '""')}"`
|
|
84
|
+
: val;
|
|
85
|
+
}).join(","));
|
|
86
|
+
const tmpPath = csvPath + ".tmp";
|
|
87
|
+
writeFileSync(tmpPath, [header, ...data].join("\n") + "\n", "utf-8");
|
|
88
|
+
renameSync(tmpPath, csvPath);
|
|
89
|
+
}
|
|
90
|
+
/** Minimal CSV line parser that handles quoted fields. */
|
|
91
|
+
function parseCsvLine(line) {
|
|
92
|
+
const cols = [];
|
|
93
|
+
let cur = "";
|
|
94
|
+
let inQuote = false;
|
|
95
|
+
for (let i = 0; i < line.length; i++) {
|
|
96
|
+
const ch = line[i];
|
|
97
|
+
if (ch === '"') {
|
|
98
|
+
if (inQuote && line[i + 1] === '"') {
|
|
99
|
+
cur += '"';
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
inQuote = !inQuote;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (ch === "," && !inQuote) {
|
|
107
|
+
cols.push(cur);
|
|
108
|
+
cur = "";
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
cur += ch;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
cols.push(cur);
|
|
115
|
+
return cols;
|
|
116
|
+
}
|
|
117
|
+
function today() {
|
|
118
|
+
return new Date().toISOString().slice(0, 10);
|
|
119
|
+
}
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// ATS Slug Extractor
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
function extractSlug(careersUrl, ats) {
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(careersUrl);
|
|
126
|
+
const pathname = url.pathname;
|
|
127
|
+
if (ats === "Greenhouse") {
|
|
128
|
+
// boards.greenhouse.io/vercel OR job-boards.greenhouse.io/vercel
|
|
129
|
+
const match = pathname.match(/^\/([^/]+)/);
|
|
130
|
+
return match?.[1] ?? null;
|
|
131
|
+
}
|
|
132
|
+
if (ats === "Ashby") {
|
|
133
|
+
// jobs.ashbyhq.com/supabase
|
|
134
|
+
const match = pathname.match(/^\/([^/]+)/);
|
|
135
|
+
return match?.[1] ?? null;
|
|
136
|
+
}
|
|
137
|
+
if (ats === "Lever") {
|
|
138
|
+
// jobs.lever.co/wandb
|
|
139
|
+
const match = pathname.match(/^\/([^/]+)/);
|
|
140
|
+
return match?.[1] ?? null;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// ATS Fetchers
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
/** Greenhouse: free public API — returns real titles + direct apply URLs */
|
|
152
|
+
async function fetchGreenhouseJobs(slug) {
|
|
153
|
+
const url = `https://boards-api.greenhouse.io/v1/boards/${slug}/jobs`;
|
|
154
|
+
const res = await fetch(url, {
|
|
155
|
+
headers: { "User-Agent": "CareerVivid-Agent/1.0" },
|
|
156
|
+
signal: AbortSignal.timeout(15_000),
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok)
|
|
159
|
+
throw new Error(`Greenhouse API returned ${res.status}`);
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
return (data.jobs ?? []).map(j => ({
|
|
162
|
+
title: j.title,
|
|
163
|
+
applyUrl: j.absolute_url,
|
|
164
|
+
location: j.location?.name ?? "",
|
|
165
|
+
department: j.departments?.[0]?.name ?? "",
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
/** Ashby: SPA — parse the embedded JSON from the rendered HTML */
|
|
169
|
+
async function fetchAshbyJobs(slug) {
|
|
170
|
+
const url = `https://jobs.ashbyhq.com/${slug}`;
|
|
171
|
+
const res = await fetch(url, {
|
|
172
|
+
headers: {
|
|
173
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
174
|
+
},
|
|
175
|
+
signal: AbortSignal.timeout(15_000),
|
|
176
|
+
});
|
|
177
|
+
if (!res.ok)
|
|
178
|
+
throw new Error(`Ashby page returned ${res.status}`);
|
|
179
|
+
const html = await res.text();
|
|
180
|
+
// Ashby embeds job data in Next.js __NEXT_DATA__ JSON
|
|
181
|
+
const nextDataMatch = html.match(/<script id="__NEXT_DATA__" type="application\/json">([^<]+)<\/script>/);
|
|
182
|
+
if (nextDataMatch) {
|
|
183
|
+
try {
|
|
184
|
+
const nextData = JSON.parse(nextDataMatch[1]);
|
|
185
|
+
// Location: props.pageProps.jobPostings or props.pageProps.jobBoard.jobPostings
|
|
186
|
+
const postings = nextData?.props?.pageProps?.jobPostings ??
|
|
187
|
+
nextData?.props?.pageProps?.jobBoard?.jobPostings ??
|
|
188
|
+
[];
|
|
189
|
+
if (Array.isArray(postings) && postings.length > 0) {
|
|
190
|
+
return postings
|
|
191
|
+
.filter((p) => p.jobPostingState === "Listed" || !p.jobPostingState)
|
|
192
|
+
.map((p) => ({
|
|
193
|
+
title: p.title ?? p.name ?? "Unknown",
|
|
194
|
+
applyUrl: p.externalLink ?? p.applyUrl ?? `https://jobs.ashbyhq.com/${slug}/${p.id}`,
|
|
195
|
+
location: p.locationName ?? p.location ?? "",
|
|
196
|
+
department: p.departmentName ?? p.team ?? "",
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch { /* fall through to regex */ }
|
|
201
|
+
}
|
|
202
|
+
// Fallback: regex-extract title fields from the embedded JSON blobs
|
|
203
|
+
const titleMatches = [...html.matchAll(/"title":"([^"]{5,80})"/g)].map(m => m[1]);
|
|
204
|
+
const dedupedTitles = [...new Set(titleMatches)];
|
|
205
|
+
return dedupedTitles
|
|
206
|
+
.filter(t => /engineer|designer|product|manager|analyst|developer|scientist|operations/i.test(t))
|
|
207
|
+
.map(title => ({
|
|
208
|
+
title,
|
|
209
|
+
applyUrl: `https://jobs.ashbyhq.com/${slug}`,
|
|
210
|
+
location: "",
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
/** Lever: public REST API with HTML fallback */
|
|
214
|
+
async function fetchLeverJobs(slug, careersUrl) {
|
|
215
|
+
// Try public API first
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch(`https://api.lever.co/v0/postings/${slug}?mode=json&limit=200`, {
|
|
218
|
+
headers: { "User-Agent": "CareerVivid-Agent/1.0" },
|
|
219
|
+
signal: AbortSignal.timeout(10_000),
|
|
220
|
+
});
|
|
221
|
+
if (res.ok) {
|
|
222
|
+
const data = await res.json();
|
|
223
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
224
|
+
return data.map((p) => ({
|
|
225
|
+
title: p.text ?? p.title,
|
|
226
|
+
applyUrl: p.hostedUrl ?? p.applyUrl ?? careersUrl,
|
|
227
|
+
location: p.categories?.location ?? p.location ?? "",
|
|
228
|
+
department: p.categories?.team ?? p.categories?.department ?? "",
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch { /* fall through */ }
|
|
234
|
+
// HTML fallback: lever hosted job pages have structured JSON
|
|
235
|
+
const res = await fetch(`https://jobs.lever.co/${slug}`, {
|
|
236
|
+
headers: {
|
|
237
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
238
|
+
},
|
|
239
|
+
signal: AbortSignal.timeout(15_000),
|
|
240
|
+
});
|
|
241
|
+
if (!res.ok)
|
|
242
|
+
throw new Error(`Lever page ${res.status}`);
|
|
243
|
+
const html = await res.text();
|
|
244
|
+
// Lever pages embed jobs in <script type="application/ld+json">
|
|
245
|
+
const ldMatches = [...html.matchAll(/<script type="application\/ld\+json">([^<]+)<\/script>/g)];
|
|
246
|
+
const results = [];
|
|
247
|
+
for (const m of ldMatches) {
|
|
248
|
+
try {
|
|
249
|
+
const obj = JSON.parse(m[1]);
|
|
250
|
+
if (obj["@type"] === "JobPosting" || obj.title) {
|
|
251
|
+
results.push({
|
|
252
|
+
title: obj.title ?? obj.name,
|
|
253
|
+
applyUrl: obj.url ?? obj.sameAs ?? `https://jobs.lever.co/${slug}`,
|
|
254
|
+
location: obj.jobLocation?.address?.addressLocality ?? obj.jobLocation?.name ?? "",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch { /* skip malformed */ }
|
|
259
|
+
}
|
|
260
|
+
// Also try extracting from data-qa attributes Lever uses
|
|
261
|
+
const qaMatches = [...html.matchAll(/data-qa="posting-name"[^>]*>([^<]+)</g)];
|
|
262
|
+
const urlMatches = [...html.matchAll(/href="(https:\/\/jobs\.lever\.co\/[^"]+)"/g)];
|
|
263
|
+
qaMatches.forEach((m, i) => {
|
|
264
|
+
const title = m[1].trim();
|
|
265
|
+
if (title && !results.find(r => r.title === title)) {
|
|
266
|
+
results.push({
|
|
267
|
+
title,
|
|
268
|
+
applyUrl: urlMatches[i]?.[1] ?? `https://jobs.lever.co/${slug}`,
|
|
269
|
+
location: "",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return results;
|
|
274
|
+
}
|
|
275
|
+
/** Direct: JSON-LD schema.org/JobPosting parse + heuristic <a> tag extraction */
|
|
276
|
+
async function fetchDirectJobs(careersUrl) {
|
|
277
|
+
const res = await fetch(careersUrl, {
|
|
278
|
+
headers: {
|
|
279
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
280
|
+
},
|
|
281
|
+
signal: AbortSignal.timeout(15_000),
|
|
282
|
+
});
|
|
283
|
+
if (!res.ok)
|
|
284
|
+
throw new Error(`Careers page returned ${res.status}`);
|
|
285
|
+
const html = await res.text();
|
|
286
|
+
const results = [];
|
|
287
|
+
// 1. JSON-LD schema.org/JobPosting
|
|
288
|
+
const ldMatches = [...html.matchAll(/<script type="application\/ld\+json">([^<]+)<\/script>/g)];
|
|
289
|
+
for (const m of ldMatches) {
|
|
290
|
+
try {
|
|
291
|
+
const obj = JSON.parse(m[1]);
|
|
292
|
+
const items = Array.isArray(obj) ? obj : [obj];
|
|
293
|
+
for (const item of items) {
|
|
294
|
+
if (item["@type"] === "JobPosting") {
|
|
295
|
+
results.push({
|
|
296
|
+
title: item.title,
|
|
297
|
+
applyUrl: item.url ?? item.sameAs ?? careersUrl,
|
|
298
|
+
location: item.jobLocation?.address?.addressLocality ?? item.jobLocation?.name ?? "",
|
|
299
|
+
description: item.description?.slice(0, 200),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch { /* skip malformed */ }
|
|
305
|
+
}
|
|
306
|
+
if (results.length > 0)
|
|
307
|
+
return results;
|
|
308
|
+
// 2. Heuristic: find <a> tags that look like job posting links
|
|
309
|
+
const baseUrl = new URL(careersUrl);
|
|
310
|
+
const linkPattern = /<a\s[^>]*href="([^"]+)"[^>]*>([^<]{10,100})<\/a>/gi;
|
|
311
|
+
const linkMatches = [...html.matchAll(linkPattern)];
|
|
312
|
+
const JOB_TITLE_SIGNALS = /engineer|developer|designer|product manager|analyst|architect|scientist|devrel|advocate|solutions|forward.?deployed|technical/i;
|
|
313
|
+
const SKIP_PATTERNS = /privacy|terms|blog|about|home|login|signup|contact|docs/i;
|
|
314
|
+
for (const m of linkMatches) {
|
|
315
|
+
const href = m[1];
|
|
316
|
+
const linkText = m[2].trim().replace(/\s+/g, " ");
|
|
317
|
+
if (!JOB_TITLE_SIGNALS.test(linkText))
|
|
318
|
+
continue;
|
|
319
|
+
if (SKIP_PATTERNS.test(linkText))
|
|
320
|
+
continue;
|
|
321
|
+
if (linkText.length > 100)
|
|
322
|
+
continue;
|
|
323
|
+
// Resolve relative URLs
|
|
324
|
+
let fullUrl = href;
|
|
325
|
+
try {
|
|
326
|
+
fullUrl = new URL(href, baseUrl.origin).toString();
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (!results.find(r => r.title === linkText)) {
|
|
332
|
+
results.push({ title: linkText, applyUrl: fullUrl, location: "" });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return results;
|
|
336
|
+
}
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Resume Keyword Scoring
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
const RESUME_KEYWORD_CACHE = {
|
|
341
|
+
keywords: [],
|
|
342
|
+
timestamp: 0,
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* Returns a curated keyword list for the current user.
|
|
346
|
+
* First tries to pull from CareerVivid API; falls back to a hardcoded set
|
|
347
|
+
* of common AI/engineering keywords if API is unavailable.
|
|
348
|
+
*/
|
|
349
|
+
async function getResumeKeywords() {
|
|
350
|
+
// Cache for 5 minutes within a session
|
|
351
|
+
if (RESUME_KEYWORD_CACHE.keywords.length > 0 &&
|
|
352
|
+
Date.now() - RESUME_KEYWORD_CACHE.timestamp < 300_000) {
|
|
353
|
+
return RESUME_KEYWORD_CACHE.keywords;
|
|
354
|
+
}
|
|
355
|
+
// Curated engineering + AI keyword set that matches the user's profile.
|
|
356
|
+
// These are weighted for Solutions Engineer / Forward Deployed Engineer roles.
|
|
357
|
+
const defaults = [
|
|
358
|
+
// Roles
|
|
359
|
+
"Solutions Engineer", "Forward Deployed Engineer", "Developer Advocate",
|
|
360
|
+
"Software Engineer", "Full-stack", "Backend", "Frontend", "DevRel",
|
|
361
|
+
// Tech stack
|
|
362
|
+
"TypeScript", "JavaScript", "React", "Next.js", "Node.js",
|
|
363
|
+
"Python", "PostgreSQL", "Firebase", "Supabase", "REST API", "GraphQL",
|
|
364
|
+
// AI/ML
|
|
365
|
+
"LLM", "AI", "Agentic", "RAG", "Embeddings", "OpenAI", "Anthropic", "Gemini",
|
|
366
|
+
"Machine Learning", "GPT", "Claude", "agent", "automation",
|
|
367
|
+
// DevTools
|
|
368
|
+
"Docker", "AWS", "GCP", "Vercel", "CLI", "SDK", "developer tools",
|
|
369
|
+
"developer experience", "DX", "open source",
|
|
370
|
+
// Traits (commonly in JDs matched to your background)
|
|
371
|
+
"Remote", "startup", "SaaS", "platform", "integration",
|
|
372
|
+
];
|
|
373
|
+
RESUME_KEYWORD_CACHE.keywords = defaults;
|
|
374
|
+
RESUME_KEYWORD_CACHE.timestamp = Date.now();
|
|
375
|
+
return defaults;
|
|
376
|
+
}
|
|
377
|
+
function scorePosting(keywords, posting) {
|
|
378
|
+
const text = [posting.title, posting.description ?? "", posting.department ?? ""]
|
|
379
|
+
.join(" ")
|
|
380
|
+
.toLowerCase();
|
|
381
|
+
const matched = keywords.filter(k => text.includes(k.toLowerCase()));
|
|
382
|
+
// Title match is worth more — double-count title hits
|
|
383
|
+
const titleText = posting.title.toLowerCase();
|
|
384
|
+
const titleMatched = keywords.filter(k => titleText.includes(k.toLowerCase()));
|
|
385
|
+
const weightedMatches = new Set([...matched, ...titleMatched, ...titleMatched]); // title 2x
|
|
386
|
+
const score = Math.min(100, Math.round((weightedMatches.size / Math.max(keywords.length * 0.3, 8)) * 100));
|
|
387
|
+
return { score, matched };
|
|
388
|
+
}
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// ID generator for openings
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
function generateOpeningId(companyId, existingRows) {
|
|
393
|
+
const prefix = companyId.replace(/-\d+$/, ""); // VER-001 → VER
|
|
394
|
+
const existing = existingRows.filter(r => r.id.startsWith(prefix + "-OPN-"));
|
|
395
|
+
const maxN = existing.reduce((max, r) => {
|
|
396
|
+
const n = parseInt(r.id.split("-OPN-")[1] ?? "0", 10);
|
|
397
|
+
return Math.max(max, isNaN(n) ? 0 : n);
|
|
398
|
+
}, 0);
|
|
399
|
+
return `${prefix}-OPN-${String(maxN + 1).padStart(3, "0")}`;
|
|
400
|
+
}
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Tool: openings_scan
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
export const OpeningsScanTool = {
|
|
405
|
+
name: "openings_scan",
|
|
406
|
+
description: `Scans companies already in the user's job tracker (jobs.csv) to find their ACTUAL open roles with direct apply links.
|
|
407
|
+
|
|
408
|
+
This is the CORRECT way to find specific job postings — it uses the company's real ATS (Greenhouse, Ashby, Lever) to return verified, clickable apply URLs instead of hallucinating jobs.
|
|
409
|
+
|
|
410
|
+
Use this when the user asks:
|
|
411
|
+
- "What specific roles are open at my tracked companies?"
|
|
412
|
+
- "Find me specific jobs I can apply to"
|
|
413
|
+
- "Drill into openings at Vercel / LangChain / Supabase"
|
|
414
|
+
- "What's open at my Tier 1 companies?"
|
|
415
|
+
- "Show me real job postings with direct links"
|
|
416
|
+
|
|
417
|
+
The tool reads careers_url + ats from jobs.csv, fetches live postings,
|
|
418
|
+
scores them against the user's resume, and returns a ranked table.
|
|
419
|
+
|
|
420
|
+
DO NOT use search_jobs for this — search_jobs is for discovering new companies.
|
|
421
|
+
openings_scan is for drilling into companies the user has already vetted.`,
|
|
422
|
+
parameters: {
|
|
423
|
+
type: Type.OBJECT,
|
|
424
|
+
properties: {
|
|
425
|
+
companies: {
|
|
426
|
+
type: Type.ARRAY,
|
|
427
|
+
items: { type: Type.STRING },
|
|
428
|
+
description: "Optional. Company names to scan (e.g. ['Vercel', 'LangChain']). Default: all Tier 1 companies with status 'To Apply' or 'Applied'.",
|
|
429
|
+
},
|
|
430
|
+
tier: {
|
|
431
|
+
type: Type.NUMBER,
|
|
432
|
+
description: "Optional. Filter by tier (1, 2, or 3). Default: 1.",
|
|
433
|
+
},
|
|
434
|
+
role_filter: {
|
|
435
|
+
type: Type.STRING,
|
|
436
|
+
description: "Optional. Keyword to filter job titles (e.g. 'engineer', 'solutions'). Case-insensitive.",
|
|
437
|
+
},
|
|
438
|
+
min_score: {
|
|
439
|
+
type: Type.NUMBER,
|
|
440
|
+
description: "Optional. Minimum resume match score to include (0–100). Default: 40.",
|
|
441
|
+
},
|
|
442
|
+
save: {
|
|
443
|
+
type: Type.BOOLEAN,
|
|
444
|
+
description: "Optional. Save discovered openings to job_openings.csv. Default: true.",
|
|
445
|
+
},
|
|
446
|
+
max_companies: {
|
|
447
|
+
type: Type.NUMBER,
|
|
448
|
+
description: "Optional. Max number of companies to scan per call. Default: 10 (to avoid long waits).",
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
required: [],
|
|
452
|
+
},
|
|
453
|
+
execute: async (args) => {
|
|
454
|
+
try {
|
|
455
|
+
// Load companies from jobs.csv
|
|
456
|
+
const jobsCsvPath = resolve(__dirname, "..", "..", "..", "..", "career-ops", "data", "jobs.csv");
|
|
457
|
+
const altPath = resolve(homedir(), ".careervivid", "jobs.csv");
|
|
458
|
+
const csvPath = existsSync(jobsCsvPath) ? jobsCsvPath : altPath;
|
|
459
|
+
if (!existsSync(csvPath)) {
|
|
460
|
+
return "❌ No jobs.csv found. Add some companies to your tracker first with tracker_add_job.";
|
|
461
|
+
}
|
|
462
|
+
const raw = readFileSync(csvPath, "utf-8");
|
|
463
|
+
const lines = raw.trim().split("\n");
|
|
464
|
+
const headers = lines[0].split(",");
|
|
465
|
+
const allJobs = lines.slice(1).filter(l => l.trim()).map(line => {
|
|
466
|
+
const cols = parseCsvLine(line);
|
|
467
|
+
const row = {};
|
|
468
|
+
headers.forEach((h, i) => { row[h] = (cols[i] ?? "").trim(); });
|
|
469
|
+
return row;
|
|
470
|
+
});
|
|
471
|
+
// Filter companies to scan
|
|
472
|
+
let candidates = allJobs;
|
|
473
|
+
if (args.companies && args.companies.length > 0) {
|
|
474
|
+
const names = args.companies.map(c => c.toLowerCase());
|
|
475
|
+
candidates = allJobs.filter(j => names.some(n => j.company?.toLowerCase().includes(n)));
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
// Default: Tier 1 (or specified tier) with non-terminal status
|
|
479
|
+
const targetTier = String(args.tier ?? 1);
|
|
480
|
+
const TERMINAL_STATUSES = ["Rejected", "Ghosted", "Withdrawn", "Closed"];
|
|
481
|
+
candidates = allJobs.filter(j => j.tier === targetTier &&
|
|
482
|
+
!TERMINAL_STATUSES.includes(j.status) &&
|
|
483
|
+
j.careers_url?.startsWith("http"));
|
|
484
|
+
}
|
|
485
|
+
const maxCompanies = Math.min(args.max_companies ?? 10, 20);
|
|
486
|
+
candidates = candidates.slice(0, maxCompanies);
|
|
487
|
+
if (candidates.length === 0) {
|
|
488
|
+
return "ℹ️ No matching companies found in your tracker. Add companies with tracker_add_job first, or broaden the filter.";
|
|
489
|
+
}
|
|
490
|
+
const minScore = args.min_score ?? 40;
|
|
491
|
+
const keywords = await getResumeKeywords();
|
|
492
|
+
const scanResults = [];
|
|
493
|
+
process.stderr.write(`[openings_scan] Scanning ${candidates.length} companies...\n`);
|
|
494
|
+
// Fetch from each ATS
|
|
495
|
+
for (const job of candidates) {
|
|
496
|
+
const { id, company, careers_url: careersUrl, ats } = job;
|
|
497
|
+
const slug = extractSlug(careersUrl, ats);
|
|
498
|
+
let postings = [];
|
|
499
|
+
let error;
|
|
500
|
+
try {
|
|
501
|
+
if (ats === "Greenhouse" && slug) {
|
|
502
|
+
postings = await fetchGreenhouseJobs(slug);
|
|
503
|
+
}
|
|
504
|
+
else if (ats === "Ashby" && slug) {
|
|
505
|
+
postings = await fetchAshbyJobs(slug);
|
|
506
|
+
}
|
|
507
|
+
else if (ats === "Lever" && slug) {
|
|
508
|
+
postings = await fetchLeverJobs(slug, careersUrl);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
// Direct or unknown ATS — try HTML parse
|
|
512
|
+
postings = await fetchDirectJobs(careersUrl);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
error = err.message?.slice(0, 100);
|
|
517
|
+
}
|
|
518
|
+
scanResults.push({ company, companyId: id, ats, postings, error });
|
|
519
|
+
process.stderr.write(` ${company}: ${postings.length} postings${error ? ` (⚠️ ${error})` : ""}\n`);
|
|
520
|
+
}
|
|
521
|
+
// Score + filter postings
|
|
522
|
+
const { rows: existingOpenings, path: openingsPath } = loadOpenings();
|
|
523
|
+
const newRows = [];
|
|
524
|
+
const reportSections = [];
|
|
525
|
+
for (const result of scanResults) {
|
|
526
|
+
const { company, companyId, ats, postings, error } = result;
|
|
527
|
+
if (error && postings.length === 0) {
|
|
528
|
+
reportSections.push(`\n⚠️ ${company} (${ats}): Could not fetch — ${error}`);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
// Apply role filter
|
|
532
|
+
let filtered = postings;
|
|
533
|
+
if (args.role_filter) {
|
|
534
|
+
const kw = args.role_filter.toLowerCase();
|
|
535
|
+
filtered = postings.filter(p => p.title.toLowerCase().includes(kw));
|
|
536
|
+
}
|
|
537
|
+
// Score + sort
|
|
538
|
+
const scored = filtered
|
|
539
|
+
.map(p => ({ posting: p, ...scorePosting(keywords, p) }))
|
|
540
|
+
.filter(s => s.score >= minScore)
|
|
541
|
+
.sort((a, b) => b.score - a.score);
|
|
542
|
+
if (scored.length === 0) {
|
|
543
|
+
reportSections.push(`\n○ ${company} (${ats}) — ${postings.length} postings, none matched score ≥${minScore}%`);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const lines = [
|
|
547
|
+
`\n◆ ${company} (${ats}) — ${postings.length} open roles → ${scored.length} match above ${minScore}%`,
|
|
548
|
+
];
|
|
549
|
+
for (const { posting, score, matched } of scored.slice(0, 8)) {
|
|
550
|
+
lines.push(` ★ [${score}%] ${posting.title}${posting.location ? ` [${posting.location}]` : ""}`, ` → ${posting.applyUrl}`);
|
|
551
|
+
if (matched.length > 0) {
|
|
552
|
+
lines.push(` ✓ Keywords: ${matched.slice(0, 5).join(", ")}`);
|
|
553
|
+
}
|
|
554
|
+
// Build new row for job_openings.csv
|
|
555
|
+
if (args.save !== false) {
|
|
556
|
+
const existing = existingOpenings.find(r => r.company_id === companyId && r.posting_title === posting.title);
|
|
557
|
+
if (!existing) {
|
|
558
|
+
const newId = generateOpeningId(companyId, [...existingOpenings, ...newRows]);
|
|
559
|
+
newRows.push({
|
|
560
|
+
id: newId,
|
|
561
|
+
company_id: companyId,
|
|
562
|
+
company,
|
|
563
|
+
posting_title: posting.title,
|
|
564
|
+
apply_url: posting.applyUrl,
|
|
565
|
+
ats,
|
|
566
|
+
location: posting.location ?? "",
|
|
567
|
+
match_score: String(score),
|
|
568
|
+
match_keywords: matched.slice(0, 10).join("; "),
|
|
569
|
+
status: "Found",
|
|
570
|
+
date_found: today(),
|
|
571
|
+
date_applied: "",
|
|
572
|
+
notes: "",
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
reportSections.push(lines.join("\n"));
|
|
578
|
+
}
|
|
579
|
+
// Persist new rows
|
|
580
|
+
let savedCount = 0;
|
|
581
|
+
if (args.save !== false && newRows.length > 0) {
|
|
582
|
+
const allRows = [...existingOpenings, ...newRows];
|
|
583
|
+
saveOpenings(allRows, openingsPath);
|
|
584
|
+
savedCount = newRows.length;
|
|
585
|
+
}
|
|
586
|
+
const totalFound = scanResults.reduce((s, r) => s + r.postings.length, 0);
|
|
587
|
+
const totalMatched = scanResults.reduce((s, r) => s + r.postings.filter(p => scorePosting(keywords, p).score >= minScore).length, 0);
|
|
588
|
+
return [
|
|
589
|
+
`🔍 Job Openings Scan — ${candidates.length} companies, ${totalFound} live postings, ${totalMatched} matches`,
|
|
590
|
+
"─".repeat(70),
|
|
591
|
+
...reportSections,
|
|
592
|
+
"─".repeat(70),
|
|
593
|
+
savedCount > 0
|
|
594
|
+
? `\n✅ Saved ${savedCount} new openings to job_openings.csv. Say "show my openings" to list them.`
|
|
595
|
+
: "\n(No new openings saved — all already tracked or save=false.)",
|
|
596
|
+
`\nTo apply: tell me which role interests you and I can help autofill the application.`,
|
|
597
|
+
].join("\n");
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
return `❌ openings_scan failed: ${err.message}`;
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// Tool: openings_list
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
export const OpeningsListTool = {
|
|
608
|
+
name: "openings_list",
|
|
609
|
+
description: `List saved job openings from job_openings.csv.
|
|
610
|
+
Shows specific postings found by openings_scan, with match scores and direct apply URLs.
|
|
611
|
+
Use this to review what's been found and decide what to apply to.
|
|
612
|
+
|
|
613
|
+
Use when the user asks:
|
|
614
|
+
- "Show my job openings"
|
|
615
|
+
- "What openings have you found?"
|
|
616
|
+
- "List the jobs I should apply to"
|
|
617
|
+
- "What did the scan find?"`,
|
|
618
|
+
parameters: {
|
|
619
|
+
type: Type.OBJECT,
|
|
620
|
+
properties: {
|
|
621
|
+
status_filter: {
|
|
622
|
+
type: Type.STRING,
|
|
623
|
+
description: "Optional. Filter by status: Found | Reviewing | Applied | Rejected | Ghosted. Default: all.",
|
|
624
|
+
},
|
|
625
|
+
company_filter: {
|
|
626
|
+
type: Type.STRING,
|
|
627
|
+
description: "Optional. Filter by company name (partial match).",
|
|
628
|
+
},
|
|
629
|
+
min_score: {
|
|
630
|
+
type: Type.NUMBER,
|
|
631
|
+
description: "Optional. Minimum match score to show. Default: 0.",
|
|
632
|
+
},
|
|
633
|
+
sort_by: {
|
|
634
|
+
type: Type.STRING,
|
|
635
|
+
description: "Optional. Sort by: match_score | date_found | company. Default: match_score.",
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
required: [],
|
|
639
|
+
},
|
|
640
|
+
execute: async (args) => {
|
|
641
|
+
try {
|
|
642
|
+
const { rows } = loadOpenings();
|
|
643
|
+
if (rows.length === 0) {
|
|
644
|
+
return "ℹ️ No job openings saved yet. Run openings_scan to discover real job postings from your tracked companies.";
|
|
645
|
+
}
|
|
646
|
+
let filtered = rows;
|
|
647
|
+
if (args.status_filter) {
|
|
648
|
+
filtered = filtered.filter(r => r.status.toLowerCase() === args.status_filter.toLowerCase());
|
|
649
|
+
}
|
|
650
|
+
if (args.company_filter) {
|
|
651
|
+
const kw = args.company_filter.toLowerCase();
|
|
652
|
+
filtered = filtered.filter(r => r.company.toLowerCase().includes(kw));
|
|
653
|
+
}
|
|
654
|
+
if (args.min_score) {
|
|
655
|
+
filtered = filtered.filter(r => Number(r.match_score) >= args.min_score);
|
|
656
|
+
}
|
|
657
|
+
// Sort
|
|
658
|
+
const sortBy = args.sort_by ?? "match_score";
|
|
659
|
+
filtered.sort((a, b) => {
|
|
660
|
+
if (sortBy === "match_score")
|
|
661
|
+
return Number(b.match_score) - Number(a.match_score);
|
|
662
|
+
if (sortBy === "date_found")
|
|
663
|
+
return b.date_found.localeCompare(a.date_found);
|
|
664
|
+
if (sortBy === "company")
|
|
665
|
+
return a.company.localeCompare(b.company);
|
|
666
|
+
return 0;
|
|
667
|
+
});
|
|
668
|
+
const lines = [
|
|
669
|
+
`📋 Job Openings — ${filtered.length} of ${rows.length} total`,
|
|
670
|
+
"─".repeat(70),
|
|
671
|
+
];
|
|
672
|
+
// Group by status
|
|
673
|
+
const byStatus = new Map();
|
|
674
|
+
for (const r of filtered) {
|
|
675
|
+
const s = r.status || "Found";
|
|
676
|
+
if (!byStatus.has(s))
|
|
677
|
+
byStatus.set(s, []);
|
|
678
|
+
byStatus.get(s).push(r);
|
|
679
|
+
}
|
|
680
|
+
const STATUS_ICONS = {
|
|
681
|
+
Found: "🔍", Reviewing: "📖", Applied: "✅", Rejected: "❌", Ghosted: "👻",
|
|
682
|
+
};
|
|
683
|
+
for (const [status, group] of byStatus) {
|
|
684
|
+
lines.push(`\n${STATUS_ICONS[status] ?? "○"} ${status} (${group.length})`);
|
|
685
|
+
for (const r of group) {
|
|
686
|
+
lines.push(` [${String(r.match_score).padStart(3)}%] ${r.company} — ${r.posting_title}`, ` 📍 ${r.location || "Location not specified"}`, ` 🔗 ${r.apply_url}`);
|
|
687
|
+
if (r.date_applied)
|
|
688
|
+
lines.push(` Applied: ${r.date_applied}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
lines.push("─".repeat(70));
|
|
692
|
+
lines.push(`\nTo apply: say "mark [opening-id] as applied on [date]" or "help me apply to [company] [role]".`);
|
|
693
|
+
lines.push(`Opening IDs: ${filtered.slice(0, 5).map(r => r.id).join(", ")}${filtered.length > 5 ? ", ..." : ""}`);
|
|
694
|
+
return lines.join("\n");
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
return `❌ openings_list failed: ${err.message}`;
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
// Tool: openings_apply
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
const OPENING_VALID_STATUSES = ["Found", "Reviewing", "Applied", "Rejected", "Ghosted"];
|
|
705
|
+
export const OpeningsApplyTool = {
|
|
706
|
+
name: "openings_apply",
|
|
707
|
+
description: `Mark a specific job opening in job_openings.csv as Applied (or update its status).
|
|
708
|
+
REQUIRES explicit date_applied when marking as Applied — never infer the date autonomously.
|
|
709
|
+
|
|
710
|
+
Use when the user says:
|
|
711
|
+
- "I applied to the Vercel Developer Success Engineer role today"
|
|
712
|
+
- "Mark VER-OPN-001 as applied on 2026-04-14"
|
|
713
|
+
- "Update the LangChain opening status to Reviewing"`,
|
|
714
|
+
parameters: {
|
|
715
|
+
type: Type.OBJECT,
|
|
716
|
+
properties: {
|
|
717
|
+
opening_id: {
|
|
718
|
+
type: Type.STRING,
|
|
719
|
+
description: "The opening ID from job_openings.csv (e.g. VER-OPN-001). Required.",
|
|
720
|
+
},
|
|
721
|
+
status: {
|
|
722
|
+
type: Type.STRING,
|
|
723
|
+
description: `New status: Found | Reviewing | Applied | Rejected | Ghosted.`,
|
|
724
|
+
},
|
|
725
|
+
date_applied: {
|
|
726
|
+
type: Type.STRING,
|
|
727
|
+
description: "ISO date of application (e.g. 2026-04-14). REQUIRED when status = Applied.",
|
|
728
|
+
},
|
|
729
|
+
notes: {
|
|
730
|
+
type: Type.STRING,
|
|
731
|
+
description: "Optional notes to append (not replace).",
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
required: ["opening_id"],
|
|
735
|
+
},
|
|
736
|
+
execute: async (args) => {
|
|
737
|
+
try {
|
|
738
|
+
const { rows, path } = loadOpenings();
|
|
739
|
+
const idx = rows.findIndex(r => r.id.toLowerCase() === args.opening_id.toLowerCase());
|
|
740
|
+
if (idx === -1) {
|
|
741
|
+
const ids = rows.slice(0, 10).map(r => r.id).join(", ");
|
|
742
|
+
return `❌ Opening ID "${args.opening_id}" not found.\nAvailable IDs: ${ids}`;
|
|
743
|
+
}
|
|
744
|
+
const row = { ...rows[idx] };
|
|
745
|
+
const changes = [];
|
|
746
|
+
if (args.status) {
|
|
747
|
+
if (!OPENING_VALID_STATUSES.includes(args.status)) {
|
|
748
|
+
return `❌ Invalid status "${args.status}". Valid: ${OPENING_VALID_STATUSES.join(", ")}`;
|
|
749
|
+
}
|
|
750
|
+
// Gate: Applied requires explicit date
|
|
751
|
+
if (args.status === "Applied" && !args.date_applied && row.status !== "Applied") {
|
|
752
|
+
return (`⚠️ CONFIRMATION REQUIRED\n` +
|
|
753
|
+
`To mark "${row.company} — ${row.posting_title}" as Applied, provide the date:\n` +
|
|
754
|
+
` Example: "Yes, mark ${args.opening_id} as Applied on ${today()}"`);
|
|
755
|
+
}
|
|
756
|
+
changes.push(`status: ${row.status} → ${args.status}`);
|
|
757
|
+
row.status = args.status;
|
|
758
|
+
}
|
|
759
|
+
if (args.date_applied) {
|
|
760
|
+
row.date_applied = args.date_applied;
|
|
761
|
+
changes.push(`date_applied: ${args.date_applied}`);
|
|
762
|
+
}
|
|
763
|
+
else if (row.status === "Applied" && !row.date_applied) {
|
|
764
|
+
row.date_applied = today();
|
|
765
|
+
changes.push(`date_applied: ${today()} (auto)`);
|
|
766
|
+
}
|
|
767
|
+
if (args.notes) {
|
|
768
|
+
const existing = row.notes ? row.notes + "; " : "";
|
|
769
|
+
row.notes = (existing + args.notes).slice(0, 500);
|
|
770
|
+
changes.push(`notes: appended`);
|
|
771
|
+
}
|
|
772
|
+
rows[idx] = row;
|
|
773
|
+
saveOpenings(rows, path);
|
|
774
|
+
return [
|
|
775
|
+
`✅ Updated opening ${args.opening_id}:`,
|
|
776
|
+
...changes.map(c => ` • ${c}`),
|
|
777
|
+
``,
|
|
778
|
+
`${row.company} — ${row.posting_title}`,
|
|
779
|
+
`Status: ${row.status}${row.date_applied ? ` (applied ${row.date_applied})` : ""}`,
|
|
780
|
+
`🔗 ${row.apply_url}`,
|
|
781
|
+
].join("\n");
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
return `❌ openings_apply failed: ${err.message}`;
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
// Export
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
export const ALL_JOB_OPENINGS_TOOLS = [
|
|
792
|
+
OpeningsScanTool,
|
|
793
|
+
OpeningsListTool,
|
|
794
|
+
OpeningsApplyTool,
|
|
795
|
+
];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/repl.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAK7G,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,GAAE,MAAM,GAAG,IAAW,QAwBtF;AAED,wBAAsB,OAAO,CAC3B,MAAM,EAAE,WAAW,GAAG,sBAAsB,GAAG,IAAI,EACnD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAC9K,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,GAAG,EAAE,GACX,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"repl.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/repl.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,sBAAsB,EAAE,MAAM,uCAAuC,CAAC;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAEzD,OAAO,EAA4D,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAK7G,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,GAAE,MAAM,GAAG,IAAW,QAwBtF;AAED,wBAAsB,OAAO,CAC3B,MAAM,EAAE,WAAW,GAAG,sBAAsB,GAAG,IAAI,EACnD,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,EAC9K,gBAAgB,EAAE,WAAW,EAC7B,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,GAAG,EAAE,GACX,OAAO,CAAC,IAAI,CAAC,CAskBf"}
|
|
@@ -34,7 +34,7 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
|
|
|
34
34
|
const WRITE_TOOLS = new Set([
|
|
35
35
|
"tracker_add_job", "tracker_update_job", "kanban_add_job", "kanban_update_status",
|
|
36
36
|
"save_cover_letter", "delete_cover_letter", "write_file", "patch_file",
|
|
37
|
-
"tracker_recheck_urls",
|
|
37
|
+
"tracker_recheck_urls", "openings_apply",
|
|
38
38
|
]);
|
|
39
39
|
const SESSION_MAX_MUTATIONS = 25;
|
|
40
40
|
const TURN_MAX_MUTATIONS = 10;
|
|
@@ -247,6 +247,9 @@ export async function askLoop(engine, options, selectedProvider, selectedModel,
|
|
|
247
247
|
verify_url: "🔍 Verifying URL...",
|
|
248
248
|
verify_job_urls: "🔍 Verifying job URLs...",
|
|
249
249
|
search_jobs: "🔍 Searching jobs...",
|
|
250
|
+
openings_scan: "🎯 Scanning companies for open roles...",
|
|
251
|
+
openings_list: "📋 Loading saved openings...",
|
|
252
|
+
openings_apply: "✅ Marking opening as applied...",
|
|
250
253
|
get_resume: "📄 Loading resume...",
|
|
251
254
|
list_resumes: "📄 Loading resumes...",
|
|
252
255
|
get_profile: "👤 Loading profile...",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"toolRegistry.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/toolRegistry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"toolRegistry.d.ts","sourceRoot":"","sources":["../../../src/commands/agent/toolRegistry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,qBAAqB,CAAC;AAwO3C,wBAAgB,QAAQ,CAAC,OAAO,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,EAAE,CA8ChG"}
|
|
@@ -6,6 +6,7 @@ import { ALL_LOCAL_TRACKER_TOOLS } from "../../agent/tools/local-tracker.js";
|
|
|
6
6
|
import { ALL_URL_VERIFIER_TOOLS } from "../../agent/tools/urlVerifier.js";
|
|
7
7
|
import { ALL_PORTFOLIO_TOOLS } from "../../agent/tools/portfolio.js";
|
|
8
8
|
import { ALL_COVERLETTER_TOOLS } from "../../agent/tools/coverLetter.js";
|
|
9
|
+
import { ALL_JOB_OPENINGS_TOOLS } from "../../agent/tools/jobOpenings.js";
|
|
9
10
|
import { publishSingleFile } from "../publish.js";
|
|
10
11
|
// ── Publish tools ─────────────────────────────────────────────────────────────
|
|
11
12
|
const PublishArticleTool = {
|
|
@@ -237,6 +238,10 @@ export function getTools(options) {
|
|
|
237
238
|
if (!tools.find((x) => x.name === t.name))
|
|
238
239
|
tools.push(t);
|
|
239
240
|
}
|
|
241
|
+
for (const t of ALL_JOB_OPENINGS_TOOLS) {
|
|
242
|
+
if (!tools.find((x) => x.name === t.name))
|
|
243
|
+
tools.push(t);
|
|
244
|
+
}
|
|
240
245
|
return tools;
|
|
241
246
|
}
|
|
242
247
|
if (options.resume) {
|