@timurtekb/tekjobs 0.24.1
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/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/app/README.md +22 -0
- package/app/dist/assets/index-DzGgV6NF.css +1 -0
- package/app/dist/assets/index-QteuouKv.js +77 -0
- package/app/dist/favicon.svg +1 -0
- package/app/dist/index.html +16 -0
- package/app/server/cover-letter.mjs +243 -0
- package/app/server/index.mjs +113 -0
- package/app/server/mail-check.mjs +234 -0
- package/app/server/mcp.mjs +129 -0
- package/app/server/people.mjs +164 -0
- package/app/server/store.mjs +1131 -0
- package/app/server/tailored-resume.mjs +213 -0
- package/cli.mjs +205 -0
- package/package.json +74 -0
- package/run.cmd +10 -0
- package/run.mjs +201 -0
- package/run.sh +12 -0
- package/samples/vault/Jobs/Basalt Systems - Senior Design Engineer, Growth (1010).md +56 -0
- package/samples/vault/Jobs/Brightline Studio - Design Engineer (1005).md +51 -0
- package/samples/vault/Jobs/Copperleaf - Principal Design Engineer (1006).md +53 -0
- package/samples/vault/Jobs/Example Studio - Senior Design Engineer (1013).md +52 -0
- package/samples/vault/Jobs/Fjord Analytics - Staff Frontend Engineer, Platform (1003).md +58 -0
- package/samples/vault/Jobs/Halcyon Robotics - Senior UX Engineer (1004).md +52 -0
- package/samples/vault/Jobs/Lumen Health - Design Systems Engineer (1002).md +51 -0
- package/samples/vault/Jobs/Meridian Pay - Staff Design Engineer (1009).md +67 -0
- package/samples/vault/Jobs/Northwind Labs - Staff Design Engineer, Design Systems (1000).md +57 -0
- package/samples/vault/Jobs/Orbital Software - Senior Design Engineer (1001).md +52 -0
- package/samples/vault/Jobs/Quill & Co - Design Engineer, Editor (1008).md +55 -0
- package/samples/vault/Jobs/Signalfire Design - Design Engineer (1012).md +53 -0
- package/samples/vault/Jobs/Tessellate - Senior Frontend Engineer, Design Systems (1007).md +52 -0
- package/samples/vault/Jobs/Verdant - UX Engineer (1011).md +51 -0
- package/samples/vault/Logs/2026-09-16.md +13 -0
- package/samples/vault/Logs/2026-09-23.md +13 -0
- package/samples/vault/People/Lee Example (Fjord Analytics).md +22 -0
- package/samples/vault/People/Priya Example (Meridian Pay).md +23 -0
- package/samples/vault/People/Sam Example (Northwind Labs).md +20 -0
- package/samples/vault/Profile/Positioning.md +16 -0
- package/samples/vault/Profile/Profile.md +41 -0
- package/samples/vault/Profile/Resume.md +32 -0
- package/samples/vault/Profile/Voice.md +15 -0
- package/samples/vault/README.md +11 -0
- package/samples/vault/Targets/Companies.md +318 -0
- package/samples/vault/Targets/Search Criteria.md +234 -0
- package/samples/vault/_Home.md +54 -0
- package/scraper/config.mjs +122 -0
- package/scraper/health.mjs +50 -0
- package/scraper/import-link.mjs +281 -0
- package/scraper/profile.mjs +186 -0
- package/scraper/rescore.mjs +236 -0
- package/scraper/resume-sync.mjs +224 -0
- package/scraper/resume.mjs +65 -0
- package/scraper/score.mjs +114 -0
- package/scraper/sources-email.mjs +242 -0
- package/scraper/sources-extra.mjs +558 -0
- package/scraper/sources-sites.mjs +280 -0
- package/scraper/sources.mjs +264 -0
- package/scraper/starter/companies-table.md +306 -0
- package/scraper/starter/criteria.json +56 -0
- package/scraper/starter/profile.md +38 -0
- package/scraper/vault.mjs +212 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// TekJobs MCP server (stdio, JSON-RPC 2.0). Lets Claude Code / Claude Desktop run the job search without the UI:
|
|
3
|
+
// search and read matches, move them through the pipeline, add notes, edit criteria, add boards, trigger a scan,
|
|
4
|
+
// and pull the materials needed to tailor an application. Hand-rolled: no SDK dependency.
|
|
5
|
+
import * as store from './store.mjs';
|
|
6
|
+
import * as letter from './cover-letter.mjs';
|
|
7
|
+
import * as tailored from './tailored-resume.mjs';
|
|
8
|
+
import * as mail from './mail-check.mjs';
|
|
9
|
+
import * as people from './people.mjs';
|
|
10
|
+
|
|
11
|
+
const TOOLS = [
|
|
12
|
+
{ name: 'list_snippets', description: 'The copy panel: the person\'s standard answers for application forms (name, email, phone, links, availability, salary answer, anything they added), grouped, from Profile/Snippets.md. Use these verbatim when drafting form answers; never invent a value that is empty here.', inputSchema: { type: 'object', properties: {} } },
|
|
13
|
+
{ name: 'list_people', description: 'The people in the search: recruiters, hiring managers, interviewers and referrals, one note each under People/, with role, company, email, last contact and the job notes they are on. Newest contact first.', inputSchema: { type: 'object', properties: {} } },
|
|
14
|
+
{ name: 'get_person', description: 'One person in full: the row plus their About text and dated Log of contacts.', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
15
|
+
{ name: 'add_person', description: 'Add a person (or recognise one already there, by email or name and company) and optionally put them on a job note. role: recruiter | hiring-manager | interviewer | referral | other. Give jobId to attach; context is a few words on the thread.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, role: { type: 'string', enum: people.ROLES }, company: { type: 'string' }, email: { type: 'string' }, links: { type: 'string' }, about: { type: 'string' }, jobId: { type: 'string' }, context: { type: 'string' } }, required: ['name'] } },
|
|
16
|
+
{ name: 'attach_person', description: 'Put an existing person on a job note (a line under its People section) and the job on their Threads. Idempotent.', inputSchema: { type: 'object', properties: { jobId: { type: 'string' }, personId: { type: 'string' }, role: { type: 'string', enum: people.ROLES }, context: { type: 'string' } }, required: ['jobId', 'personId'] } },
|
|
17
|
+
{ name: 'log_contact', description: "Append a dated line to a person's Log (a call, a reply, a note to self) and move their last contact forward. Nothing is sent.", inputSchema: { type: 'object', properties: { personId: { type: 'string' }, text: { type: 'string' }, date: { type: 'string', description: 'YYYY-MM-DD, default today.' } }, required: ['personId', 'text'] } },
|
|
18
|
+
{ name: 'tailored_resume_materials', description: 'Everything needed to tailor the resume to one posting: the posting, the resume of record (the only source of facts), the binding rules and the exact Markdown structure to output. Tailoring means reorder, prune, tighten and re-summarise; never add a bullet, title, date or number. Write it from the returned prompt, then call save_tailored_resume. emphasis: auto | design-systems | ai-product.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, emphasis: { type: 'string' }, extra: { type: 'string' } }, required: ['id'] } },
|
|
19
|
+
{ name: 'save_tailored_resume', description: 'Save a tailored resume (Markdown in the required structure) into the job note. Returns warnings: bullets that do not trace to the resume of record, figures or date ranges or job headers that are not on it. Fix every warning and save again; a person will upload this. The print view is /api/jobs/<id>/resume.html on the local server.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' } }, required: ['id', 'text'] } },
|
|
20
|
+
{ name: 'cover_letter_materials', description: 'Everything needed to write a cover letter for one job: the posting, the resume (the only source of facts), the profile, positioning, what the packet has already decided, and the binding rules. Write the letter yourself from the returned prompt, then call save_cover_letter. emphasis: auto | design-systems | ai-product. length: short | standard. extra: anything the person asked you to include.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, emphasis: { type: 'string' }, length: { type: 'string' }, extra: { type: 'string' } }, required: ['id'] } },
|
|
21
|
+
{ name: 'save_cover_letter', description: 'Save a cover letter into the job note (plain text, no markdown). Returns the word count and warnings: figures that are not on the resume, dashes, placeholders, stock phrases, a letter that never names the company. Fix what it flags and save again. Saving does not change the job status and nothing is sent.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, text: { type: 'string' } }, required: ['id', 'text'] } },
|
|
22
|
+
{ name: 'search_jobs', description: 'Search the open job matches in the vault. Returns compact rows sorted by score. Filters: q (text in company/title/location), status (new|reviewing|applying|applied|interviewing|offer|rejected|passed|all, or several comma-separated), band (floor|stretch|below|unknown|all), kind (design-eng|adjacent|all), location (comma-separated any-of, matched against the posting\'s location text), remote (true keeps only rows the scan marked remote), source (a note source such as ashby, greenhouse, wellfound, builtin, google, apple, or several comma-separated; "link" means every job the person added by pasting a link), company (one or several comma-separated), payMin / payMax (annual, judged on the top of the stated range), payKnown (true keeps only postings that state pay), postedDays / foundDays (posted or found within N days), minScore, maxScore, limit.', inputSchema: { type: 'object', properties: { q: { type: 'string' }, status: { type: 'string' }, band: { type: 'string' }, kind: { type: 'string' }, location: { type: 'string' }, remote: { type: 'boolean' }, source: { type: 'string' }, company: { type: 'string' }, payMin: { type: 'number' }, payMax: { type: 'number' }, payKnown: { type: 'boolean' }, postedDays: { type: 'number' }, foundDays: { type: 'number' }, maxScore: { type: 'number' }, minScore: { type: 'number' }, limit: { type: 'number' } } } },
|
|
23
|
+
{ name: 'get_job', description: 'Read one job note in full: frontmatter, why it matched, status log, notes, application section, and the job description text. id is the note name (from search_jobs).', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
24
|
+
{ name: 'today', description: "The short list that wants a decision today, rather than the whole inventory: the top unreviewed matches by fit, ones aging past the point where listings here start closing, ones that closed while still unreviewed, and anything in flight that is being prepared, due a follow-up, or interviewing. Scores are given as a 0-100 fit alongside the raw score. Start here.", inputSchema: { type: 'object', properties: { cap: { type: 'number', description: 'Rows per section, default 7.' } } } },
|
|
25
|
+
{ name: 'set_status', description: 'Move a job through the pipeline. Rewrites the note frontmatter and appends to its Status log. You can move a job as far as \"ready\", which means a packet is drafted and waiting on the person. You cannot set applied, interviewing or offer: those record things that happened outside this machine, and only the person they happened to can say so. When passing on a job, give a reason: it is what lets the criteria be argued with later from evidence rather than memory.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, status: { type: 'string', enum: store.STATUSES }, reason: { type: 'string', enum: store.PASS_REASONS, description: 'Why, when the status is passed or rejected.' } }, required: ['id', 'status'] } },
|
|
26
|
+
{ name: 'add_note', description: "Append a dated line under the job note's Notes section.", inputSchema: { type: 'object', properties: { id: { type: 'string' }, note: { type: 'string' } }, required: ['id', 'note'] } },
|
|
27
|
+
{ name: 'save_application_field', description: 'Fill one field of the application packet in a job note. Use after drafting. Call application_packet first to see the fields, what is already filled, and what is still required.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, field: { type: 'string', enum: store.APPLICATION_FIELDS.map((f) => f.field) }, value: { type: 'string' } }, required: ['id', 'field', 'value'] } },
|
|
28
|
+
{ name: 'application_packet', description: "The application packet for one job as structured data: every field, what is in it, which required ones are still missing, and whether it is ready for a person to approve. 'ready' means the required fields are filled, not that the application is good, and it is never permission to send anything.", inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
29
|
+
{ name: 'application_materials', description: 'Everything needed to tailor an application for one job: the job note plus the candidate profile, positioning note, and current resume draft from the vault. Read this, then draft; then save with save_application_field.', inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
30
|
+
{ name: 'summary', description: 'Pipeline counts by status, pay band and kind; last scan; current floor, stretch and score bar.', inputSchema: { type: 'object', properties: {} } },
|
|
31
|
+
{ name: 'run_scan', description: 'Start a scan of every board now (background). Poll scan_status. Pass dry=true to score without writing notes. Pass criteria=<preset name> to score this one run with a named criteria preset instead of the active Search Criteria (see list_criteria_presets).', inputSchema: { type: 'object', properties: { dry: { type: 'boolean' }, criteria: { type: 'string' } } } },
|
|
32
|
+
{ name: 'mail_check', description: 'Start a read-only pass over the person\'s mailbox for application updates (confirmations, rejections, interview invitations) through the local CLI\'s Gmail connector, with only the Gmail read tools allowed. Runs in the background for a few minutes; poll mail_items. days: how far back (default: since the last check, or 21 days the first time).', inputSchema: { type: 'object', properties: { days: { type: 'number' } } } },
|
|
33
|
+
{ name: 'mail_items', description: 'The application emails found so far, each matched to a job note (exact role, same company, or none) with what confirming it would do. Confirming is done by the person in the app; an agent can read these and tell them what is waiting.', inputSchema: { type: 'object', properties: {} } },
|
|
34
|
+
{ name: 'preview_criteria', description: 'What a proposed criteria JSON would do to the notes that exist, without saving or scanning: how many rise above or fall below the bar, who enters or leaves the top 20, the biggest movers. Covers title terms, recency and pay exactly; description, seniority and location rules need a scan.', inputSchema: { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] } },
|
|
35
|
+
{ name: 'outcomes', description: 'What the search is producing: the funnel (found, reviewed, shortlisted, applied, interviewing, offer), response rate, median days from discovery to application, applications waiting without a response at 7/14/21 days with the oldest listed, and applied/response counts by source.', inputSchema: { type: 'object', properties: {} } },
|
|
36
|
+
{ name: 'list_criteria_presets', description: 'Named criteria sets under Targets/Criteria/: name, bar, pay floor, title count, and whether one is identical to the active Search Criteria.', inputSchema: { type: 'object', properties: {} } },
|
|
37
|
+
{ name: 'save_criteria_preset', description: 'Create or update a named criteria preset from a full criteria JSON string (same shape as get_criteria). Does not change the active criteria; use activate_criteria_preset for that, or run_scan with criteria=<name> for one run.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, raw: { type: 'string' } }, required: ['name', 'raw'] } },
|
|
38
|
+
{ name: 'activate_criteria_preset', description: 'Copy a named preset into Targets/Search Criteria.md so the daily scan and everything else use it.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
|
|
39
|
+
{ name: 'scan_status', description: 'Whether a scan is running, and its last output lines.', inputSchema: { type: 'object', properties: {} } },
|
|
40
|
+
{ name: 'rescore_notes', description: 'Score every existing note again under the saved criteria (the CLI\'s rescore --full): score, pay band, match reasons and a status-log line on each note that moved; notes already at the current weights are left alone. dry previews the count.', inputSchema: { type: 'object', properties: { dry: { type: 'boolean' } } } },
|
|
41
|
+
{ name: 'scan_preview', description: 'The top of the last scan\'s ranking, dry or real, with score, pay band and the first reasons: what the criteria find, before or without notes. Use after a dry run_scan.', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: '1 to 50, default 15' } } } },
|
|
42
|
+
{ name: 'get_criteria', description: 'The scoring criteria JSON from Targets/Search Criteria.md.', inputSchema: { type: 'object', properties: {} } },
|
|
43
|
+
{ name: 'set_criteria', description: 'Replace the criteria JSON block. Pass the full JSON as a string; it is validated before writing.', inputSchema: { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] } },
|
|
44
|
+
{ name: 'list_companies', description: 'The company watchlist rows (name, ats, slug, tier, status) with each board\'s health: state (failed, zero, stale, never, ok), last success, last attempt, last error.', inputSchema: { type: 'object', properties: {} } },
|
|
45
|
+
{ name: 'list_feeds', description: 'The aggregator feeds: whether each is on under the criteria\'s openSources, and how it did on the last scan.', inputSchema: { type: 'object', properties: {} } },
|
|
46
|
+
{ name: 'onboarding_status', description: 'What the onboarding still needs: profile folder, resume, profile note, criteria, first scan. Call this first in a new setup.', inputSchema: { type: 'object', properties: {} } },
|
|
47
|
+
{ name: 'onboarding_materials', description: 'Start the onboarding interview. Returns the resume text, the current profile note, the current criteria JSON, and the interview script to follow step by step. Follow the script; it ends with save_profile, set_criteria and a scan.', inputSchema: { type: 'object', properties: {} } },
|
|
48
|
+
{ name: 'save_profile', description: 'Write Profile/Profile.md in full (markdown with the standard headings). The previous version is kept beside it.', inputSchema: { type: 'object', properties: { markdown: { type: 'string' } }, required: ['markdown'] } },
|
|
49
|
+
{ name: 'fetch_link', description: 'Fetch a public page the user gave you (portfolio, GitHub, personal site) as plain text, to fold into the profile. http(s) only; never use it on LinkedIn profile pages.', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
|
|
50
|
+
{ name: 'import_resume', description: 'Import a resume file from a local path (PDF, DOCX, Markdown, text): copies it into Profile/ and extracts its text for the interview.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
|
|
51
|
+
{ name: 'add_job', description: 'Add postings the person found themselves, from links (LinkedIn job pages, a company careers page, a Greenhouse/Lever/Ashby posting). Each link is read once, scored with the current criteria, and written as a job note unless the scan already has it or a note with the same company and title exists. A LinkedIn link is opened as a signed-out visitor; if it names the company\'s own apply page on a known board, that page is imported and the LinkedIn link is recorded as where it was seen. Returns one result per link. Up to 25 links.', inputSchema: { type: 'object', properties: { urls: { type: 'array', items: { type: 'string' } } }, required: ['urls'] } },
|
|
52
|
+
{ name: 'attach_posting', description: 'Attach the real posting to a note that was created without one (from an application email or a pasted link with thin data). Reads the posting through its board when the link is Greenhouse/Lever/Ashby (including company pages with ?gh_jid= or ?ashby_jid=), replaces the note\'s posting facts, score, reasons and description, and keeps the status, notes, packet and drafts. The note name does not change.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, url: { type: 'string' } }, required: ['id', 'url'] } },
|
|
53
|
+
{ name: 'add_company', description: 'Append a board to the watchlist. ats is the platform (greenhouse, lever, ashby, workday, rippling, smartrecruiters, workable, bamboohr, breezy, personio, teamtailor, eightfold, atlassian, github, spotify, amazon); slug is the board token.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, ats: { type: 'string' }, slug: { type: 'string' }, tier: { type: 'string' }, notes: { type: 'string' } }, required: ['name', 'ats', 'slug'] } },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
async function call(name, a = {}) {
|
|
57
|
+
switch (name) {
|
|
58
|
+
case 'onboarding_status': return store.onboardingStatus();
|
|
59
|
+
case 'onboarding_materials': return store.onboardingMaterials();
|
|
60
|
+
case 'save_profile': return store.saveProfile(a.markdown);
|
|
61
|
+
case 'fetch_link': return store.fetchLink(a.url);
|
|
62
|
+
case 'import_resume': return store.importResume(a.path);
|
|
63
|
+
case 'search_jobs': { const r = store.searchJobs({ q: a.q, location: a.location || '', status: a.status, band: a.band, kind: a.kind, source: a.source, company: a.company, payMin: a.payMin, payMax: a.payMax, payKnown: a.payKnown === true, postedDays: a.postedDays, foundDays: a.foundDays, maxScore: a.maxScore, remoteOnly: a.remote === true, minScore: a.minScore, limit: a.limit || 50 }); return { total: r.total, rows: r.rows.map((j) => ({ id: j.id, score: j.score, company: j.company, title: j.title, location: j.location, pay: j.salary, band: j.payBand, status: j.status, posted: j.posted, url: j.url })) }; }
|
|
64
|
+
case 'get_job': return store.getJob(a.id);
|
|
65
|
+
case 'today': return store.today({ cap: a.cap || 7 });
|
|
66
|
+
case 'set_status': return store.setStatus(a.id, a.status, 'mcp', a.reason || '');
|
|
67
|
+
case 'add_note': return store.addNote(a.id, a.note, 'mcp');
|
|
68
|
+
case 'save_application_field': return store.saveApplicationDraft(a.id, { field: a.field, value: a.value });
|
|
69
|
+
case 'application_packet': return store.applicationPacket(a.id);
|
|
70
|
+
case 'tailored_resume_materials': return { ...await tailored.materials(a.id, { emphasis: a.emphasis, extra: a.extra || '' }), saved: tailored.saved(a.id) };
|
|
71
|
+
case 'save_tailored_resume': return tailored.save(a.id, a.text, 'mcp');
|
|
72
|
+
case 'cover_letter_materials': return { ...await letter.materials(a.id, { emphasis: a.emphasis, length: a.length, extra: a.extra || '' }), saved: letter.saved(a.id) };
|
|
73
|
+
case 'save_cover_letter': return letter.save(a.id, a.text, 'mcp');
|
|
74
|
+
case 'application_materials': return { job: store.getJob(a.id), packet: store.applicationPacket(a.id), ...store.profile() };
|
|
75
|
+
case 'summary': return store.summary();
|
|
76
|
+
case 'run_scan': return store.runScan(a.dry ? ['--dry'] : [], { criteria: a.criteria || '', via: 'mcp' });
|
|
77
|
+
case 'mail_check': return mail.start({ sinceDays: a.days });
|
|
78
|
+
case 'mail_items': return mail.items();
|
|
79
|
+
case 'preview_criteria': return store.previewCriteria(a.raw);
|
|
80
|
+
case 'rescore_notes': return store.rescoreNotes({ dry: !!a.dry });
|
|
81
|
+
case 'outcomes': return store.outcomes();
|
|
82
|
+
case 'list_criteria_presets': return store.criteriaPresets();
|
|
83
|
+
case 'save_criteria_preset': return store.saveCriteriaPreset(a.name, a.raw);
|
|
84
|
+
case 'activate_criteria_preset': return store.activateCriteriaPreset(a.name);
|
|
85
|
+
case 'scan_status': return store.scanStatus();
|
|
86
|
+
case 'scan_preview': return store.scanPreview(a);
|
|
87
|
+
case 'get_criteria': return store.getCriteria();
|
|
88
|
+
case 'set_criteria': return store.setCriteria(a.raw);
|
|
89
|
+
case 'list_companies': return store.companies();
|
|
90
|
+
case 'list_feeds': return store.feeds();
|
|
91
|
+
case 'add_company': return store.addCompany(a);
|
|
92
|
+
case 'add_job': return store.importLinks(a.urls);
|
|
93
|
+
case 'attach_posting': return store.attachPosting(a.id, a.url);
|
|
94
|
+
case 'list_snippets': return store.getSnippets();
|
|
95
|
+
case 'list_people': return people.listPeople();
|
|
96
|
+
case 'get_person': return people.getPerson(a.id);
|
|
97
|
+
case 'add_person': { const p = people.createPerson(a); if (a.jobId) people.attachPerson(a.jobId, p.id, { role: a.role, context: a.context }); return people.getPerson(p.id); }
|
|
98
|
+
case 'attach_person': return people.attachPerson(a.jobId, a.personId, { role: a.role, context: a.context });
|
|
99
|
+
case 'log_contact': return people.logContact(a.personId, { date: a.date, via: 'mcp', text: a.text });
|
|
100
|
+
default: throw Object.assign(new Error(`unknown tool ${name}`), { code: -32601 });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const write = (o) => process.stdout.write(JSON.stringify(o) + '\n');
|
|
105
|
+
let buf = '';
|
|
106
|
+
process.stdin.setEncoding('utf8');
|
|
107
|
+
process.stdin.on('data', (chunk) => {
|
|
108
|
+
buf += chunk; let i;
|
|
109
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
110
|
+
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
|
111
|
+
if (!line) continue;
|
|
112
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
113
|
+
handle(msg);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
function handle(msg) {
|
|
117
|
+
const { id, method, params = {} } = msg;
|
|
118
|
+
if (method === 'initialize') return write({ jsonrpc: '2.0', id, result: { protocolVersion: params.protocolVersion || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'tekjobs', version: '0.1.0' } } });
|
|
119
|
+
if (method === 'notifications/initialized' || method?.startsWith('notifications/')) return;
|
|
120
|
+
if (method === 'ping') return write({ jsonrpc: '2.0', id, result: {} });
|
|
121
|
+
if (method === 'tools/list') return write({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
122
|
+
if (method === 'tools/call') {
|
|
123
|
+
return call(params.name, params.arguments || {})
|
|
124
|
+
.then((result) => write({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } }))
|
|
125
|
+
.catch((e) => write({ jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: e.message }] } }));
|
|
126
|
+
}
|
|
127
|
+
if (id !== undefined) write({ jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } });
|
|
128
|
+
}
|
|
129
|
+
process.stderr.write(`tekjobs-mcp: vault ${store.VAULT}\n`);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// People: the recruiters, hiring managers, referrals and interviewers a search accumulates. A dozen
|
|
2
|
+
// relationships, not a list. One note per person under People/, and a "## People" section on each job note
|
|
3
|
+
// that names who is on that thread. The mail check proposes people from the humans who wrote (never the
|
|
4
|
+
// no-reply senders), and they are written when the person confirms the email, like everything else from mail.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { VAULT } from '../../scraper/config.mjs';
|
|
8
|
+
import { readFrontmatter } from '../../scraper/vault.mjs';
|
|
9
|
+
import * as store from './store.mjs';
|
|
10
|
+
|
|
11
|
+
export const PEOPLE_DIR = path.join(VAULT, 'People');
|
|
12
|
+
export const ROLES = ['recruiter', 'hiring-manager', 'interviewer', 'referral', 'other'];
|
|
13
|
+
const isoDay = () => new Date().toISOString().slice(0, 10);
|
|
14
|
+
const yaml = (v) => JSON.stringify(v ?? '');
|
|
15
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
16
|
+
|
|
17
|
+
// ---------------- pure ----------------
|
|
18
|
+
/** A filename for a person: their name, cleaned; a company suffix keeps two Alex Kims apart. */
|
|
19
|
+
export function personId(name, company = '') {
|
|
20
|
+
const clean = (s) => String(s || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
21
|
+
const n = clean(name).slice(0, 60);
|
|
22
|
+
return company ? `${n} (${clean(company).slice(0, 30)})` : n;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const AUTOMATED = /^(no-?reply|do-?not-?reply|donotreply|notifications?|noreply|mailer|jobs|careers|recruiting|talent|hiring|applications?|hr|system|updates?|alerts?|team|info|support|hello|candidates?)@|@(mail\.|em\.|e\.|notify\.|notifications?\.)/i;
|
|
26
|
+
const ATS_DOMAINS = /(greenhouse|ashbyhq|lever\.co|myworkdayjobs|workday|smartrecruiters|jobvite|icims|workablemail|workable|rippling|bamboohr|breezy|personio|teamtailor|eightfold|linkedin|indeed|glassdoor|wellfound|hired\.com|gem\.com|goodtime|calendly|modernloop)/i;
|
|
27
|
+
|
|
28
|
+
/** "Jane Doe <jane@x.com>" or "jane@x.com" into name and address. */
|
|
29
|
+
export function parseAddress(from = '') {
|
|
30
|
+
const s = String(from).trim();
|
|
31
|
+
const m = s.match(/^"?([^"<]*)"?\s*<([^>]+)>$/);
|
|
32
|
+
const email = (m ? m[2] : s).trim().toLowerCase();
|
|
33
|
+
let name = (m ? m[1] : '').trim();
|
|
34
|
+
if (!name && email.includes('@')) name = email.split('@')[0].replace(/[._-]+/g, ' ').replace(/\d+/g, '').trim().replace(/\b\w/g, (c) => c.toUpperCase());
|
|
35
|
+
return { name, email: email.includes('@') ? email : '' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The person behind a mail item, or null when a machine sent it. A human sender on an application thread is
|
|
40
|
+
* almost always the recruiter or coordinator; the role can be corrected on the note.
|
|
41
|
+
*/
|
|
42
|
+
export function fromMail(item = {}) {
|
|
43
|
+
const { name: fromName, email } = parseAddress(item.from);
|
|
44
|
+
if (!email || AUTOMATED.test(email) || ATS_DOMAINS.test(email.split('@')[1] || '')) return null;
|
|
45
|
+
const name = String(item.fromName || '').trim() || fromName;
|
|
46
|
+
if (!name || name.includes('@')) return null;
|
|
47
|
+
return { name, email, role: 'recruiter', company: item.company || '' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The People-line parser lives in store.mjs, which Today reads through; it is the same function here.
|
|
51
|
+
export { parsePeopleLines } from './store.mjs';
|
|
52
|
+
|
|
53
|
+
const wikiJobs = (section = '') => [...String(section).matchAll(/\[\[Jobs\/([^\]|]+)(?:\|[^\]]*)?\]\]\s*(?:·\s*([^\n]*))?/g)].map((m) => ({ id: m[1].trim(), role: (m[2] || '').trim() }));
|
|
54
|
+
|
|
55
|
+
// ---------------- notes ----------------
|
|
56
|
+
const file = (id) => path.join(PEOPLE_DIR, `${path.basename(String(id)).replace(/\.md$/, '')}.md`);
|
|
57
|
+
const section = (text, h) => { const m = text.match(new RegExp(`^## ${h}\\s*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm')); return m ? m[1].trim() : ''; };
|
|
58
|
+
|
|
59
|
+
function ensureSection(text, heading) {
|
|
60
|
+
if (new RegExp(`^## ${heading}[ \\t]*$`, 'm').test(text)) return text;
|
|
61
|
+
// On a job note the section sits above the posting, with the person's other material.
|
|
62
|
+
if (/^## Job description[ \t]*$/m.test(text)) return text.replace(/^## Job description[ \t]*$/m, () => `## ${heading}\n\n## Job description`);
|
|
63
|
+
return `${text.trimEnd()}\n\n## ${heading}\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function row(fm, text) {
|
|
67
|
+
const jobs = new Map(store.listJobs().map((j) => [j.id, j]));
|
|
68
|
+
const threads = wikiJobs(section(text, 'Threads')).map((t) => { const j = jobs.get(t.id); return { id: t.id, role: t.role, title: j ? `${j.company} - ${j.title}` : t.id, status: j ? j.status : '' }; });
|
|
69
|
+
return {
|
|
70
|
+
id: fm._name, name: fm.name || fm._name, role: fm.role || 'other', company: fm.company || '', email: fm.email || '', links: fm.links || '',
|
|
71
|
+
created: fm.created || '', lastContact: fm.last_contact || '', threads, live: threads.filter((t) => ['applying', 'ready', 'applied', 'interviewing', 'offer'].includes(t.status)).length,
|
|
72
|
+
path: fm._file, obsidianUrl: `obsidian://open?path=${encodeURIComponent(fm._file)}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function listPeople() {
|
|
77
|
+
if (!fs.existsSync(PEOPLE_DIR)) return [];
|
|
78
|
+
return fs.readdirSync(PEOPLE_DIR).filter((f) => f.endsWith('.md')).map((f) => {
|
|
79
|
+
const p = path.join(PEOPLE_DIR, f);
|
|
80
|
+
const fm = readFrontmatter(p);
|
|
81
|
+
return fm ? row(fm, fs.readFileSync(p, 'utf8')) : null;
|
|
82
|
+
}).filter(Boolean).sort((a, b) => (b.lastContact || '').localeCompare(a.lastContact || '') || a.name.localeCompare(b.name));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getPerson(id) {
|
|
86
|
+
const p = file(id);
|
|
87
|
+
if (!fs.existsSync(p)) throw Object.assign(new Error(`No person named "${id}"`), { status: 404 });
|
|
88
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
89
|
+
return { ...row(readFrontmatter(p), text), about: section(text, 'About'), log: section(text, 'Log') };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The same person again: by email first, then by name at the same company, then by name alone. */
|
|
93
|
+
export function findPerson({ email = '', name = '', company = '' } = {}) {
|
|
94
|
+
const all = listPeople();
|
|
95
|
+
const e = String(email).toLowerCase().trim();
|
|
96
|
+
if (e) { const hit = all.find((p) => p.email.toLowerCase() === e); if (hit) return hit; }
|
|
97
|
+
const n = norm(name);
|
|
98
|
+
if (!n) return null;
|
|
99
|
+
return all.find((p) => norm(p.name) === n && (!company || norm(p.company) === norm(company))) || all.find((p) => norm(p.name) === n) || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createPerson({ name, role = 'other', company = '', email = '', links = '', about = '' } = {}) {
|
|
103
|
+
if (!name || !String(name).trim()) throw Object.assign(new Error('A person needs a name.'), { status: 400 });
|
|
104
|
+
if (!ROLES.includes(role)) throw Object.assign(new Error(`role must be one of ${ROLES.join(', ')}`), { status: 400 });
|
|
105
|
+
const existing = findPerson({ email, name, company });
|
|
106
|
+
if (existing) return getPerson(existing.id);
|
|
107
|
+
fs.mkdirSync(PEOPLE_DIR, { recursive: true });
|
|
108
|
+
let id = personId(name, company), n = 2;
|
|
109
|
+
while (fs.existsSync(file(id))) id = `${personId(name, company)} ${n++}`;
|
|
110
|
+
const fm = ['---', 'type: person', `name: ${yaml(String(name).trim())}`, `role: ${role}`, `company: ${yaml(company)}`, `email: ${yaml(email)}`, `links: ${yaml(links)}`, `created: ${isoDay()}`, 'last_contact: ', '---'].join('\n');
|
|
111
|
+
const body = `\n# ${String(name).trim()}\n\n## About\n${about ? about.trim() + '\n' : ''}\n## Threads\n\n## Log\n`;
|
|
112
|
+
fs.writeFileSync(file(id), fm + '\n' + body);
|
|
113
|
+
return getPerson(id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Put a person on a job note and the job on the person's Threads. Idempotent. */
|
|
117
|
+
export function attachPerson(jobId, personId_, { role = '', context = '' } = {}) {
|
|
118
|
+
const person = getPerson(personId_);
|
|
119
|
+
const job = store.getJob(jobId);
|
|
120
|
+
const r = ROLES.includes(role) ? role : person.role;
|
|
121
|
+
let text = fs.readFileSync(job.path, 'utf8');
|
|
122
|
+
if (!text.includes(`[[People/${person.id}`)) {
|
|
123
|
+
const line = `- [[People/${person.id}|${person.name}]]${r ? ` · ${r}` : ''}${person.email ? ` · ${person.email}` : ''}${context ? ` · ${String(context).replace(/\r?\n/g, ' ').trim()}` : ''}`;
|
|
124
|
+
text = store.appendUnderHeading(ensureSection(text, 'People'), 'People', line);
|
|
125
|
+
fs.writeFileSync(job.path, text);
|
|
126
|
+
store.cacheClear();
|
|
127
|
+
}
|
|
128
|
+
let ptext = fs.readFileSync(person.path, 'utf8');
|
|
129
|
+
if (!ptext.includes(`[[Jobs/${job.id}`)) {
|
|
130
|
+
ptext = store.appendUnderHeading(ptext, 'Threads', `- [[Jobs/${job.id}|${job.company} - ${job.title}]]${r ? ` · ${r}` : ''}`);
|
|
131
|
+
fs.writeFileSync(person.path, ptext);
|
|
132
|
+
}
|
|
133
|
+
return store.getJob(jobId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A dated line under the person's Log; last_contact moves forward, never back. */
|
|
137
|
+
export function logContact(personId_, { date = '', via = 'app', text = '' } = {}) {
|
|
138
|
+
const person = getPerson(personId_);
|
|
139
|
+
const day = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : isoDay();
|
|
140
|
+
let ptext = fs.readFileSync(person.path, 'utf8');
|
|
141
|
+
if (String(text).trim()) ptext = store.appendUnderHeading(ptext, 'Log', `- ${day} (${via}): ${String(text).replace(/\r?\n/g, ' ').trim()}`);
|
|
142
|
+
if (!person.lastContact || day > person.lastContact) ptext = store.replaceFrontmatterLine(ptext, 'last_contact', day);
|
|
143
|
+
fs.writeFileSync(person.path, ptext);
|
|
144
|
+
return getPerson(person.id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The people on one job note, as the note lists them. */
|
|
148
|
+
export function peopleOf(jobId) {
|
|
149
|
+
return store.parsePeopleLines(store.getJob(jobId).sections.people);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* From a confirmed mail item: the human who wrote becomes a person (or is recognised), goes on the note's
|
|
154
|
+
* People, and gets the email on their Log. Called by the mail check after the person confirms; nothing here
|
|
155
|
+
* runs on its own.
|
|
156
|
+
*/
|
|
157
|
+
export function recordFromMail(jobId, item, link) {
|
|
158
|
+
const who = fromMail(item);
|
|
159
|
+
if (!who) return null;
|
|
160
|
+
const person = createPerson({ name: who.name, role: who.role, company: who.company, email: who.email, about: `Wrote about ${item.role || 'an application'} at ${item.company}.` });
|
|
161
|
+
attachPerson(jobId, person.id, { role: person.role, context: `${item.kind}, ${item.date || 'undated'}` });
|
|
162
|
+
logContact(person.id, { date: item.date, via: 'mail', text: `${item.kind}: ${item.gist}${link ? ` [message](${link})` : ''}` });
|
|
163
|
+
return person.id;
|
|
164
|
+
}
|