@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/app/README.md +22 -0
  5. package/app/dist/assets/index-DzGgV6NF.css +1 -0
  6. package/app/dist/assets/index-QteuouKv.js +77 -0
  7. package/app/dist/favicon.svg +1 -0
  8. package/app/dist/index.html +16 -0
  9. package/app/server/cover-letter.mjs +243 -0
  10. package/app/server/index.mjs +113 -0
  11. package/app/server/mail-check.mjs +234 -0
  12. package/app/server/mcp.mjs +129 -0
  13. package/app/server/people.mjs +164 -0
  14. package/app/server/store.mjs +1131 -0
  15. package/app/server/tailored-resume.mjs +213 -0
  16. package/cli.mjs +205 -0
  17. package/package.json +74 -0
  18. package/run.cmd +10 -0
  19. package/run.mjs +201 -0
  20. package/run.sh +12 -0
  21. package/samples/vault/Jobs/Basalt Systems - Senior Design Engineer, Growth (1010).md +56 -0
  22. package/samples/vault/Jobs/Brightline Studio - Design Engineer (1005).md +51 -0
  23. package/samples/vault/Jobs/Copperleaf - Principal Design Engineer (1006).md +53 -0
  24. package/samples/vault/Jobs/Example Studio - Senior Design Engineer (1013).md +52 -0
  25. package/samples/vault/Jobs/Fjord Analytics - Staff Frontend Engineer, Platform (1003).md +58 -0
  26. package/samples/vault/Jobs/Halcyon Robotics - Senior UX Engineer (1004).md +52 -0
  27. package/samples/vault/Jobs/Lumen Health - Design Systems Engineer (1002).md +51 -0
  28. package/samples/vault/Jobs/Meridian Pay - Staff Design Engineer (1009).md +67 -0
  29. package/samples/vault/Jobs/Northwind Labs - Staff Design Engineer, Design Systems (1000).md +57 -0
  30. package/samples/vault/Jobs/Orbital Software - Senior Design Engineer (1001).md +52 -0
  31. package/samples/vault/Jobs/Quill & Co - Design Engineer, Editor (1008).md +55 -0
  32. package/samples/vault/Jobs/Signalfire Design - Design Engineer (1012).md +53 -0
  33. package/samples/vault/Jobs/Tessellate - Senior Frontend Engineer, Design Systems (1007).md +52 -0
  34. package/samples/vault/Jobs/Verdant - UX Engineer (1011).md +51 -0
  35. package/samples/vault/Logs/2026-09-16.md +13 -0
  36. package/samples/vault/Logs/2026-09-23.md +13 -0
  37. package/samples/vault/People/Lee Example (Fjord Analytics).md +22 -0
  38. package/samples/vault/People/Priya Example (Meridian Pay).md +23 -0
  39. package/samples/vault/People/Sam Example (Northwind Labs).md +20 -0
  40. package/samples/vault/Profile/Positioning.md +16 -0
  41. package/samples/vault/Profile/Profile.md +41 -0
  42. package/samples/vault/Profile/Resume.md +32 -0
  43. package/samples/vault/Profile/Voice.md +15 -0
  44. package/samples/vault/README.md +11 -0
  45. package/samples/vault/Targets/Companies.md +318 -0
  46. package/samples/vault/Targets/Search Criteria.md +234 -0
  47. package/samples/vault/_Home.md +54 -0
  48. package/scraper/config.mjs +122 -0
  49. package/scraper/health.mjs +50 -0
  50. package/scraper/import-link.mjs +281 -0
  51. package/scraper/profile.mjs +186 -0
  52. package/scraper/rescore.mjs +236 -0
  53. package/scraper/resume-sync.mjs +224 -0
  54. package/scraper/resume.mjs +65 -0
  55. package/scraper/score.mjs +114 -0
  56. package/scraper/sources-email.mjs +242 -0
  57. package/scraper/sources-extra.mjs +558 -0
  58. package/scraper/sources-sites.mjs +280 -0
  59. package/scraper/sources.mjs +264 -0
  60. package/scraper/starter/companies-table.md +306 -0
  61. package/scraper/starter/criteria.json +56 -0
  62. package/scraper/starter/profile.md +38 -0
  63. package/scraper/vault.mjs +212 -0
@@ -0,0 +1,186 @@
1
+ // Profile folder lifecycle: create it, import a resume, report what is still missing, save the interview's output.
2
+ // Shared by the CLI (cli.mjs), the API server and the MCP server.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { P, VAULT, rememberProfileDir, loadCriteria } from './config.mjs';
7
+ import { extractText } from './resume.mjs';
8
+
9
+ const STARTER = fileURLToPath(new URL('./starter/', import.meta.url));
10
+ const today = () => new Date().toISOString().slice(0, 10);
11
+
12
+ /** Create the folder layout and starter notes in `dir` (default: the resolved profile dir). Never overwrites. */
13
+ export function initProfile(dir = VAULT) {
14
+ const made = [];
15
+ const mk = (rel, content) => {
16
+ const file = path.join(dir, rel);
17
+ fs.mkdirSync(path.dirname(file), { recursive: true });
18
+ if (fs.existsSync(file)) return;
19
+ fs.writeFileSync(file, content);
20
+ made.push(rel);
21
+ };
22
+ for (const d of ['Profile', 'Targets', 'Jobs', 'Logs', '.tekjobs']) fs.mkdirSync(path.join(dir, d), { recursive: true });
23
+ mk('Profile/Profile.md', fs.readFileSync(path.join(STARTER, 'profile.md'), 'utf8').replace('{{date}}', today()));
24
+ mk('Targets/Search Criteria.md', criteriaNote(fs.readFileSync(path.join(STARTER, 'criteria.json'), 'utf8').trim()));
25
+ mk('Targets/Companies.md', companiesNote(fs.readFileSync(path.join(STARTER, 'companies-table.md'), 'utf8')));
26
+ mk('README.md', readme(dir));
27
+ rememberProfileDir(dir);
28
+ return { dir, made };
29
+ }
30
+
31
+ /** Copy a resume into Profile/ and write its extracted text beside it as `Resume - Source.md`. */
32
+ export async function importResume(file, dir = VAULT) {
33
+ if (!fs.existsSync(file)) throw Object.assign(new Error(`No such file: ${file}`), { status: 404 });
34
+ const text = await extractText(file);
35
+ if (text.trim().length < 200) throw Object.assign(new Error(`The resume text came out at ${text.trim().length} characters; a resume is at least 200. If it is a scanned PDF, export it as text or DOCX first.`), { status: 422 });
36
+ const profileDir = path.join(dir, 'Profile');
37
+ fs.mkdirSync(profileDir, { recursive: true });
38
+ const dest = path.join(profileDir, `Resume - Original${path.extname(file).toLowerCase()}`);
39
+ if (path.resolve(file) !== path.resolve(dest)) fs.copyFileSync(file, dest);
40
+ const md = `---\ntype: resume-source\nimported: ${today()}\noriginal: ${JSON.stringify(path.basename(file))}\n---\n# Resume, as imported\n\n> Text extracted from the original by \`tekjobs resume\`. The interview reads this; the polished resume drafts live in their own notes.\n\n${text}\n`;
41
+ fs.writeFileSync(path.join(profileDir, 'Resume - Source.md'), md);
42
+ return { original: dest, source: path.join(profileDir, 'Resume - Source.md'), chars: text.length };
43
+ }
44
+
45
+ /** What the onboarding still needs. Drives the CLI, the app's Onboarding screen and the MCP status tool. */
46
+ export function onboardingStatus(dir = VAULT) {
47
+ const exists = (rel) => fs.existsSync(path.join(dir, rel));
48
+ const read = (rel) => { try { return fs.readFileSync(path.join(dir, rel), 'utf8'); } catch { return ''; } };
49
+ const profile = read('Profile/Profile.md');
50
+ const profileFilled = !!profile && !/^status: draft$/m.test(profile) && !/\(The interview fills this in/.test(profile);
51
+ let criteria = null; let criteriaFilled = false;
52
+ try { criteria = loadCriteria(); criteriaFilled = Object.keys(criteria.titleTerms || {}).length > 0; } catch { /* absent or invalid */ }
53
+ const jobs = exists('Jobs') ? fs.readdirSync(path.join(dir, 'Jobs')).filter((f) => f.endsWith('.md')).length : 0;
54
+ const steps = [
55
+ { id: 'folder', label: 'Profile folder exists', done: exists('Targets/Search Criteria.md') && exists('Targets/Companies.md'), how: 'tekjobs init [dir]' },
56
+ { id: 'resume', label: 'Resume imported', done: exists('Profile/Resume - Source.md') || (exists('Profile') && fs.readdirSync(path.join(dir, 'Profile')).some((f) => /^Resume.*\.md$/i.test(f))), how: 'tekjobs resume <file.pdf|docx|md>' },
57
+ { id: 'profile', label: 'Profile written by the interview', done: profileFilled, how: 'Run the interview: in Claude Code (or any MCP client connected to tekjobs), ask it to call onboarding_materials and interview you.' },
58
+ { id: 'criteria', label: 'Search criteria filled (title terms set)', done: criteriaFilled, how: 'The interview writes them; or edit Targets/Search Criteria.md.' },
59
+ { id: 'scan', label: 'First scan has run', done: jobs > 0, how: 'tekjobs scan, or the Runs screen.' },
60
+ ];
61
+ return { dir, steps, complete: steps.every((s) => s.done), jobs, profilePath: path.join(dir, 'Profile', 'Profile.md') };
62
+ }
63
+
64
+ /** Everything the interviewing LLM needs in one call: resume text, current notes, and the script to follow. */
65
+ export function onboardingMaterials(dir = VAULT) {
66
+ const read = (rel) => { try { return fs.readFileSync(path.join(dir, rel), 'utf8'); } catch { return ''; } };
67
+ let criteriaRaw = '';
68
+ const m = read('Targets/Search Criteria.md').match(/```json\s*\n([\s\S]*?)\n```/); if (m) criteriaRaw = m[1];
69
+ return {
70
+ status: onboardingStatus(dir),
71
+ resumeText: read('Profile/Resume - Source.md').replace(/^---[\s\S]*?---\n/, ''),
72
+ profile: read('Profile/Profile.md'),
73
+ criteria: criteriaRaw,
74
+ script: INTERVIEW_SCRIPT,
75
+ };
76
+ }
77
+
78
+ export function saveProfile(markdown, dir = VAULT) {
79
+ if (!markdown || markdown.trim().length < 100) throw Object.assign(new Error('Profile is too short to save; write the full note.'), { status: 400 });
80
+ let md = markdown.replace(/^status: draft$/m, 'status: interviewed');
81
+ if (!/^---\n/.test(md)) md = `---\ntype: profile\nupdated: ${today()}\nstatus: interviewed\n---\n${md}`;
82
+ md = md.replace(/^updated: .*$/m, `updated: ${today()}`);
83
+ const file = path.join(dir, 'Profile', 'Profile.md');
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
85
+ if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dir, 'Profile', `Profile.before-${today()}.md`));
86
+ fs.writeFileSync(file, md);
87
+ return { saved: file };
88
+ }
89
+
90
+ /** Fetch a public page (portfolio, GitHub, LinkedIn export is a file, not a URL) as text for the interview to summarize. */
91
+ export async function fetchLink(url, { maxChars = 20000 } = {}) {
92
+ const u = new URL(url);
93
+ if (!/^https?:$/.test(u.protocol)) throw Object.assign(new Error('Only http(s) URLs.'), { status: 400 });
94
+ // A link that does not resolve should say so in words the interviewer can pass on, not "fetch failed".
95
+ let res;
96
+ try { res = await fetch(u, { headers: { 'user-agent': 'TekJobs/1.0 (onboarding; fetching a link the user gave)', accept: 'text/html,text/plain,application/json' }, signal: AbortSignal.timeout(20000), redirect: 'follow' }); }
97
+ catch (e) { const why = e.name === 'TimeoutError' ? 'no answer in 20 seconds' : (e.cause && e.cause.code) || e.message; throw Object.assign(new Error(`Could not fetch ${u.hostname}: ${why}. Ask the person to check the link, or to paste the page's text instead.`), { status: 502 }); }
98
+ const html = await res.text();
99
+ const text = html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, '').replace(/<\/(p|div|li|h[1-6]|tr|section|article)>/gi, '\n').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/[ \t]+/g, ' ').replace(/\n\s*\n+/g, '\n\n').trim();
100
+ return { url, status: res.status, chars: text.length, text: text.slice(0, maxChars), truncated: text.length > maxChars };
101
+ }
102
+
103
+ const INTERVIEW_SCRIPT = `You are onboarding a job seeker into TekJobs. Goal: write Profile/Profile.md and the criteria JSON so the daily scan finds the right roles for THIS person. Be direct and specific; no filler.
104
+
105
+ 1. Read resumeText. Extract: name, location, current role and dates, the last three roles, stack and tools, quantified outcomes (numbers, scale, team size), and the through-line of the career. If the resume is missing, ask the user to run \`tekjobs resume <file>\` or paste it.
106
+ 2. Ask the user, in ONE message, only what the resume cannot tell you (skip anything already clear):
107
+ - Target titles (2 to 6). Offer a guess from the resume and let them edit it.
108
+ - Seniority they are applying at (senior / staff / lead / principal / manager).
109
+ - Work mode: remote only, hybrid, on-site; and where they live.
110
+ - Pay floor (base), and a stretch band under it they would still look at.
111
+ - Must-have keywords (stack, domain) and hard exclusions (titles, industries, company types).
112
+ - Company stage or size preference, if any.
113
+ - Links: portfolio, GitHub, personal site. Fetch each with fetch_link and fold what you learn into the profile. LinkedIn: ask for their data-export ZIP contents or a pasted summary; never scrape a LinkedIn page.
114
+ - Earliest start date.
115
+ 3. Write Profile/Profile.md using the existing note's headings (Basics · What you are, in three sentences · Target roles table with tiers A/B · Constraints & preferences · Proof points · Documents). Proof points are one line each, with numbers. Call save_profile with the full markdown.
116
+ 4. Build the criteria from the current JSON (keep every key). Set:
117
+ - titleTerms: exact lowercase substrings that appear in real job titles for the target roles, weighted 40 for exact-fit titles down to ~20 for adjacent ones. Include common variants ("front-end", "frontend", "front end").
118
+ - titleExclude: add the user's hard exclusions as lowercase substrings.
119
+ - descTerms: 15 to 30 stack/domain words with weights 2 to 6.
120
+ - salary.minAnnual and salary.stretchAnnual as integers (or null if the user declines).
121
+ - location.requireRemote true only if the user said remote only; add their metro to bayAreaTerms with bayAreaBoost 12 if hybrid there is acceptable.
122
+ Call set_criteria with the full JSON string. It is validated before writing.
123
+ 5. Call run_scan with dry=true and poll scan_status every few seconds until running is false (two to three minutes). A dry run writes no notes, so do not use search_jobs yet: call scan_preview limit=15 and show the user the top matches with score, pay band and one reason each. Ask whether the list looks right. Adjust the criteria once if it does not (set_criteria, then another dry run_scan and scan_preview). When it does, call run_scan with dry=false and poll scan_status until it finishes; that run writes the notes, and search_jobs and the app's Today page work from then on.
124
+ 6. Finish by telling the user where things live: Profile/Profile.md, Targets/Search Criteria.md, Jobs/. Remind them the daily scan runs on its own from here.`;
125
+
126
+ function criteriaNote(json) {
127
+ return `---
128
+ type: config
129
+ updated: ${today()}
130
+ ---
131
+ # Search Criteria
132
+
133
+ > **The scraper reads the JSON block below.** The onboarding interview fills it from your resume and your answers; after that, edit it here. Weights are additive points. A job needs \`minScore\` to get its own note in \`Jobs/\`.
134
+
135
+ How scoring works:
136
+ - **titleTerms** — best single match in the job title counts fully, each extra match adds 5. Empty until the interview runs.
137
+ - **titleExclude** — any hit in the title drops the job entirely.
138
+ - **noTitleMatchPenalty** — a posting whose title matches nothing in \`titleTerms\` takes this hit, so location and recency alone can't carry it over the bar.
139
+ - **descTerms** — each term found in the description adds its weight (capped at \`descCap\`).
140
+ - **seniority** — senior/staff/lead/principal adds; junior/intern subtracts.
141
+ - **location** — remote adds; with \`requireRemote\` on, anything not remote takes \`notRemotePenalty\` and drops out. Clearly non-US-only listings subtract heavily.
142
+ - **salary** — a stated range at or above \`minAnnual\` adds; between \`stretchAnnual\` and \`minAnnual\` is the stretch band (small penalty, kept visible); below subtracts. No stated range is neutral.
143
+ - **recency** — posted within 7 days adds, over 90 days subtracts.
144
+ - **openSources** — aggregator feeds that need no company slug. Set one to \`false\` to drop it. \`wellfound\` and \`builtin\` read those sites' remote listing pages (no API), searched by design-engineering roles; both lose to the same job from its ATS when both turn up.
145
+ - **email** — job alert emails you save into the \`Inbox\` folder of this profile, as \`.eml\`. This is how the boards with no public API get in: LinkedIn, Indeed, Otta and Wellfound all send alerts, and an email in your own mailbox is your own data. Nothing contacts those sites — it reads files you put there, and the links are for you to open. Links to Greenhouse, Lever, Ashby and Workday are picked up from any sender. Rows are thin (a title, a company, usually a location), so they score below the same job from an ATS and lose to it when both turn up.
146
+ - **adzuna** — the one source that needs a key. Set \`ADZUNA_APP_ID\` and \`ADZUNA_APP_KEY\` in the environment (free at developer.adzuna.com), turn \`openSources.adzuna\` on, and it searches your top \`titleTerms\` in each country under \`adzuna.countries\`. It reaches listings that never appear on a company ATS board, but its descriptions come back as snippets, so those rows score lower than the same job fetched from its ATS.
147
+ - **usajobs** — every US federal posting. Set \`USAJOBS_API_KEY\` and \`USAJOBS_EMAIL\` (free at developer.usajobs.gov; the email is the one the key is registered to) and turn \`openSources.usajobs\` on. Unlike Adzuna it returns the whole description, so these rows score on equal footing, and it carries a real application deadline.
148
+
149
+ \`\`\`json
150
+ ${json}
151
+ \`\`\`
152
+ `;
153
+ }
154
+
155
+ function companiesNote(table) {
156
+ return `---
157
+ type: config
158
+ updated: ${today()}
159
+ ---
160
+ # Company Watchlist
161
+
162
+ > **The scraper reads the table below.** Add a row to start watching a company; delete a row to stop. \`ATS\` names the platform; \`Slug\` is the company's board token on that platform (for Greenhouse, Lever, Ashby: the part of the URL after \`boards.greenhouse.io/\`, \`jobs.lever.co/\`, or \`jobs.ashbyhq.com/\`). Tier is yours (A = dream, B = strong, C = fine). The scraper rewrites the \`Status\` column on every run: \`ok · N\` or \`bad-slug\`.
163
+
164
+ Platforms: \`greenhouse\`, \`lever\`, \`ashby\`, \`workday\` (slug \`host/tenant/site\`), \`rippling\`, \`smartrecruiters\`, \`workable\`, \`bamboohr\`, \`breezy\`, \`personio\`, \`teamtailor\`, \`eightfold\` (slug \`host/domain\`), and single-company fetchers \`atlassian\`, \`github\`, \`spotify\`, \`amazon\`, \`google\`, \`apple\` (slug \`-\`; Google and Apple are read from their career pages, keyword-searched, mostly on-site roles). Aggregators with no slug are toggled in [[Targets/Search Criteria]] under \`openSources\`.
165
+
166
+ This starter list is the community registry: tech companies whose boards answer without a login. Cut it to what you care about, or add your own.
167
+
168
+ ${table}`;
169
+ }
170
+
171
+ function readme(dir) {
172
+ return `# TekJobs profile folder
173
+
174
+ This folder is your job search: profile, resume, criteria, watchlist, one note per matched job, and logs. It is plain markdown. Open it in Obsidian or any editor; the TekJobs app and its MCP server read and write the same files.
175
+
176
+ - \`Profile/Profile.md\` — who you are and what you want (written by the onboarding interview)
177
+ - \`Profile/Resume - Source.md\` — your resume as text
178
+ - \`Targets/Search Criteria.md\` — how postings are scored (JSON block)
179
+ - \`Targets/Companies.md\` — which boards are watched
180
+ - \`Jobs/\` — one note per match; change \`status:\` to move it through the pipeline
181
+ - \`Logs/\` — what each scan found
182
+ - \`.tekjobs/\` — scan state (safe to delete; the next scan rebuilds it, existing notes are never overwritten)
183
+
184
+ Folder: ${dir}
185
+ `;
186
+ }
@@ -0,0 +1,236 @@
1
+ // Apply a change in scoring weights to notes that already exist.
2
+ //
3
+ // Scores are written into a note's frontmatter when the note is created and never recomputed, so a change to
4
+ // the criteria only reaches matches found after it. This brings the existing ones forward.
5
+ //
6
+ // It does NOT re-run scoreJob against the note. It cannot: a note stores at most `maxDescriptionChars` of the
7
+ // posting, so on this vault 194 of 338 open notes hold less text than the scan scored them on, and rescoring
8
+ // from the note would quietly lower every one of them. Instead it recomputes only the three components that
9
+ // are exactly derivable from frontmatter — title, recency and pay — and applies the difference to the stored
10
+ // score. Everything else (description keywords, seniority, location) is unchanged by a weight edit and
11
+ // cancels out, so the arithmetic is exact rather than approximate.
12
+ //
13
+ // Recency is measured at the age the posting had when it was found, not today's age. The stored score is a
14
+ // snapshot taken at discovery; re-dating it would make every score drift daily and mean something different
15
+ // from the number beside it in an older note.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import { P, loadCriteria } from './config.mjs';
20
+ import { scoreJob } from './score.mjs';
21
+
22
+ /**
23
+ * A short fingerprint of every criteria key the scorer reads.
24
+ *
25
+ * Stamped into a note as `weights:` so a note knows which rules produced its score. Without it a rescore has
26
+ * no way to tell an already-adjusted note from an untouched one, and running the command twice would apply
27
+ * the same delta twice — which is exactly the kind of quiet corruption this vault's never-rewrite rule
28
+ * exists to prevent.
29
+ */
30
+ export function weightsFingerprint(c = {}) {
31
+ const relevant = {
32
+ titleTerms: c.titleTerms, titleExclude: c.titleExclude, titleExtraPer: c.titleExtraPer,
33
+ titleExtraCap: c.titleExtraCap, noTitleMatchPenalty: c.noTitleMatchPenalty,
34
+ seniority: c.seniority, descTerms: c.descTerms, descCap: c.descCap,
35
+ location: c.location, salary: c.salary, recency: c.recency,
36
+ };
37
+ // Keys sorted at every level. (An array replacer in JSON.stringify whitelists keys at every depth too, so
38
+ // the earlier `JSON.stringify(relevant, topLevelKeys)` dropped nearly all the nested values: a criteria
39
+ // set with a different pay floor or different title weights hashed the same. Found 2026-09-22 when a
40
+ // preset with a 180k floor reported itself identical to the 220k active set.)
41
+ const stable = JSON.stringify(sortKeys(relevant));
42
+ return crypto.createHash('sha1').update(stable).digest('hex').slice(0, 8);
43
+ }
44
+ const sortKeys = (v) => (Array.isArray(v) ? v.map(sortKeys) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v);
45
+
46
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
47
+
48
+ /** Title points under one set of weights. `extraCap` null means the old, uncapped behaviour. */
49
+ export function titlePoints(title = '', c = {}, extraCap) {
50
+ const hits = Object.entries(c.titleTerms || {})
51
+ .filter(([t]) => title.toLowerCase().includes(t.toLowerCase()))
52
+ .sort((a, b) => b[1] - a[1]);
53
+ if (!hits.length) return c.noTitleMatchPenalty ?? -40;
54
+ const per = c.titleExtraPer ?? 5;
55
+ const extra = (hits.length - 1) * per;
56
+ return hits[0][1] + (extraCap == null ? extra : Math.min(extra, extraCap));
57
+ }
58
+
59
+ /** Recency points for an age in days. `withDays2` false reproduces the old four-bucket scale. */
60
+ export function recencyPoints(days, r = {}, withDays2 = true) {
61
+ if (!Number.isFinite(days)) return 0;
62
+ if (withDays2 && days <= 2) return r.days2 ?? r.days7 ?? 0;
63
+ if (days <= 7) return r.days7 ?? 0;
64
+ if (days <= 30) return r.days30 ?? 0;
65
+ if (days <= 90) return r.days90 ?? 0;
66
+ return r.older ?? 0;
67
+ }
68
+
69
+ /** Pay points. `scaled` false reproduces the old flat bonus for clearing the floor. */
70
+ export function payPoints(salaryMax, S = {}, scaled = true) {
71
+ if (!S.minAnnual || !salaryMax) return 0;
72
+ if (salaryMax >= S.minAnnual) {
73
+ const base = S.meetsBonus ?? 10;
74
+ if (!scaled) return base;
75
+ const over = Math.max(0, salaryMax - S.minAnnual);
76
+ return base + Math.min(Math.round((over / 10000) * (S.abovePer10k ?? 1)), S.aboveCap ?? 15);
77
+ }
78
+ if (S.stretchAnnual && salaryMax >= S.stretchAnnual) return S.stretchPenalty ?? -8;
79
+ return S.belowPenalty ?? -30;
80
+ }
81
+
82
+ /**
83
+ * @param {object} opts
84
+ * @param {boolean} opts.dry report without writing
85
+ * @param {object} opts.before the criteria the existing scores were computed under
86
+ * @param {object} [opts.now] the criteria to move to; the active note when omitted. A preview passes a
87
+ * proposed set here with dry=true and reads `changes` without writing anything.
88
+ */
89
+ export function rescore({ dry = false, before = null, stampOnly = false, now: proposed = null } = {}) {
90
+ const now = proposed || loadCriteria();
91
+ const fingerprint = weightsFingerprint(now);
92
+
93
+ // Record the current weights against every note without touching a score. For a vault whose notes were
94
+ // brought forward some other way, so the first real rescore does not adjust them a second time.
95
+ if (stampOnly) {
96
+ const files = fs.readdirSync(P.jobs).filter((f) => f.endsWith('.md'));
97
+ let stamped = 0;
98
+ for (const f of files) {
99
+ const file = path.join(P.jobs, f);
100
+ const text = fs.readFileSync(file, 'utf8');
101
+ if (new RegExp(`^weights: ${fingerprint}$`, 'm').test(text)) continue;
102
+ if (!dry) fs.writeFileSync(file, stampWeights(text, fingerprint));
103
+ stamped++;
104
+ }
105
+ return { total: files.length, changed: stamped, changes: [], fingerprint, stampOnly: true };
106
+ }
107
+
108
+ // `before` is required, and deliberately has no default.
109
+ //
110
+ // A note records the score it was given but not the weights that produced it, so nothing here can work out
111
+ // what has already been applied. Guessing the previous weights from the current ones would make the command
112
+ // silently double-apply on a second run and quietly corrupt every score in the vault. Until a note carries
113
+ // a fingerprint of the weights that scored it, the caller has to say what changed.
114
+ if (!before) {
115
+ throw Object.assign(
116
+ new Error('rescore needs the previous criteria: a note does not record which weights scored it, so running without them would double-apply. Pass --from <old-criteria.json>.'),
117
+ { status: 400 },
118
+ );
119
+ }
120
+ const old = before;
121
+
122
+ const files = fs.readdirSync(P.jobs).filter((f) => f.endsWith('.md'));
123
+ const changes = [];
124
+ for (const f of files) {
125
+ const file = path.join(P.jobs, f);
126
+ let text = fs.readFileSync(file, 'utf8');
127
+ const fm = (k) => (text.match(new RegExp('^' + k + ': (.*)$', 'm')) || [, ''])[1].replace(/^"|"$/g, '').trim();
128
+ const stored = num(fm('score'));
129
+ if (!stored) continue;
130
+ // Already at these weights: nothing to do, and re-applying the delta would be the bug.
131
+ if (fm('weights') === fingerprint) continue;
132
+
133
+ const title = fm('title');
134
+ const posted = fm('posted');
135
+ const found = fm('found');
136
+ // The age the posting had when the scan scored it, not its age today.
137
+ const ageAtScan = posted && found ? (Date.parse(found) - Date.parse(posted)) / 864e5 : NaN;
138
+ const salaryMax = num(fm('salary_max'));
139
+
140
+ const delta =
141
+ (titlePoints(title, now, now.titleExtraCap ?? 10) - titlePoints(title, old, old.titleExtraCap)) +
142
+ (recencyPoints(ageAtScan, now.recency, true) - recencyPoints(ageAtScan, old.recency, false)) +
143
+ (payPoints(salaryMax, now.salary, true) - payPoints(salaryMax, old.salary, false));
144
+
145
+ const updated = Math.round(stored + delta);
146
+ if (delta) changes.push({ file: f, company: fm('company'), title, stored, updated, delta });
147
+ if (!dry) {
148
+ // The stamp is written even when the delta is zero: the note is at these weights either way, and
149
+ // recording that is what makes a second run a no-op.
150
+ fs.writeFileSync(file, stampWeights(text.replace(/^score: .*$/m, `score: ${updated}`), fingerprint));
151
+ }
152
+ }
153
+ return { total: files.length, changed: changes.length, changes, fingerprint };
154
+ }
155
+
156
+ /**
157
+ * Score every note again from what the note holds, under the current criteria (or a proposed set).
158
+ *
159
+ * This is the rescore for a rule change the delta method cannot express: location terms, penalties,
160
+ * description keywords, seniority words. The job is rebuilt from the note's frontmatter and its Job description
161
+ * section and put through scoreJob with recency judged at the day the note was found. One component is
162
+ * carried over rather than recomputed: description points on a note whose description was truncated at
163
+ * write time, because the scan saw more text than the note keeps and re-reading the shorter text would lower
164
+ * the score for no change in the rules. Notes already stamped with the target weights are left alone.
165
+ *
166
+ * Writes, when not dry: `score:`, `pay_band:`, `weights:`, the "Why it matched" section, and a Status log line
167
+ * on every note whose score moved. Nothing else in the note is touched.
168
+ */
169
+ export function rescoreFull({ dry = false, now: proposed = null } = {}) {
170
+ const criteria = proposed || loadCriteria();
171
+ const fingerprint = weightsFingerprint(criteria);
172
+ const files = fs.readdirSync(P.jobs).filter((f) => f.endsWith('.md'));
173
+ const changes = [];
174
+ let seen = 0, skipped = 0;
175
+ for (const f of files) {
176
+ const file = path.join(P.jobs, f);
177
+ let text = fs.readFileSync(file, 'utf8');
178
+ const fm = (k) => (text.match(new RegExp('^' + k + ': (.*)$', 'm')) || [, ''])[1].replace(/^"|"$/g, '').trim();
179
+ if (fm('weights') === fingerprint) { skipped++; continue; }
180
+ if (!/^score: /m.test(text)) continue;
181
+ seen++;
182
+ const section = (h) => (text.match(new RegExp(`^## ${h}[ \\t]*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm')) || [, ''])[1];
183
+ const descRaw = section('Job description');
184
+ const truncated = /^>.*truncated/m.test(descRaw);
185
+ const description = descRaw.replace(/^>.*$/gm, '').trim();
186
+ const stored = num(fm('score'));
187
+ const found = fm('found');
188
+ const job = {
189
+ title: fm('title'), company: fm('company'), location: fm('location'), remote: fm('remote') === 'true',
190
+ descriptionText: description, salary: fm('salary'), salaryMax: num(fm('salary_max')), posted: fm('posted') || null, department: fm('department'),
191
+ };
192
+ // The scan runs in the morning; a date-only `found` would put it at midnight and shift a two-day-old
193
+ // posting into the fresher tier, so the scan's own hour is assumed.
194
+ const scored = scoreJob(job, criteria, { now: found ? Date.parse(found) + 14.5 * 3600e3 : Date.now() });
195
+ let score = scored.score;
196
+ let reasons = scored.reasons;
197
+ // A note that holds less than the scan read (truncated, or no description at all, as some boards give
198
+ // none through their API) keeps the description points the scan awarded.
199
+ if ((truncated || !description) && !scored.excluded) {
200
+ const oldLine = (section('Why it matched').match(/^- (description \+(\d+):.*)$/m) || []);
201
+ const newLine = reasons.findIndex((r) => r.startsWith('description +'));
202
+ const newPts = newLine >= 0 ? num((reasons[newLine].match(/\+(\d+)/) || [])[1]) : 0;
203
+ if (oldLine[1]) {
204
+ score = Math.round(score - newPts + num(oldLine[2]));
205
+ const kept = `${oldLine[1]} (kept from the scan; the note holds part of the posting)`;
206
+ if (newLine >= 0) reasons[newLine] = kept; else reasons.splice(Math.min(3, reasons.length), 0, kept);
207
+ }
208
+ }
209
+ const delta = score - stored;
210
+ if (delta) changes.push({ file: f, company: job.company, title: job.title, stored, updated: score, delta, status: fm('status') });
211
+ if (!dry) {
212
+ text = text.replace(/^score: .*$/m, `score: ${score}`);
213
+ if (/^pay_band: /m.test(text)) text = text.replace(/^pay_band: .*$/m, `pay_band: ${scored.payBand || 'unknown'}`);
214
+ text = stampWeights(text, fingerprint);
215
+ const why = `## Why it matched\n${reasons.map((r) => `- ${r}`).join('\n')}\n\n`;
216
+ text = /^## Why it matched[ \t]*\n/m.test(text) ? text.replace(/^## Why it matched[ \t]*\n[\s\S]*?(?=^## |(?![\s\S]))/m, () => why) : text;
217
+ if (delta) text = appendUnder(text, 'Status log', `- ${new Date().toISOString().slice(0, 10)} — rescored ${stored} → **${score}** with criteria ${fingerprint} (via cli)`);
218
+ fs.writeFileSync(file, text);
219
+ }
220
+ }
221
+ changes.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
222
+ return { total: files.length, considered: seen, alreadyCurrent: skipped, changed: changes.length, changes, fingerprint, full: true };
223
+ }
224
+
225
+ function appendUnder(text, heading, line) {
226
+ const re = new RegExp(`(^## ${heading}[ \\t]*\\n)([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm');
227
+ if (!re.test(text)) return text.trimEnd() + `\n\n## ${heading}\n${line}\n`;
228
+ return text.replace(re, (all, h, content) => { const body = content.trim(); return `${h}${body ? body + '\n' : ''}${line}\n\n`; });
229
+ }
230
+
231
+ /** Set or replace the `weights:` frontmatter line, without disturbing anything else. */
232
+ function stampWeights(text, fingerprint) {
233
+ return /^weights: .*$/m.test(text)
234
+ ? text.replace(/^weights: .*$/m, `weights: ${fingerprint}`)
235
+ : text.replace(/^(score: .*)$/m, `$1\nweights: ${fingerprint}`);
236
+ }
@@ -0,0 +1,224 @@
1
+ // Keep the resume note application drafts read in step with the resume the person actually maintains.
2
+ //
3
+ // Drafts are written from Profile/Resume.md. The person edits their resume somewhere else — a Google Doc, a
4
+ // portfolio page, a .docx — and nothing noticed when the two drifted. The first time it happened on a real
5
+ // vault, an application packet went on quoting "three of five evals in production" after that line had been
6
+ // removed from the resume, and would have contradicted the person's own portfolio if it had been sent.
7
+ //
8
+ // So a sync does two jobs. It rewrites the note from the source, and it reports what changed — including any
9
+ // application packet that still quotes a claim the resume no longer makes. The second is the point.
10
+ //
11
+ // No credentials, no API. A source is a URL anyone can read, or a local file:
12
+ // - a Google Doc shared as "anyone with the link can view" (fetched through its markdown export)
13
+ // - any public page, e.g. a portfolio's /resume
14
+ // - a local .md, .txt, .docx or .pdf — including a Doc downloaded with File > Download
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { P, VAULT, CONFIG_FILE, HOME_DIR } from './config.mjs';
18
+ import { htmlToText } from './sources.mjs';
19
+ import { extractText } from './resume.mjs';
20
+
21
+ /** The canonical resume note. Generic on purpose: nothing about it should assume whose resume it is. */
22
+ export const RESUME_NOTE = 'Profile/Resume.md';
23
+ /** Where earlier versions of this project kept it. Read as a fallback, never written. */
24
+ export const LEGACY_RESUME_NOTES = ['Profile/Resume - Design Engineer (Staff).md', 'Profile/Resume - Master.md'];
25
+
26
+ const today = () => new Date().toISOString().slice(0, 10);
27
+
28
+ // ---------------- the remembered source ----------------
29
+
30
+ function readConfig() {
31
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
32
+ }
33
+ export function rememberedSource() {
34
+ return readConfig().resumeSource || '';
35
+ }
36
+ function rememberSource(source) {
37
+ fs.mkdirSync(HOME_DIR, { recursive: true });
38
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...readConfig(), resumeSource: source }, null, 2));
39
+ }
40
+
41
+ // ---------------- fetching ----------------
42
+
43
+ /** A Google Docs editing URL, turned into its markdown export URL. Anything else returns null. */
44
+ export function docExportUrl(url) {
45
+ const m = String(url).match(/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)/);
46
+ return m ? `https://docs.google.com/document/d/${m[1]}/export?format=md` : null;
47
+ }
48
+
49
+ async function readSource(source) {
50
+ if (/^https?:\/\//i.test(source)) {
51
+ const exportUrl = docExportUrl(source);
52
+ const res = await fetch(exportUrl || source, {
53
+ headers: { 'user-agent': 'TekJobs/1.0 (resume sync; a link the user gave)', accept: 'text/markdown,text/plain,text/html' },
54
+ redirect: 'follow',
55
+ signal: AbortSignal.timeout(25000),
56
+ });
57
+ const body = await res.text();
58
+ if (exportUrl && (res.status === 401 || res.status === 403 || /accounts\.google\.com/.test(res.url))) {
59
+ throw Object.assign(new Error([
60
+ 'That Google Doc is private, so it cannot be read without signing in, and this command does not sign in.',
61
+ 'Any one of these works:',
62
+ ' 1. Share the Doc as "Anyone with the link can view", then sync again.',
63
+ ' 2. File > Download > Markdown (.md) or Word (.docx), then: tekjobs resume sync <that file>',
64
+ ' 3. Sync from a public page instead, e.g. your portfolio\'s /resume.',
65
+ ].join('\n')), { status: 403 });
66
+ }
67
+ if (!res.ok) throw Object.assign(new Error(`Fetching ${source} returned HTTP ${res.status}.`), { status: 502 });
68
+ const isHtml = /text\/html/i.test(res.headers.get('content-type') || '') || /^\s*<!doctype html|<html[\s>]/i.test(body);
69
+ return { text: isHtml ? pageText(body) : body, kind: exportUrl ? 'google-doc' : isHtml ? 'web-page' : 'url' };
70
+ }
71
+ const file = path.resolve(source);
72
+ if (!fs.existsSync(file)) throw Object.assign(new Error(`No such file: ${file}`), { status: 404 });
73
+ return { text: await extractText(file), kind: 'file' };
74
+ }
75
+
76
+ /**
77
+ * A resume page as text: the <main> element when there is one, without the site's navigation, and with
78
+ * in-site links reduced to their text. A resume that carries the whole site menu reads as a resume that
79
+ * changed every time the menu did.
80
+ */
81
+ function pageText(html) {
82
+ const main = html.match(/<main[\s>][\s\S]*<\/main>/i);
83
+ const scoped = (main ? main[0] : html)
84
+ .replace(/<\s*(nav|header|footer|aside|button|svg|noscript)[\s>][\s\S]*?<\/\s*\1\s*>/gi, '')
85
+ .replace(/<a [^>]*href="(?!https?:|mailto:)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi, '$1');
86
+ return htmlToText(scoped);
87
+ }
88
+
89
+ // ---------------- comparing ----------------
90
+
91
+ /** Resume lines with markdown and spacing stripped, so the same text in two formats compares equal. */
92
+ function lines(text = '') {
93
+ return String(text)
94
+ .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '') // frontmatter
95
+ .split(/\r?\n/)
96
+ .filter((l) => !/^\s*>/.test(l)) // banners about the note, not the resume
97
+ .map((l) => l
98
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // links
99
+ .replace(/^\s*(?:[#>*+-]|\d+\.)+\s*/, '') // headings, quotes, bullets
100
+ .replace(/[*_`]/g, '')
101
+ .replace(/\s+/g, ' ')
102
+ .trim())
103
+ .filter((l) => l.length > 2);
104
+ }
105
+ const key = (l) => l.toLowerCase().replace(/[^\p{L}\p{N}%]+/gu, ' ').trim();
106
+
107
+ const NUMBER_WORD = /^(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|twenty|thirty|fifty|hundred|thousand|million|dozen|half)$/i;
108
+ const FILLER = new Set(['of', 'across', 'the', 'a', 'an', 'and', 'or', 'to', 'in', 'on', 'for', 'with', 'than', 'plus', 'more']);
109
+ const YEAR_OR_DATE = /^(19|20)\d\d$|^\d{1,2}$/;
110
+
111
+ /**
112
+ * The checkable claims in a line: each figure or number word with the word it counts. "three of five evals"
113
+ * gives "three five" and "five evals"; "175 components", "fifteen scheduled". Numbers are what a packet quotes
114
+ * and what a reader checks, and pairing each with its noun survives rewording around it — "fifteen scheduled
115
+ * operational pipelines" and "fifteen scheduled pipelines" make the same claim. Dates are not claims.
116
+ */
117
+ function claims(line) {
118
+ const words = key(line).split(' ').filter((w) => w && !FILLER.has(w));
119
+ const out = new Set();
120
+ words.forEach((w, i) => {
121
+ const isFigure = /^\d/.test(w) && !YEAR_OR_DATE.test(w); // starts with a digit: "175", "500k", not "html5" or "css3"
122
+ if ((isFigure || NUMBER_WORD.test(w)) && words[i + 1] && !YEAR_OR_DATE.test(words[i + 1])) out.add(`${w} ${words[i + 1]}`);
123
+ });
124
+ return [...out];
125
+ }
126
+
127
+ /** The same pairs, for a whole text, so presence can be checked without caring how the sentence was phrased. */
128
+ const claimSet = (text) => new Set(lines(text).flatMap(claims));
129
+
130
+ // A reworded claim is still the same claim: "fifteen scheduled pipelines" and "15 scheduled pipelines",
131
+ // "175 components" and "a 175-component design system", "twelve packages" and "12 npm packages".
132
+ // So claims are compared on a canonical number plus the stem of the noun, and a claim counts as kept when
133
+ // both appear anywhere in one line of the new resume, not only side by side.
134
+ const WORD_NUM = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, twenty: 20, thirty: 30, fifty: 50, hundred: 100, thousand: 1000, million: 1000000, dozen: 12 };
135
+ const canonNum = (w) => (w.toLowerCase() in WORD_NUM ? String(WORD_NUM[w.toLowerCase()]) : w.toLowerCase());
136
+ const stem = (w) => w.toLowerCase().replace(/ies$/, 'y').replace(/(?<=[a-z]{3})s$/, '').slice(0, 8);
137
+ const claimKey = (c) => { const [n, ...rest] = c.split(' '); return `${canonNum(n)} ${stem(rest.join(' '))}`; };
138
+ /** True when some line of `text` carries the claim's number and its noun, however the sentence is built. */
139
+ function stillClaimed(claim, text) {
140
+ const [n, ...rest] = claim.split(' ');
141
+ const num = canonNum(n), noun = stem(rest.join(' '));
142
+ return lines(text).some((l) => {
143
+ const words = key(l).split(' ').filter(Boolean);
144
+ return words.some((w) => canonNum(w) === num) && words.some((w) => stem(w) === noun);
145
+ });
146
+ }
147
+
148
+ function currentNoteText() {
149
+ for (const rel of [RESUME_NOTE, ...LEGACY_RESUME_NOTES]) {
150
+ const file = path.join(VAULT, rel);
151
+ if (fs.existsSync(file)) return { rel, text: fs.readFileSync(file, 'utf8') };
152
+ }
153
+ return { rel: '', text: '' };
154
+ }
155
+
156
+ /**
157
+ * Job notes whose Application section still quotes a claim the resume has stopped making. Checked against
158
+ * the new resume as a whole, so a claim that was only reworded — the figure still there in another line —
159
+ * does not raise a false alarm.
160
+ */
161
+ function stalePackets(removedLines, newText) {
162
+ const kept = claimSet(newText);
163
+ const gone = [...new Set(removedLines.flatMap(claims))].filter((c) => !kept.has(c) && !stillClaimed(c, newText));
164
+ if (!gone.length || !fs.existsSync(P.jobs)) return [];
165
+ const hits = [];
166
+ for (const f of fs.readdirSync(P.jobs).filter((x) => x.endsWith('.md'))) {
167
+ const text = fs.readFileSync(path.join(P.jobs, f), 'utf8');
168
+ const section = (text.match(/^## Application\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/m) || [, ''])[1];
169
+ if (!section.trim()) continue;
170
+ const inPacket = new Set(claims(section).map(claimKey));
171
+ const quoted = gone.filter((c) => inPacket.has(claimKey(c)));
172
+ if (quoted.length) hits.push({ note: f.replace(/\.md$/, ''), quotes: quoted });
173
+ }
174
+ return hits;
175
+ }
176
+
177
+ /** Exposed for tests: the comparison is the part of this module that must not cry wolf, and must not go quiet. */
178
+ export const _internals = { claims, claimKey, stillClaimed, lines, stalePackets };
179
+
180
+ // ---------------- the command ----------------
181
+
182
+ export async function syncResume({ source = '', dry = false } = {}) {
183
+ const src = source || rememberedSource();
184
+ if (!src) {
185
+ throw Object.assign(new Error('No resume source yet. Pass one the first time: tekjobs resume sync <google-doc-url | page-url | file>'), { status: 400 });
186
+ }
187
+
188
+ const { text, kind } = await readSource(src);
189
+ const body = String(text).replace(/\r\n/g, '\n').trim();
190
+ if (body.length < 400) {
191
+ throw Object.assign(new Error(`The resume from ${src} came back nearly empty (${body.length} characters), so nothing was written. If it is a web page, check it renders without JavaScript.`), { status: 422 });
192
+ }
193
+
194
+ const previous = currentNoteText();
195
+ const before = lines(previous.text);
196
+ const after = lines(body);
197
+ const beforeKeys = new Set(before.map(key));
198
+ const afterKeys = new Set(after.map(key));
199
+ const removed = before.filter((l) => !afterKeys.has(key(l)));
200
+ const added = after.filter((l) => !beforeKeys.has(key(l)));
201
+ const stale = stalePackets(removed, body);
202
+
203
+ const note = [
204
+ '---',
205
+ 'type: resume',
206
+ 'status: current',
207
+ `source: ${JSON.stringify(src)}`,
208
+ `source_kind: ${kind}`,
209
+ `synced: ${today()}`,
210
+ '---',
211
+ `> **Synced from the source above on ${today()} by \`tekjobs resume sync\`.** Application drafts read this note. Edit the source, not this note: the next sync replaces it.`,
212
+ '',
213
+ body,
214
+ '',
215
+ ].join('\n');
216
+
217
+ if (!dry) {
218
+ fs.mkdirSync(path.join(VAULT, 'Profile'), { recursive: true });
219
+ fs.writeFileSync(path.join(VAULT, RESUME_NOTE), note);
220
+ if (source) rememberSource(/^https?:\/\//i.test(source) ? source : path.resolve(source));
221
+ }
222
+
223
+ return { source: src, kind, chars: body.length, previous: previous.rel, removed, added, stale, wrote: !dry, note: RESUME_NOTE };
224
+ }