@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 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#0d1a14"/><rect x="0.5" y="0.5" width="63" height="63" rx="11.5" fill="none" stroke="#1a3326"/><text x="32" y="45" text-anchor="middle" font-family="'Bricolage Grotesque', system-ui, sans-serif" font-size="38" font-weight="700" letter-spacing="-1" fill="#c8f542">T</text></svg>
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>TekJobs</title>
7
+ <meta name="theme-color" content="#0d1a14" />
8
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
9
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;600&family=Hanken+Grotesk:wght@400;500;600&family=Azeret+Mono:wght@400;500&display=swap" data-zengin="fonts" />
10
+ <script type="module" crossorigin src="/assets/index-QteuouKv.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-DzGgV6NF.css">
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+ </body>
16
+ </html>
@@ -0,0 +1,243 @@
1
+ // Cover letters, written from the posting and the person's own resume.
2
+ //
3
+ // Two ways in, one set of rules:
4
+ // - The app's button runs a local LLM CLI the person is already signed in to (Claude Code by default, Codex
5
+ // or anything else that reads a prompt on stdin). No API key; it rides their subscription.
6
+ // - An agent connected over MCP asks for the same materials, writes the letter itself, and saves it.
7
+ // Either way the letter is checked against the resume before it is kept: a figure the resume does not make is
8
+ // flagged, because a cover letter is exactly where an eager model invents one.
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { spawn } from 'node:child_process';
13
+ import * as store from './store.mjs';
14
+ import { CONFIG_FILE, VAULT } from '../../scraper/config.mjs';
15
+ import { _internals as resumeCheck } from '../../scraper/resume-sync.mjs';
16
+
17
+ const EMPHASES = {
18
+ auto: 'Choose whichever of the two emphases below the posting itself asks for, and commit to it.',
19
+ 'design-systems': 'Lead with design systems: component architecture, tokens, accessibility gates, adoption across teams.',
20
+ 'ai-product': 'Lead with AI product surfaces: making model behaviour legible and controllable, retrieval, evaluation, agent tooling.',
21
+ };
22
+ const LENGTHS = { short: [110, 170], standard: [170, 250] };
23
+
24
+ // The hard constraints. Voice and shape come from the person's own Profile/Voice.md when it exists (it is
25
+ // written into the prompt verbatim), with DEFAULT_VOICE as the fallback for a profile that has none yet.
26
+ const RULES = `Hard rules, all binding:
27
+ - Every fact about the candidate comes from the RESUME or PROFILE below. Do not invent employers, dates, numbers, outcomes, team sizes or technologies. A figure the resume gives may be used exactly; never rounded, never a new one.
28
+ - Name the company in the first two sentences and write about their specific product, by name, not the category it belongs to ("Linear's site", never "a developer tool's website"). A letter that never says the company's name is a form letter and will be rejected by the checker.
29
+ - No placeholders in brackets. With no name to address, open with "Hello,".
30
+ - Close with the portfolio link once, exactly as the PROFILE gives it, the availability exactly as the profile states it, and the name. No sign-off word, no address block, no subject line.
31
+ - Plain text only. No markdown, no bullets, no headings, no bold. Short paragraphs separated by blank lines.
32
+ - No em dashes or en dashes anywhere.
33
+ - Output the finished letter and nothing else: no preamble, no notes, no title.`;
34
+
35
+ const DEFAULT_VOICE = `Voice and shape:
36
+ - First person singular, written to a peer. Short plain sentences, one idea each, varied in length.
37
+ - No adjectives about yourself. No "passionate", "excited", "thrilled", "strong", "deep", "leverage", "robust", "delve", "elevate", "empower".
38
+ - Start inside their problem, in your own words: one or two sentences showing you understood what they are building and why the hard part is hard. Not a quote, not "I am writing to apply", not "for more than a decade".
39
+ - One piece of evidence with enough detail to be believed. A second only if it is a different kind of thing. Answer what they ask for without announcing that you are answering ("You ask for X" is banned).
40
+ - Quote the posting at most once. No "not X but Y" contrasts, no colon reveals, no rhetorical questions, no closing metaphor. End on the last concrete fact.`;
41
+
42
+ const EDIT_PASS = `Process: write a draft, then edit it as a sharp human editor before you output it. In the edit, cut every sentence that could move unchanged into a letter to a different company (the portability test); break any pattern where two paragraphs open the same way or three sentences share a shape; remove every adjective the candidate applies to himself; remove any sentence that only explains the sentence before it; check every number against the RESUME; count the quotations from the posting and keep at most one. Output only the edited letter.`;
43
+
44
+ /**
45
+ * The posting text to write from. The note's copy is a scan-time snapshot and is sometimes a stub: a few
46
+ * boards nest their HTML in ways the scanner flattens to one paragraph. A letter written from a stub is
47
+ * generic by construction, so when the copy is thin the live posting is fetched. The note is not rewritten.
48
+ */
49
+ const THIN = 1500;
50
+ const livePostings = new Map();
51
+ async function postingText(job) {
52
+ const noted = job.sections.description.replace(/^>.*$/gm, '').trim();
53
+ if (noted.length >= THIN || !job.url) return { text: noted, source: 'note' };
54
+ if (!livePostings.has(job.id)) {
55
+ try { const r = await store.fetchLink(job.url, { maxChars: 12000 }); livePostings.set(job.id, r.status < 400 ? r.text : ''); }
56
+ catch { livePostings.set(job.id, ''); }
57
+ }
58
+ const live = livePostings.get(job.id);
59
+ return live.length > noted.length ? { text: live, source: 'live posting' } : { text: noted, source: 'note (thin, and the live posting could not be read)' };
60
+ }
61
+
62
+ /** Everything a writer needs, as one prompt. The same text goes to the CLI and to an MCP agent. */
63
+ export async function materials(id, { emphasis = 'auto', length = 'standard', extra = '' } = {}) {
64
+ const job = store.getJob(id);
65
+ const posting = await postingText(job);
66
+ const packet = store.applicationPacket(id);
67
+ const { profile, positioning, resume } = store.profile();
68
+ if (!resume.trim()) throw Object.assign(new Error('No resume in the profile yet. Import one first (tekjobs resume <file>).'), { status: 409 });
69
+ const [lo, hi] = LENGTHS[length] || LENGTHS.standard;
70
+ const packetBits = ['Narrative', 'Tailored summary', 'Tailored bullets', 'Portfolio', 'Risks', 'Contact / referral']
71
+ .filter((f) => packet.values[f]).map((f) => `${f}: ${packet.values[f]}`).join('\n');
72
+ const voice = readVoice();
73
+ const prompt = [
74
+ `Write a cover letter for this job application, ${lo} to ${hi} words, in the candidate's own voice.`,
75
+ `Emphasis: ${EMPHASES[emphasis] || EMPHASES.auto}`,
76
+ extra.trim() ? `The candidate also asked: ${extra.trim()}` : '',
77
+ RULES,
78
+ voice ? `=== THE CANDIDATE'S OWN WRITING RULES AND VOICE (binding; match the register of the samples, never copy their sentences) ===\n${voice}` : DEFAULT_VOICE,
79
+ EDIT_PASS,
80
+ `=== POSTING ===\nCompany: ${job.company}\nTitle: ${job.title}\nLocation: ${job.location}\n\n${posting.text.slice(0, 9000)}`,
81
+ `=== WHY THE SCANNER MATCHED IT ===\n${job.sections.why}`,
82
+ packetBits ? `=== WHAT HAS ALREADY BEEN DECIDED FOR THIS APPLICATION ===\n${packetBits}` : '',
83
+ `=== RESUME (the only source of facts) ===\n${strip(resume)}`,
84
+ `=== PROFILE ===\n${strip(profile).slice(0, 3500)}`,
85
+ positioning.trim() ? `=== POSITIONING ===\n${strip(positioning).slice(0, 2500)}` : '',
86
+ ].filter(Boolean).join('\n\n');
87
+ return { id, company: job.company, title: job.title, emphasis, length, words: [lo, hi], postingSource: posting.source, postingChars: posting.text.length, prompt, rules: RULES };
88
+ }
89
+ const strip = (md) => md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').replace(/^>.*$/gm, '').trim();
90
+ /** Profile/Voice.md: how this person writes, in their own words, with samples. Optional; the fallback is generic. */
91
+ export function readVoice() {
92
+ try { return fs.readFileSync(path.join(VAULT, 'Profile', 'Voice.md'), 'utf8').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').replace(/^> (\*\*)?The cover-letter writer reads this note.*\n/m, '').trim(); }
93
+ catch { return ''; }
94
+ }
95
+
96
+ /**
97
+ * The figures a letter asserts, each with the word it counts.
98
+ *
99
+ * Stricter than the resume differ's extractor on purpose. That one ignores one and two digit numbers, because
100
+ * in a resume they are mostly days of the month. In a letter they are mostly the invented ones: "a team of 40",
101
+ * "8 years", "3x faster". Here only a number beside a month name, or a four digit year, is treated as a date.
102
+ * "one" is left out: in prose it is a pronoun far more often than a count ("one I have worked on").
103
+ */
104
+ const MONTH = /^(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec|january|february|march|april|june|july|august|september|october|november|december)$/;
105
+ const COUNT_WORD = /^(two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|twenty|thirty|forty|fifty|hundred|thousand|million|dozen)$/;
106
+ const SKIP = new Set(['of', 'across', 'the', 'a', 'an', 'and', 'or', 'to', 'in', 'on', 'for', 'with', 'than', 'plus', 'more', 'over', 'about', 'around', 'nearly', 'up']);
107
+ const NOT_A_NOUN = new Set(['i', 'we', 'you', 'it', 'is', 'was', 'are', 'that', 'this', 'have', 'has', 'had', 'will', 'would', 'can', 'at', 'by', 'from', 'as', 'so', 'but', 'if', 'when', 'my', 'your', 'their', 'our']);
108
+ /** "29,000" is one number. Left alone, the comma splits it into "29" and "000" and neither means anything. */
109
+ const ungroup = (s) => String(s).replace(/(\d),(?=\d{3}\b)/g, '$1');
110
+ export function letterClaims(text) {
111
+ const out = new Set();
112
+ for (const line of resumeCheck.lines(ungroup(text))) {
113
+ const words = line.toLowerCase().replace(/[^\p{L}\p{N}%]+/gu, ' ').trim().split(' ').filter((w) => w && !SKIP.has(w));
114
+ words.forEach((w, i) => {
115
+ const figure = /^\d/.test(w) && !/^(19|20)\d\d$/.test(w);
116
+ if (!figure && !COUNT_WORD.test(w)) return;
117
+ const prev = words[i - 1] || '', next = words[i + 1] || '';
118
+ if (figure && (MONTH.test(prev) || MONTH.test(next))) return; // "November 3", "3 November"
119
+ if (!next || NOT_A_NOUN.has(next) || /^(19|20)\d\d$/.test(next)) return;
120
+ out.add(`${w} ${next}`);
121
+ });
122
+ }
123
+ return [...out];
124
+ }
125
+
126
+ /**
127
+ * The patterns that make a letter read as generated. Each is a regex and the sentence a person would say about
128
+ * it. Drawn from the no-ai-slop editing rules and from the first letter the button produced, which opened with
129
+ * a decade, quoted the posting three times, and answered it in three identical "You ask for" stanzas.
130
+ */
131
+ const SLOP = [
132
+ [/\b(for |over )?more than a decade\b|\bover a decade\b|\ba decade of\b/i, 'opens on "a decade": the sentence every applicant with ten years writes'],
133
+ [/\bthat is (exactly )?the (line|seam|problem|gap|work|job|role) (i|you)\b/i, 'a tag line explaining the sentence before it ("That is the line I have worked on")'],
134
+ [/\b(not just|not only|isn't just|is not just) \w[^.]{0,60}?\b(but|it's|it is)\b/i, 'a "not just X but Y" contrast; say Y'],
135
+ [/\b(isn't|is not|wasn't) (about|a matter of) [^.]{2,50}\. (it's|it is) /i, 'an "it isn\'t X, it\'s Y" contrast; say Y'],
136
+ [/^[^:\n]{3,60}: [a-z][^.\n]{4,}\.?$/m, 'a colon reveal ("The result: a system that lasts")'],
137
+ [/\b(i believe|i am confident|i would love|i look forward|thank you for (your|the) consideration|i am writing to)\b/i, 'applicant boilerplate ("I believe", "I look forward", "I am writing to")'],
138
+ [/\b(leverage|leveraging|robust|delve|elevate|empower|passionate|excited|thrilled|synergy|cutting-edge|ever-evolving|multifaceted|meticulous|transformative)\b/i, 'a word from the banned list'],
139
+ [/\b(uncompromising|relentless|meticulous|obsessive|deep (design )?sensibility|strong (visual |design )?(taste|eye|sense)|craft bar)\b/i, 'an adjective about yourself; let the facts carry it'],
140
+ [/\?(\s|$)/, 'a rhetorical question'],
141
+ ];
142
+ export function slopFindings(text) {
143
+ const out = [];
144
+ for (const [re, why] of SLOP) { const m = text.match(re); if (m) out.push(`${why}: "${m[0].trim().slice(0, 60)}"`); }
145
+ const youAsk = (text.match(/\byou (ask|are looking|are asking|want|need|describe)\b/gi) || []).length;
146
+ if (youAsk >= 2) out.push(`answers the posting in ${youAsk} "You ask for" stanzas; a form, not a letter`);
147
+ const quotes = (text.match(/"[^"\n]{6,}"/g) || []).length;
148
+ if (quotes > 1) out.push(`quotes the posting ${quotes} times; once at most`);
149
+ const paras = text.split(/\n\s*\n/).map((p) => p.trim().split(/\s+/).slice(0, 2).join(' ').toLowerCase()).filter(Boolean);
150
+ const dupOpen = paras.find((p, i) => p.length > 3 && paras.indexOf(p) !== i);
151
+ if (dupOpen) out.push(`two paragraphs open the same way ("${dupOpen}…")`);
152
+ return out;
153
+ }
154
+
155
+ /** Deterministic checks on a finished letter. Warnings, never blocks: the person decides. */
156
+ export function check(id, text) {
157
+ const job = store.getJob(id);
158
+ const { resume, profile } = store.profile();
159
+ // The live posting counts as a source too, when one was fetched: quoting their own number back is not inventing it.
160
+ const sources = `${resume}\n${profile}\n${job.sections.description}\n${livePostings.get(job.id) || ''}\n${job.company} ${job.title}`;
161
+ const words = text.trim() ? text.trim().split(/\s+/).length : 0;
162
+ const warnings = [];
163
+ const unsupported = letterClaims(text).filter((c) => !resumeCheck.stillClaimed(c, ungroup(sources)));
164
+ for (const c of unsupported) warnings.push({ kind: 'claim', text: `"${c}" is not on your resume, your profile or the posting. Check it before sending.` });
165
+ if (/[—–]/.test(text)) warnings.push({ kind: 'style', text: 'Contains an em or en dash. Your own rule is none in anything you send.' });
166
+ const ph = text.match(/\[[^\]\n]{2,40}\]/g); if (ph) warnings.push({ kind: 'placeholder', text: `Placeholder left in: ${[...new Set(ph)].join(', ')}` });
167
+ const cliche = ['great fit', 'perfect fit', 'dear hiring manager', 'to whom it may concern', 'fast-paced', 'team player', 'hit the ground running'].filter((p) => text.toLowerCase().includes(p));
168
+ if (cliche.length) warnings.push({ kind: 'style', text: `Stock phrases: ${cliche.join(', ')}.` });
169
+ for (const s of slopFindings(text)) warnings.push({ kind: 'slop', text: s });
170
+ if (!text.toLowerCase().includes(job.company.toLowerCase().split(/[ (]/)[0])) warnings.push({ kind: 'specific', text: `Never names ${job.company}. A letter that could go to anyone reads like one.` });
171
+ if (words > 290) warnings.push({ kind: 'length', text: `${words} words. Most readers stop near 250.` });
172
+ if (words && words < 90) warnings.push({ kind: 'length', text: `${words} words. Probably too thin to carry proof.` });
173
+ return { words, warnings };
174
+ }
175
+
176
+ export function saved(id) {
177
+ const text = store.getCoverLetter(id);
178
+ return text ? { text, ...check(id, text) } : null;
179
+ }
180
+ export function save(id, text, via = 'app') {
181
+ const clean = String(text || '').replace(/\r/g, '').trim();
182
+ if (clean.length < 80) throw Object.assign(new Error('That is too short to be a cover letter.'), { status: 400 });
183
+ store.saveCoverLetter(id, clean, via);
184
+ return saved(id);
185
+ }
186
+
187
+ // ---------------- the local runner ----------------
188
+
189
+ /** Which CLI writes the letter. ~/.tekjobs/config.json: { "llm": { "command": "claude", "args": ["-p"] } } */
190
+ export function runnerConfig() {
191
+ let cfg = {};
192
+ try { cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')).llm || {}; } catch { /* none */ }
193
+ if (process.env.TEKJOBS_LLM_CMD) cfg = { command: process.env.TEKJOBS_LLM_CMD, args: (process.env.TEKJOBS_LLM_ARGS || '').split(' ').filter(Boolean) };
194
+ return { command: cfg.command || 'claude', args: cfg.args || ['-p', '--output-format', 'text'] };
195
+ }
196
+
197
+ /**
198
+ * @param {string} prompt written to the CLI's stdin
199
+ * @param {object} [opts]
200
+ * @param {number} [opts.timeoutMs]
201
+ * @param {string[]} [opts.extraArgs] appended after the configured args: the mail check passes tool allow and
202
+ * deny lists here so a run can read a mailbox and nothing else
203
+ */
204
+ export function runLLM(prompt, { timeoutMs = 240000, extraArgs = [] } = {}) {
205
+ const { command, args } = runnerConfig();
206
+ // An empty working directory, so the CLI does not load some project's hooks, MCP servers or instructions.
207
+ const cwd = path.join(os.tmpdir(), 'tekjobs-llm'); fs.mkdirSync(cwd, { recursive: true });
208
+ return new Promise((resolve, reject) => {
209
+ let out = '', err = '';
210
+ const child = spawn(command, [...args, ...extraArgs], { cwd, shell: true, env: process.env }); // shell: npm-installed CLIs are .cmd shims on Windows
211
+ const timer = setTimeout(() => { child.kill(); reject(new Error(`${command} did not answer within ${Math.round(timeoutMs / 1000)} seconds.`)); }, timeoutMs);
212
+ child.stdout.on('data', (d) => { out += d; });
213
+ child.stderr.on('data', (d) => { err += d; });
214
+ child.on('error', (e) => { clearTimeout(timer); reject(new Error(`Could not start "${command}": ${e.message}`)); });
215
+ child.on('close', (code) => {
216
+ clearTimeout(timer);
217
+ const all = `${out}\n${err}`;
218
+ if (/not recognized as|command not found|ENOENT/i.test(all)) return reject(Object.assign(new Error(`"${command}" is not installed or not on PATH. Install Claude Code, or set another CLI under "llm" in ${CONFIG_FILE}.`), { kind: 'missing' }));
219
+ if (/failed to authenticate|oauth|not logged in|please log in|login required|unauthorized|401/i.test(all)) return reject(Object.assign(new Error(`${command} is signed out. Open a terminal, run "${command}", sign in, then try again. (The desktop app's login is separate from the terminal's.)`), { kind: 'auth' }));
220
+ if (/requires a newer version|please upgrade/i.test(all)) return reject(Object.assign(new Error(`${command} is out of date and its model refused the request. Update it (npm i -g, or its own updater), then try again.`), { kind: 'outdated' }));
221
+ if (code !== 0 && !out.trim()) return reject(new Error(`${command} exited with code ${code}: ${err.trim().split('\n').slice(-2).join(' ') || 'no output'}`));
222
+ resolve(out.replace(/^```[a-z]*\n|\n```\s*$/g, '').trim());
223
+ });
224
+ child.stdin.write(prompt); child.stdin.end();
225
+ });
226
+ }
227
+
228
+ const drafts = new Map(); // id → { running, startedAt, finishedAt, error, errorKind }
229
+ export function state(id) {
230
+ const d = drafts.get(id) || { running: false, startedAt: null, finishedAt: null, error: '', errorKind: '' };
231
+ return { ...d, saved: saved(id), runner: runnerConfig().command };
232
+ }
233
+ export function start(id, opts = {}) {
234
+ const cur = drafts.get(id);
235
+ if (cur?.running) return state(id);
236
+ store.getJob(id); // throws early, and synchronously, if there is no such job
237
+ drafts.set(id, { running: true, startedAt: new Date().toISOString(), finishedAt: null, error: '', errorKind: '' });
238
+ materials(id, opts).then((m) => runLLM(m.prompt))
239
+ .then((text) => { save(id, text, `app · ${runnerConfig().command}`); drafts.set(id, { ...drafts.get(id), running: false, finishedAt: new Date().toISOString() }); })
240
+ .catch((e) => drafts.set(id, { ...drafts.get(id), running: false, finishedAt: new Date().toISOString(), error: e.message, errorKind: e.kind || 'failed' }));
241
+ return state(id);
242
+ }
243
+ export { VAULT };
@@ -0,0 +1,113 @@
1
+ // TekJobs local server: JSON API over the vault + static hosting of the built app. No dependencies.
2
+ // Dev: `npm run server` here and `npm run dev` for Vite (proxies /api). Prod: `npm start` serves dist/ and the API on one port.
3
+ import http from 'node:http';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import * as store from './store.mjs';
8
+ import * as letter from './cover-letter.mjs';
9
+ import * as tailored from './tailored-resume.mjs';
10
+ import * as mail from './mail-check.mjs';
11
+ import * as people from './people.mjs';
12
+
13
+ const PORT = Number(process.env.PORT || 8787);
14
+ const DIST = fileURLToPath(new URL('../dist', import.meta.url));
15
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.woff2': 'font/woff2' };
16
+
17
+ const json = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); };
18
+ const readBody = (req) => new Promise((resolve, reject) => { let s = ''; req.on('data', (d) => { s += d; if (s.length > 5e6) reject(new Error('body too large')); }); req.on('end', () => { try { resolve(s ? JSON.parse(s) : {}); } catch (e) { reject(Object.assign(new Error('invalid JSON body'), { status: 400 })); } }); });
19
+
20
+ const routes = [
21
+ ['GET', /^\/api\/summary$/, () => store.summary()],
22
+ ['GET', /^\/api\/today$/, (_, q) => store.today({ cap: Number(q.get('cap') || 7), waitingDays: Number(q.get('waitingDays') || 14) })],
23
+ ['GET', /^\/api\/jobs$/, (_, q) => store.searchJobs({ ...store.filtersFromParams(q), sort: q.get('sort') || 'score', dir: q.get('dir') || 'desc', limit: Number(q.get('limit') || 500), offset: Number(q.get('offset') || 0) })],
24
+ ['GET', /^\/api\/jobs\/facets$/, (_, q) => store.jobFacets(store.filtersFromParams(q))],
25
+ ['GET', /^\/api\/jobs\/([^/]+)\/packet$/, (m) => store.applicationPacket(decodeURIComponent(m[1]))],
26
+ // Cover letter: GET is the saved letter plus the draft run's state; POST starts a draft with the local CLI; PUT saves an edit.
27
+ ['GET', /^\/api\/jobs\/([^/]+)\/cover-letter$/, (m) => letter.state(decodeURIComponent(m[1]))],
28
+ ['POST', /^\/api\/jobs\/([^/]+)\/cover-letter$/, async (m, _, req) => letter.start(decodeURIComponent(m[1]), await readBody(req))],
29
+ // Tailored resume: same shape. The print view is served as HTML below, outside the JSON routes.
30
+ ['GET', /^\/api\/jobs\/([^/]+)\/resume$/, (m) => tailored.state(decodeURIComponent(m[1]))],
31
+ ['POST', /^\/api\/jobs\/([^/]+)\/resume$/, async (m, _, req) => tailored.start(decodeURIComponent(m[1]), await readBody(req))],
32
+ ['PUT', /^\/api\/jobs\/([^/]+)\/resume$/, async (m, _, req) => { const b = await readBody(req); tailored.save(decodeURIComponent(m[1]), b.text, 'app · edited by hand'); return tailored.state(decodeURIComponent(m[1])); }],
33
+ ['PUT', /^\/api\/jobs\/([^/]+)\/cover-letter$/, async (m, _, req) => { const b = await readBody(req); letter.save(decodeURIComponent(m[1]), b.text, 'app · edited by hand'); return letter.state(decodeURIComponent(m[1])); }],
34
+ ['GET', /^\/api\/jobs\/([^/]+)\/people$/, (m) => people.peopleOf(decodeURIComponent(m[1]))],
35
+ ['GET', /^\/api\/jobs\/([^/]+)$/, (m) => store.getJob(decodeURIComponent(m[1]))],
36
+ // People: one note per person under People/, linked from the job notes they are on.
37
+ ['GET', /^\/api\/people$/, () => people.listPeople()],
38
+ ['POST', /^\/api\/people$/, async (_, __, req) => { const b = await readBody(req); const p = people.createPerson(b); if (b.jobId) people.attachPerson(b.jobId, p.id, { role: b.role, context: b.context }); return people.getPerson(p.id); }],
39
+ ['GET', /^\/api\/people\/([^/]+)$/, (m) => people.getPerson(decodeURIComponent(m[1]))],
40
+ ['POST', /^\/api\/people\/([^/]+)\/attach$/, async (m, _, req) => { const b = await readBody(req); return people.attachPerson(b.jobId, decodeURIComponent(m[1]), { role: b.role, context: b.context }); }],
41
+ ['POST', /^\/api\/people\/([^/]+)\/log$/, async (m, _, req) => { const b = await readBody(req); return people.logContact(decodeURIComponent(m[1]), { date: b.date, via: 'app', text: b.text }); }],
42
+ ['PATCH', /^\/api\/jobs\/([^/]+)$/, async (m, _, req) => { const b = await readBody(req); let job; if (b.status) job = store.setStatus(decodeURIComponent(m[1]), b.status, 'app', b.reason || ''); if (b.note) job = store.addNote(decodeURIComponent(m[1]), b.note, 'app'); if (b.application) job = store.saveApplicationDraft(decodeURIComponent(m[1]), b.application); return job || store.getJob(decodeURIComponent(m[1])); }],
43
+ ['GET', /^\/api\/runs$/, () => store.runs()],
44
+ ['GET', /^\/api\/scan$/, () => store.scanStatus()],
45
+ ['POST', /^\/api\/scan$/, async (_, __, req) => { const b = await readBody(req); return store.runScan(b.dry ? ['--dry'] : [], { criteria: b.criteria || '', retryFailed: !!b.retryFailed, via: 'app' }); }],
46
+ ['GET', /^\/api\/outcomes$/, () => store.outcomes()],
47
+ ['GET', /^\/api\/views$/, () => store.getViews()],
48
+ ['GET', /^\/api\/views\/defaults$/, () => store.defaultViews()],
49
+ ['PUT', /^\/api\/views$/, async (_, __, req) => { const b = await readBody(req); return store.saveViews(b.views); }],
50
+ ['GET', /^\/api\/mail$/, () => mail.items()],
51
+ ['POST', /^\/api\/mail\/check$/, async (_, __, req) => mail.start(await readBody(req))],
52
+ ['POST', /^\/api\/mail\/confirm-safe$/, () => mail.confirmSafe()],
53
+ ['POST', /^\/api\/mail\/([^/]+)\/confirm$/, async (m, _, req) => mail.confirm(decodeURIComponent(m[1]), await readBody(req))],
54
+ ['POST', /^\/api\/mail\/([^/]+)\/dismiss$/, (m) => mail.dismiss(decodeURIComponent(m[1]))],
55
+ ['POST', /^\/api\/criteria\/preview$/, async (_, __, req) => { const b = await readBody(req); return store.previewCriteria(b.raw); }],
56
+ ['POST', /^\/api\/rescore$/, async (_, __, req) => { const b = await readBody(req); return store.rescoreNotes({ dry: !!b.dry }); }],
57
+ ['GET', /^\/api\/criteria\/presets$/, () => store.criteriaPresets()],
58
+ ['GET', /^\/api\/criteria\/presets\/([^/]+)$/, (m) => store.getCriteriaPreset(decodeURIComponent(m[1]))],
59
+ ['PUT', /^\/api\/criteria\/presets\/([^/]+)$/, async (m, _, req) => { const b = await readBody(req); return store.saveCriteriaPreset(decodeURIComponent(m[1]), b.raw); }],
60
+ ['DELETE', /^\/api\/criteria\/presets\/([^/]+)$/, (m) => store.deleteCriteriaPreset(decodeURIComponent(m[1]))],
61
+ ['POST', /^\/api\/criteria\/presets\/([^/]+)\/activate$/, (m) => store.activateCriteriaPreset(decodeURIComponent(m[1]))],
62
+ ['GET', /^\/api\/criteria$/, () => store.getCriteria()],
63
+ ['PUT', /^\/api\/criteria$/, async (_, __, req) => { const b = await readBody(req); return store.setCriteria(b.raw); }],
64
+ ['GET', /^\/api\/profile\/summary$/, () => store.profileSummary()],
65
+ ['GET', /^\/api\/companies$/, () => store.companies()],
66
+ ['GET', /^\/api\/feeds$/, () => store.feeds()],
67
+ ['POST', /^\/api\/companies$/, async (_, __, req) => store.addCompany(await readBody(req))],
68
+ ['POST', /^\/api\/jobs\/import$/, async (_, __, req) => { const b = await readBody(req); return store.importLinks(b.urls ?? b.url); }],
69
+ ['POST', /^\/api\/jobs\/([^/]+)\/reveal$/, (m) => store.revealJob(decodeURIComponent(m[1]))],
70
+ ['POST', /^\/api\/jobs\/([^/]+)\/attach$/, async (m, _, req) => { const b = await readBody(req); return store.attachPosting(decodeURIComponent(m[1]), b.url, { linkOnly: !!b.linkOnly }); }],
71
+ // Settings (~/.tekjobs/config.json) and the folder of resume variants.
72
+ ['GET', /^\/api\/settings$/, () => store.settings()],
73
+ ['PUT', /^\/api\/settings$/, async (_, __, req) => store.saveSettings(await readBody(req))],
74
+ ['GET', /^\/api\/resumes$/, () => store.listResumes()],
75
+ ['POST', /^\/api\/resumes\/use$/, async (_, __, req) => { const b = await readBody(req); return store.useResume(b.name); }],
76
+ ['POST', /^\/api\/resumes\/open$/, async (_, __, req) => { const b = await readBody(req); return store.openResume(b.name); }],
77
+ ['GET', /^\/api\/snippets$/, () => store.getSnippets()],
78
+ ['PUT', /^\/api\/snippets$/, async (_, __, req) => { const b = await readBody(req); return store.saveSnippets(b.items); }],
79
+ ['GET', /^\/api\/profile$/, () => store.profile()],
80
+ ['PUT', /^\/api\/profile$/, async (_, __, req) => { const b = await readBody(req); return store.saveProfileNote(b.note || 'profile', b.markdown); }],
81
+ ['GET', /^\/api\/onboarding$/, () => store.onboardingStatus()],
82
+ ['POST', /^\/api\/onboarding\/init$/, () => store.initProfile()],
83
+ ['POST', /^\/api\/onboarding\/resume$/, async (_, __, req) => { const b = await readBody(req); return store.importResume(b.path); }],
84
+ ['GET', /^\/api\/statuses$/, () => store.STATUSES],
85
+ ['GET', /^\/api\/pass-reasons$/, () => store.PASS_REASONS],
86
+ ['GET', /^\/api\/application-fields$/, () => store.APPLICATION_FIELDS],
87
+ ];
88
+
89
+ function serveStatic(req, res, pathname) {
90
+ if (!fs.existsSync(DIST)) { res.writeHead(503, { 'content-type': 'text/plain' }); return res.end('No build yet. Run `npm run build`, or use `npm run dev` for the Vite dev server.'); }
91
+ let file = path.join(DIST, decodeURIComponent(pathname));
92
+ if (!file.startsWith(DIST)) { res.writeHead(403); return res.end(); }
93
+ if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(DIST, 'index.html');
94
+ res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' });
95
+ fs.createReadStream(file).pipe(res);
96
+ }
97
+
98
+ const server = http.createServer(async (req, res) => {
99
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
100
+ try {
101
+ const printView = url.pathname.match(/^\/api\/jobs\/([^/]+)\/resume\.html$/);
102
+ if (printView && req.method === 'GET') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(tailored.html(decodeURIComponent(printView[1]))); }
103
+ for (const [method, re, handler] of routes) {
104
+ const m = url.pathname.match(re);
105
+ if (m && req.method === method) return json(res, 200, await handler(m, url.searchParams, req));
106
+ }
107
+ if (url.pathname.startsWith('/api/')) return json(res, 404, { error: `no route ${req.method} ${url.pathname}` });
108
+ return serveStatic(req, res, url.pathname);
109
+ } catch (e) {
110
+ return json(res, e.status || 500, { error: e.message });
111
+ }
112
+ });
113
+ server.listen(PORT, '127.0.0.1', () => console.log(`TekJobs server http://127.0.0.1:${PORT} vault: ${store.VAULT}`));
@@ -0,0 +1,234 @@
1
+ // The response loop: what the mailbox says about the applications, reconciled with the notes.
2
+ //
3
+ // The same local CLI that writes the letters reads the mail, through the Gmail connector the person has
4
+ // already attached to it. The run is boxed: only the three Gmail read tools are allowed and every write tool
5
+ // (send, reply, draft, label, trash, forward) plus Bash/Write/Edit are denied by name, so a run can read and
6
+ // nothing else, whatever the model decides. The model's job is extraction into a fixed JSON shape; matching
7
+ // those entries to notes, and everything that changes a note, is deterministic code here, and nothing changes
8
+ // until a person confirms an item. Applied, interviewing and offer stay human statuses; so does rejected,
9
+ // because a wrong "rejected" is the one a person would mind most.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { DATA_DIR, loadCriteria } from '../../scraper/config.mjs';
13
+ import { writeJobNote } from '../../scraper/vault.mjs';
14
+ import { scoreJob } from '../../scraper/score.mjs';
15
+ import { htmlToText } from '../../scraper/sources.mjs';
16
+ import * as store from './store.mjs';
17
+ import * as people from './people.mjs';
18
+ import { runLLM, runnerConfig } from './cover-letter.mjs';
19
+
20
+ const FILE = path.join(DATA_DIR, 'mail-check.json');
21
+ const READ_TOOLS = ['mcp__claude_ai_Gmail__search_threads', 'mcp__claude_ai_Gmail__get_thread', 'mcp__claude_ai_Gmail__get_message'];
22
+ const DENY_TOOLS = ['send_message', 'reply', 'forward', 'create_draft', 'update_draft', 'send_draft', 'delete_draft', 'trash_message', 'trash_thread', 'untrash_message', 'untrash_thread', 'label_message', 'label_thread', 'unlabel_message', 'unlabel_thread', 'update_message_labels', 'create_label', 'update_label', 'delete_label', 'mark_message_spam', 'mark_thread_spam', 'unmark_message_spam', 'unmark_thread_spam', 'apply_sensitive_message_label', 'apply_sensitive_thread_label']
23
+ .map((t) => `mcp__claude_ai_Gmail__${t}`).concat(['Bash', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'WebFetch', 'WebSearch']);
24
+ export const KINDS = ['confirmation', 'rejection', 'advance', 'scheduling', 'info-request', 'other'];
25
+ const SEARCHES = [
26
+ 'from:greenhouse.io', 'from:ashbyhq.com', 'from:lever.co OR from:hire.lever.co', 'from:myworkdayjobs.com OR from:workday.com', 'from:smartrecruiters.com OR from:jobvite.com OR from:icims.com OR from:workablemail.com OR from:rippling.com',
27
+ 'subject:("your application" OR "thank you for applying" OR "thanks for applying" OR "application received" OR "application update" OR "next steps" OR "not moving forward" OR interview)',
28
+ ];
29
+ const gmailLink = (id) => `https://mail.google.com/mail/u/0/#all/${id}`;
30
+
31
+ function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return { lastRun: null, sinceDays: null, items: [] }; } }
32
+ function persist(d) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(d, null, 2)); }
33
+
34
+ /** The extraction prompt. The companies list is a hint for recognition, not a filter: mail about others is still reported. */
35
+ export function prompt({ sinceDays = 21, companies = [] } = {}) {
36
+ return `You are checking a job seeker's mailbox for updates about job applications. Use ONLY the Gmail search and read tools. Do not send, reply, draft, label, forward, trash or modify anything.
37
+
38
+ Run these searches, each with newer_than:${sinceDays}d appended, then stop:
39
+ ${SEARCHES.map((s, i) => `${i + 1}. ${s} newer_than:${sinceDays}d`).join('\n')}
40
+
41
+ Search results already carry subject, sender, date and a snippet. Decide from those. Read a thread only when the subject and snippet do not say whether the message is a confirmation of receipt, a rejection, or an advance (an interview, a screen, a scheduling request). Do not read threads that are plainly confirmations.
42
+
43
+ Report every message that is about one of the person's job applications: confirmations of receipt, rejections, invitations to interview or to schedule, requests to complete or add to an application. Ignore job alerts, newsletters, recruiter cold outreach, and anything not tied to an application the person made.${companies.length ? `
44
+
45
+ The person's own records show applications at these companies (others may exist): ${companies.join(', ')}.` : ''}
46
+
47
+ Output ONLY a JSON array, no prose, no code fence, one object per message:
48
+ {"company": "company name as the email gives it", "role": "role title if stated, else empty string", "kind": "confirmation|rejection|advance|scheduling|info-request|other", "date": "YYYY-MM-DD", "gist": "one plain sentence", "from": "sender address", "fromName": "the sender's display name, or the name a person signed with if you read the message; empty for automated senders", "messageId": "gmail message id", "subject": "subject line"}
49
+
50
+ If nothing matches, output [].`;
51
+ }
52
+
53
+ /** The model's text into clean entries, or an error that says what came back. */
54
+ export function parseOutput(text) {
55
+ const s = String(text || '');
56
+ const a = s.indexOf('['), b = s.lastIndexOf(']');
57
+ if (a < 0 || b < a) throw new Error(`The runner returned no JSON array. It said: ${s.slice(0, 200).replace(/\s+/g, ' ')}`);
58
+ let arr; try { arr = JSON.parse(s.slice(a, b + 1)); } catch (e) { throw new Error(`The runner's JSON did not parse: ${e.message}`); }
59
+ if (!Array.isArray(arr)) throw new Error('The runner returned JSON that is not an array.');
60
+ return arr.filter((m) => m && typeof m === 'object' && m.company && m.messageId).map((m) => ({
61
+ company: String(m.company).trim(), role: String(m.role || '').trim(), kind: KINDS.includes(m.kind) ? m.kind : 'other',
62
+ date: /^\d{4}-\d{2}-\d{2}$/.test(String(m.date || '')) ? m.date : '', gist: String(m.gist || '').trim().slice(0, 300),
63
+ from: String(m.from || '').trim(), fromName: String(m.fromName || '').trim().slice(0, 80), messageId: String(m.messageId).trim(), subject: String(m.subject || '').trim().slice(0, 200),
64
+ }));
65
+ }
66
+
67
+ const norm = (s) => String(s || '').toLowerCase().replace(/\(.*?\)|\[.*?\]/g, '').replace(/\b(inc|llc|ltd|corp|co|the)\b/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
68
+ const APPLIED_ISH = new Set(['applying', 'ready', 'applied', 'interviewing', 'offer', 'rejected']);
69
+ const OPEN_ISH = new Set(['new', 'reviewing', 'applying', 'ready']);
70
+
71
+ /** What confirming an entry would do to its note. Pure, so it can be tested. */
72
+ export function suggestionFor(kind, noteStatus, date) {
73
+ if (!noteStatus) return { action: 'create', status: kind === 'rejection' ? 'rejected' : kind === 'advance' || kind === 'scheduling' ? 'interviewing' : 'applied', appliedOn: date };
74
+ if (kind === 'confirmation') return OPEN_ISH.has(noteStatus) ? { action: 'status', status: 'applied', appliedOn: date } : { action: 'record', appliedOn: date };
75
+ if (kind === 'rejection') return noteStatus === 'rejected' ? { action: 'record' } : { action: 'status', status: 'rejected' };
76
+ if (kind === 'advance' || kind === 'scheduling') return ['interviewing', 'offer'].includes(noteStatus) ? { action: 'record' } : { action: 'status', status: 'interviewing' };
77
+ return { action: 'record' };
78
+ }
79
+
80
+ /**
81
+ * Match mail entries to notes: same company by name, the exact role when the title matches, otherwise the
82
+ * note that is furthest along, with the other candidates listed so a person can pick. Pure.
83
+ */
84
+ export function reconcile(mails, notes) {
85
+ const byCompany = new Map();
86
+ for (const n of notes) { const k = norm(n.company); if (!k) continue; if (!byCompany.has(k)) byCompany.set(k, []); byCompany.get(k).push(n); }
87
+ const rank = (n) => (APPLIED_ISH.has(n.status) ? 2 : n.status === 'reviewing' ? 1 : 0);
88
+ return mails.map((m) => {
89
+ const key = norm(m.company);
90
+ let cands = byCompany.get(key) || [];
91
+ if (!cands.length && key) cands = [...byCompany.entries()].filter(([k]) => k.startsWith(key + ' ') || key.startsWith(k + ' ')).flatMap(([, v]) => v);
92
+ const exact = m.role ? cands.find((n) => norm(n.title) === norm(m.role)) : null;
93
+ const byRank = [...cands].sort((a, b) => rank(b) - rank(a) || (b.score || 0) - (a.score || 0));
94
+ // An email that names a role the vault does not have is a different application, not the note that
95
+ // happens to share the company: default to a new note and offer the company's notes as a pick. (Learned
96
+ // on the first real run, when a Staff Product Designer confirmation landed on an Engineering Manager note.)
97
+ const otherRole = !exact && m.role && cands.length > 0;
98
+ const best = exact || (otherRole ? null : byRank[0] || null);
99
+ const match = exact ? 'exact' : otherRole ? 'company-other-role' : best ? 'company' : 'none';
100
+ return {
101
+ id: m.messageId, ...m, match,
102
+ noteId: best ? best.id : '', noteTitle: best ? best.title : '', noteStatus: best ? best.status : '',
103
+ candidates: byRank.slice(0, 6).map((n) => ({ id: n.id, title: n.title, status: n.status })),
104
+ suggestion: suggestionFor(m.kind, best ? best.status : '', m.date),
105
+ // The human who wrote, if one did; confirming puts them on the note and in People.
106
+ person: people.fromMail(m),
107
+ state: 'pending',
108
+ };
109
+ });
110
+ }
111
+
112
+ // ---------------- grouping ----------------
113
+ // One application produces several emails (a confirmation, then scheduling mail, then a decision), and the
114
+ // mailbox holds each one. A person decides once per application, so pending items are grouped by company and
115
+ // role; the strongest kind speaks for the group and the rest are recorded with it when it is confirmed.
116
+ const STRENGTH = { rejection: 5, advance: 4, scheduling: 4, 'info-request': 2, confirmation: 1, other: 0 };
117
+ export function groupItems(items) {
118
+ const groups = new Map();
119
+ for (const i of items.filter((x) => x.state === 'pending')) {
120
+ const key = `${norm(i.company)}|${norm(i.role)}`;
121
+ if (!groups.has(key)) groups.set(key, []);
122
+ groups.get(key).push(i);
123
+ }
124
+ return [...groups.values()].map((members) => {
125
+ const sorted = [...members].sort((a, b) => (STRENGTH[b.kind] - STRENGTH[a.kind]) || (b.date || '').localeCompare(a.date || ''));
126
+ const rep = sorted[0];
127
+ return {
128
+ id: rep.id, ids: sorted.map((m) => m.id), count: members.length,
129
+ company: rep.company, role: rep.role || sorted.find((m) => m.role)?.role || '',
130
+ kind: rep.kind, kinds: [...new Set(sorted.map((m) => m.kind))], date: sorted.map((m) => m.date).sort().pop() || '',
131
+ first: sorted.map((m) => m.date).filter(Boolean).sort()[0] || '',
132
+ gist: rep.gist, subject: rep.subject, from: rep.from,
133
+ person: rep.person || sorted.find((m) => m.person)?.person || null,
134
+ match: rep.match, noteId: rep.noteId, noteTitle: rep.noteTitle, noteStatus: rep.noteStatus, candidates: rep.candidates, suggestion: rep.suggestion,
135
+ };
136
+ }).sort((a, b) => (STRENGTH[b.kind] - STRENGTH[a.kind]) || b.date.localeCompare(a.date));
137
+ }
138
+
139
+ // ---------------- running ----------------
140
+ const run = { running: false, startedAt: null, finishedAt: null, error: '', errorKind: '', sinceDays: null };
141
+ export function items() {
142
+ const d = load();
143
+ const all = d.items || [];
144
+ return { ...run, runner: runnerConfig().command, lastRun: d.lastRun, lastSinceDays: d.sinceDays, items: all, groups: groupItems(all) };
145
+ }
146
+ export function start({ sinceDays } = {}) {
147
+ if (run.running) return items();
148
+ const d = load();
149
+ // Default window: since the last check plus a day of overlap, or three weeks the first time.
150
+ const days = Number(sinceDays) || (d.lastRun ? Math.max(2, Math.ceil((Date.now() - Date.parse(d.lastRun)) / 864e5) + 1) : 21);
151
+ Object.assign(run, { running: true, startedAt: new Date().toISOString(), finishedAt: null, error: '', errorKind: '', sinceDays: days });
152
+ const notes = store.listJobs();
153
+ const companies = [...new Set(notes.filter((n) => APPLIED_ISH.has(n.status)).map((n) => n.company))].sort();
154
+ runLLM(prompt({ sinceDays: days, companies }), { timeoutMs: 15 * 60 * 1000, extraArgs: ['--allowedTools', ...READ_TOOLS, '--disallowedTools', ...DENY_TOOLS] })
155
+ .then((text) => {
156
+ const fresh = reconcile(parseOutput(text), store.listJobs());
157
+ const cur = load();
158
+ const known = new Map((cur.items || []).map((i) => [i.id, i]));
159
+ // A message already confirmed or dismissed keeps that state; everything else is re-matched against today's notes.
160
+ const merged = fresh.map((i) => { const k = known.get(i.id); return k && k.state !== 'pending' ? { ...k, ...i, state: k.state, resolved: k.resolved } : i; });
161
+ for (const k of known.values()) if (!merged.some((i) => i.id === k.id)) merged.push(k); // older windows stay
162
+ merged.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
163
+ persist({ lastRun: new Date().toISOString(), sinceDays: days, items: merged });
164
+ Object.assign(run, { running: false, finishedAt: new Date().toISOString() });
165
+ })
166
+ .catch((e) => Object.assign(run, { running: false, finishedAt: new Date().toISOString(), error: e.message, errorKind: e.kind || 'failed' }));
167
+ return items();
168
+ }
169
+
170
+ // ---------------- confirming ----------------
171
+ const mailLine = (i) => `Mail, ${i.date || 'undated'} (${i.kind}): ${i.gist} [message](${gmailLink(i.messageId)})`;
172
+
173
+ function appliedOnEmpty(id) {
174
+ try { return /^- \*\*Applied on:\*\*\s*$/m.test(fs.readFileSync(store.getJob(id).path, 'utf8')); } catch { return false; }
175
+ }
176
+
177
+ /**
178
+ * Do what the group's strongest email suggests, to the note the person chose (or the matched one), record
179
+ * every email in the group on that note, and mark them all confirmed.
180
+ */
181
+ export function confirm(id, { noteId } = {}) {
182
+ const d = load();
183
+ const group = groupItems(d.items || []).find((g) => g.ids.includes(id));
184
+ const item = (d.items || []).find((i) => i.id === (group ? group.id : id));
185
+ if (!item) throw Object.assign(new Error(`No mail item ${id}`), { status: 404 });
186
+ const members = (d.items || []).filter((i) => (group ? group.ids : [id]).includes(i.id));
187
+ // The earliest confirmation in the group is the application date; the strongest email decides the status.
188
+ const appliedOn = members.filter((m) => m.kind === 'confirmation' && m.date).map((m) => m.date).sort()[0] || item.date;
189
+ let target = noteId || item.noteId;
190
+ const s = target ? suggestionFor(item.kind, store.getJob(target).status, appliedOn) : suggestionFor(item.kind, '', appliedOn);
191
+ if (!target) {
192
+ if (s.action !== 'create') throw Object.assign(new Error('This entry matched no note; pick one or create one.'), { status: 400 });
193
+ const criteria = loadCriteria();
194
+ const job = { id: `mail:${item.messageId}`, source: 'mail', company: item.company, title: item.role || 'Role not stated in the email', url: gmailLink(item.messageId), location: '', remote: false, posted: item.date || null, salary: '', department: '', employmentType: '',
195
+ descriptionHtml: `<p>Created from an application email (${item.kind}, ${item.date || 'undated'}): ${item.gist}</p><p>Subject: ${item.subject}</p><p>From: ${item.from}</p>`, foundVia: 'created from an application email', addedBy: 'mail' };
196
+ job.descriptionText = htmlToText(job.descriptionHtml);
197
+ const file = writeJobNote(job, scoreJob(job, criteria), criteria);
198
+ target = path.basename(file, '.md');
199
+ store.cacheClear?.();
200
+ }
201
+ if (s.action === 'status' || s.action === 'create') store.setStatus(target, s.status, 'app');
202
+ if (appliedOn && members.some((m) => m.kind === 'confirmation') && appliedOnEmpty(target)) store.saveApplicationDraft(target, { field: 'Applied on', value: appliedOn });
203
+ // Oldest first, so the note's Notes read in the order the mail arrived.
204
+ for (const m of [...members].sort((a, b) => (a.date || '').localeCompare(b.date || ''))) store.addNote(target, mailLine(m), 'app');
205
+ // The humans who wrote go on the note's People and get the mail on their own Log; machines do not.
206
+ const persons = [...new Set(members.map((m) => { try { return people.recordFromMail(target, m, gmailLink(m.messageId)); } catch { return null; } }).filter(Boolean))];
207
+ const at = new Date().toISOString();
208
+ for (const m of members) { m.state = 'confirmed'; m.noteId = target; m.resolved = { at, action: s.action, status: s.status || '', persons }; }
209
+ persist(d);
210
+ return items();
211
+ }
212
+ /**
213
+ * The safe set in one go: confirmations whose note matched by exact title. Each one either records the mail on
214
+ * a note already marked applied or marks a still-open note applied as of the mail's date. Rejections, interview
215
+ * signals, company-only matches and creates are left for one-at-a-time, since those deserve a look.
216
+ */
217
+ export function safeGroups() {
218
+ return groupItems(load().items || []).filter((g) => g.kind === 'confirmation' && g.match === 'exact' && g.noteId);
219
+ }
220
+ export function confirmSafe() {
221
+ const done = [];
222
+ for (const g of safeGroups()) { try { confirm(g.id); done.push(g.id); } catch { /* the next group still gets its turn */ } }
223
+ return { confirmed: done.length, ...items() };
224
+ }
225
+ export function dismiss(id) {
226
+ const d = load();
227
+ const group = groupItems(d.items || []).find((g) => g.ids.includes(id));
228
+ const members = (d.items || []).filter((i) => (group ? group.ids : [id]).includes(i.id));
229
+ if (!members.length) throw Object.assign(new Error(`No mail item ${id}`), { status: 404 });
230
+ const at = new Date().toISOString();
231
+ for (const m of members) { m.state = 'dismissed'; m.resolved = { at, action: 'dismissed' }; }
232
+ persist(d);
233
+ return items();
234
+ }