@timurtekb/tekjobs 0.37.1 → 0.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/app/dist/assets/{index-C77iGKNO.js → index-tl8a2c_d.js} +7 -7
- package/app/dist/index.html +1 -1
- package/app/server/mcp.mjs +3 -3
- package/app/server/store.mjs +2 -1
- package/cli.mjs +6 -1
- package/package.json +1 -1
- package/run.mjs +1 -1
- package/scraper/health.mjs +4 -1
- package/scraper/profile.mjs +45 -12
- package/scraper/sources-email.mjs +7 -4
package/app/dist/index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="icon" href="/brand/tekjobs-avatar-192.png" type="image/png" sizes="192x192" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/brand/tekjobs-avatar-192.png" />
|
|
10
10
|
<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" />
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-tl8a2c_d.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-CR3ppGJT.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
package/app/server/mcp.mjs
CHANGED
|
@@ -48,9 +48,9 @@ export const TOOLS = [
|
|
|
48
48
|
{ name: 'set_criteria', description: 'Replace the criteria JSON block. Pass the full JSON as a string; it is validated before writing.', inputSchema: { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] } },
|
|
49
49
|
{ name: 'list_companies', description: 'The company watchlist rows (name, ats, slug, tier, status) with each board\'s health: state (failed, zero, stale, never, ok), last success, last attempt, last error.', inputSchema: { type: 'object', properties: {} } },
|
|
50
50
|
{ name: 'list_feeds', description: 'The aggregator feeds: whether each is on under the criteria\'s openSources, and how it did on the last scan.', inputSchema: { type: 'object', properties: {} } },
|
|
51
|
-
{ name: 'onboarding_status', description: 'What the onboarding still needs: profile folder, resume, profile note, criteria, first scan. Call this first in a new setup.', inputSchema: { type: 'object', properties: {} } },
|
|
51
|
+
{ name: 'onboarding_status', description: 'What the onboarding still needs, in two groups: `steps` for search ready (profile folder, resume, profile note, criteria, first scan; `complete` when all done) and `writing` for writing ready (resume of record note, Positioning, Voice; `writingReady` when all done). Call this first in a new setup, and once more at the end to report both.', inputSchema: { type: 'object', properties: {} } },
|
|
52
52
|
{ name: 'onboarding_materials', description: 'Start the onboarding interview. Returns the resume text, the current profile note, the current criteria JSON, and the interview script to follow step by step. Follow the script; it ends with save_profile, set_criteria and a scan.', inputSchema: { type: 'object', properties: {} } },
|
|
53
|
-
{ name: 'save_profile', description: 'Write Profile/Profile.md
|
|
53
|
+
{ name: 'save_profile', description: 'Write one profile note in full: Profile/Profile.md by default (markdown with the standard headings), or note=positioning for Profile/Positioning.md (which story leads, the evidence rule, what must never be claimed) or note=voice for Profile/Voice.md (writing rules and one letter they would send). The previous version is kept beside it.', inputSchema: { type: 'object', properties: { markdown: { type: 'string' }, note: { type: 'string', enum: ['profile', 'positioning', 'voice'], description: 'which note; default profile' } }, required: ['markdown'] } },
|
|
54
54
|
{ name: 'fetch_link', description: 'Fetch a public page the user gave you (portfolio, GitHub, personal site) as plain text, to fold into the profile. http(s) only; never use it on LinkedIn profile pages.', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
|
|
55
55
|
{ name: 'import_resume', description: 'Import a resume file from a local path (PDF, DOCX, Markdown, text): copies it into Profile/ and extracts its text for the interview.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
|
|
56
56
|
{ name: 'add_job', description: 'Add postings the person found themselves, from links (LinkedIn job pages, a company careers page, a Greenhouse/Lever/Ashby posting). Each link is read once, scored with the current criteria, and written as a job note unless the scan already has it or a note with the same company and title exists. A LinkedIn link is opened as a signed-out visitor; if it names the company\'s own apply page on a known board, that page is imported and the LinkedIn link is recorded as where it was seen. Returns one result per link. Up to 25 links.', inputSchema: { type: 'object', properties: { urls: { type: 'array', items: { type: 'string' } } }, required: ['urls'] } },
|
|
@@ -63,7 +63,7 @@ export async function call(name, a = {}) {
|
|
|
63
63
|
switch (name) {
|
|
64
64
|
case 'onboarding_status': return store.onboardingStatus();
|
|
65
65
|
case 'onboarding_materials': return store.onboardingMaterials();
|
|
66
|
-
case 'save_profile': return store.saveProfile(a.markdown);
|
|
66
|
+
case 'save_profile': return store.saveProfile(a.markdown, undefined, { note: a.note || 'profile' });
|
|
67
67
|
case 'fetch_link': return store.fetchLink(a.url);
|
|
68
68
|
case 'import_resume': return store.importResume(a.path);
|
|
69
69
|
case 'search_jobs': { const r = store.searchJobs({ q: a.q, location: a.location || '', status: a.status, band: a.band, kind: a.kind, source: a.source, company: a.company, payMin: a.payMin, payMax: a.payMax, payKnown: a.payKnown === true, postedDays: a.postedDays, foundDays: a.foundDays, maxScore: a.maxScore, remoteOnly: a.remote === true, minScore: a.minScore, limit: a.limit || 50 }); return { total: r.total, rows: r.rows.map((j) => ({ id: j.id, score: j.score, company: j.company, title: j.title, location: j.location, pay: j.salary, band: j.payBand, status: j.status, posted: j.posted, url: j.url })) }; }
|
package/app/server/store.mjs
CHANGED
|
@@ -1079,7 +1079,8 @@ export function profileSummary() {
|
|
|
1079
1079
|
{ label: 'Resume files', state: resumes.files.length ? `${resumes.files.length} file${resumes.files.length === 1 ? '' : 's'}${resumes.files.some((f) => f.current) ? ', one is the source' : ''}` : 'no folder yet', level: resumes.files.length ? 'ok' : 'info', where: resumes.dir || '' },
|
|
1080
1080
|
docState('Profile/Positioning.md', 'Positioning'),
|
|
1081
1081
|
docState('Profile/Voice.md', 'Voice'),
|
|
1082
|
-
|
|
1082
|
+
// The copy panel works from defaults drawn from the profile until the note exists, so its absence is information, not a gap.
|
|
1083
|
+
(() => { const d = docState('Profile/Snippets.md', 'Snippets (copy panel)'); return d.level === 'missing' ? { ...d, level: 'info', state: 'defaults from the profile until you edit it' } : d; })(),
|
|
1083
1084
|
];
|
|
1084
1085
|
// What would trip a reader.
|
|
1085
1086
|
const attention = [];
|
package/cli.mjs
CHANGED
|
@@ -208,8 +208,13 @@ async function main() {
|
|
|
208
208
|
}
|
|
209
209
|
function printStatus(s) {
|
|
210
210
|
console.log(`\nOnboarding for ${s.dir}:`);
|
|
211
|
+
console.log(' Search');
|
|
211
212
|
for (const st of s.steps) console.log(` ${st.done ? '✓' : '·'} ${st.label}${st.done ? '' : ` → ${st.how}`}`);
|
|
212
|
-
console.log(
|
|
213
|
+
console.log(' Application writing');
|
|
214
|
+
for (const st of s.writing || []) console.log(` ${st.done ? '✓' : '·'} ${st.label}${st.done ? '' : ` → ${st.how}`}`);
|
|
215
|
+
const search = s.complete ? 'Search ready: the daily scan takes it from here.' : 'Search: not yet; the first unchecked line above is next.';
|
|
216
|
+
const writing = s.writingReady ? 'Writing ready: drafts read the resume of record, Positioning and Voice.' : 'Writing: not yet; drafts fall back to the imported resume text until the notes above exist.';
|
|
217
|
+
console.log(`\n${search}\n${writing}`);
|
|
213
218
|
}
|
|
214
219
|
function run(bin, args) { const p = spawn(bin, args, { stdio: 'inherit', env: process.env }); p.on('close', (c) => process.exit(c ?? 0)); }
|
|
215
220
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timurtekb/tekjobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.1",
|
|
4
4
|
"description": "A local-first job-search machine: watches hundreds of company job boards, scores every posting against your profile, and files matches into a folder of markdown notes you own. Bring your own LLM through MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/run.mjs
CHANGED
|
@@ -195,7 +195,7 @@ const logLines = [
|
|
|
195
195
|
`- Criteria: ${criteriaLabel}`,
|
|
196
196
|
// Who started it: the morning task (run.cmd / run.sh), the app, an agent over MCP, or someone at the CLI.
|
|
197
197
|
`- Via: ${process.env.TEKJOBS_RUN_VIA || 'cli'}`,
|
|
198
|
-
...extras.map(([n, r]) => `- ${n}: ${r.ok ? `${r.jobs.length} postings${r.thread ? ` (${r.thread})` : ''}` : `failed: ${r.error}`}`),
|
|
198
|
+
...extras.map(([n, r]) => `- ${n}: ${r.skipped ? `skipped (${r.skipped})` : r.ok ? `${r.jobs.length} postings${r.thread ? ` (${r.thread})` : ''}` : `failed: ${r.error}`}`),
|
|
199
199
|
...(failed.length ? [`- Failed slugs: ${failed.map((f) => `${f.name} (${f.ats}:${f.slug} — ${f.error})`).join('; ')}`] : []),
|
|
200
200
|
...(written.length ? ['', '### New matches', ...written.map(({ job }) => `- **${job.scored.score}** ${job.company} — [${job.title}](${job.url}) · ${job.location || 'n/a'}`)] : []),
|
|
201
201
|
...(closed.length ? ['', '### Closed since last run', ...closed.map((c) => `- ${c.company} — ${c.title}`)] : []),
|
package/scraper/health.mjs
CHANGED
|
@@ -19,10 +19,12 @@ const require_dir = (file) => file.replace(/[\\/][^\\/]*$/, '');
|
|
|
19
19
|
/** Fold one fetch result into a source's record. `r` is the fetcher's own answer: ok, jobs, error, emptyBoard. */
|
|
20
20
|
export function record(prev = {}, r, now = new Date().toISOString()) {
|
|
21
21
|
const jobs = r.ok ? r.jobs.length : null;
|
|
22
|
+
// Skipped (an optional source with nothing to read): the attempt is noted, nothing else moves.
|
|
23
|
+
if (r.skipped) return { ...prev, lastAttempt: now, lastError: '', failStreak: 0, skipped: r.skipped };
|
|
22
24
|
if (!r.ok) return { ...prev, lastAttempt: now, lastError: r.error || 'failed', failStreak: (prev.failStreak || 0) + 1 };
|
|
23
25
|
return {
|
|
24
26
|
...prev,
|
|
25
|
-
lastAttempt: now, lastOk: now, lastError: '', failStreak: 0,
|
|
27
|
+
lastAttempt: now, lastOk: now, lastError: '', failStreak: 0, skipped: '',
|
|
26
28
|
lastJobs: jobs, lastOkJobs: jobs,
|
|
27
29
|
zeroStreak: jobs === 0 ? (prev.zeroStreak || 0) + 1 : 0,
|
|
28
30
|
everJobs: Math.max(prev.everJobs || 0, jobs),
|
|
@@ -42,6 +44,7 @@ export function healthState(h, statusCell = '', now = Date.now()) {
|
|
|
42
44
|
return statusCell ? 'ok' : 'never';
|
|
43
45
|
}
|
|
44
46
|
if (h.lastError && (!h.lastOk || h.lastAttempt >= h.lastOk)) return 'failed';
|
|
47
|
+
if (h.skipped && (!h.lastOk || h.lastAttempt > h.lastOk)) return 'never';
|
|
45
48
|
if (!h.lastOk || now - Date.parse(h.lastOk) > STALE_DAYS * 864e5) return 'stale';
|
|
46
49
|
if (h.lastOkJobs === 0) return 'zero';
|
|
47
50
|
return 'ok';
|
package/scraper/profile.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { P, VAULT, rememberProfileDir, loadCriteria } from './config.mjs';
|
|
6
|
+
import { P, VAULT, rememberProfileDir, loadCriteria, profileSettings, writeProfileSettings } from './config.mjs';
|
|
7
|
+
import { RESUME_NOTE, LEGACY_RESUME_NOTES } from './resume-sync.mjs';
|
|
7
8
|
import { extractText } from './resume.mjs';
|
|
8
9
|
|
|
9
10
|
const STARTER = fileURLToPath(new URL('./starter/', import.meta.url));
|
|
@@ -59,7 +60,20 @@ export async function importResume(file, dir = VAULT) {
|
|
|
59
60
|
if (path.resolve(file) !== path.resolve(dest)) fs.copyFileSync(file, dest);
|
|
60
61
|
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`;
|
|
61
62
|
fs.writeFileSync(path.join(profileDir, 'Resume - Source.md'), md);
|
|
62
|
-
|
|
63
|
+
// The same import establishes the resume of record (Profile/Resume.md) when the profile has none, in the shape
|
|
64
|
+
// `tekjobs resume sync` writes, and remembers the imported file as the source so a later sync re-reads it. A
|
|
65
|
+
// record that already exists is left alone: replacing it is what `resume sync <file>` is for, and it says so.
|
|
66
|
+
const recordRel = [RESUME_NOTE, ...LEGACY_RESUME_NOTES].find((rel) => fs.existsSync(path.join(dir, rel)));
|
|
67
|
+
let record = recordRel || '';
|
|
68
|
+
let recordCreated = false;
|
|
69
|
+
if (!recordRel) {
|
|
70
|
+
const note = ['---', 'type: resume', 'status: current', `source: ${JSON.stringify(dest)}`, 'source_kind: file', `synced: ${today()}`, '---',
|
|
71
|
+
`> **Resume of record, created from the import on ${today()} by \`tekjobs resume\`.** Application drafts read this note and only claim what it says. To replace it, import again with \`tekjobs resume sync <file>\` or pick a file on the Profile page.`, '', text.trim(), ''].join('\n');
|
|
72
|
+
fs.writeFileSync(path.join(dir, RESUME_NOTE), note);
|
|
73
|
+
record = RESUME_NOTE; recordCreated = true;
|
|
74
|
+
}
|
|
75
|
+
if (path.resolve(dir) === VAULT && !profileSettings().resumeSource) writeProfileSettings({ resumeSource: dest });
|
|
76
|
+
return { original: dest, source: path.join(profileDir, 'Resume - Source.md'), chars: text.length, record: path.join(dir, record), recordCreated };
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/** What the onboarding still needs. Drives the CLI, the app's Onboarding screen and the MCP status tool. */
|
|
@@ -71,6 +85,8 @@ export function onboardingStatus(dir = VAULT) {
|
|
|
71
85
|
let criteria = null; let criteriaFilled = false;
|
|
72
86
|
try { criteria = loadCriteria(); criteriaFilled = Object.keys(criteria.titleTerms || {}).length > 0; } catch { /* absent or invalid */ }
|
|
73
87
|
const jobs = exists('Jobs') ? fs.readdirSync(path.join(dir, 'Jobs')).filter((f) => f.endsWith('.md')).length : 0;
|
|
88
|
+
// Two contracts. "Search ready" is the five steps the scan needs; "writing ready" is what the application drafts
|
|
89
|
+
// need on top, so a person is never told the setup is complete and then shown a Profile page full of warnings.
|
|
74
90
|
const steps = [
|
|
75
91
|
{ id: 'folder', label: 'Profile folder exists', done: exists('Targets/Search Criteria.md') && exists('Targets/Companies.md'), how: 'tekjobs init [dir]' },
|
|
76
92
|
{ 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>' },
|
|
@@ -78,7 +94,12 @@ export function onboardingStatus(dir = VAULT) {
|
|
|
78
94
|
{ id: 'criteria', label: 'Search criteria filled (title terms set)', done: criteriaFilled, how: 'The interview writes them; or edit Targets/Search Criteria.md.' },
|
|
79
95
|
{ id: 'scan', label: 'First scan has run', done: jobs > 0, how: 'tekjobs scan, or the Runs screen.' },
|
|
80
96
|
];
|
|
81
|
-
|
|
97
|
+
const writing = [
|
|
98
|
+
{ id: 'record', label: 'Resume of record note (Profile/Resume.md)', done: [RESUME_NOTE, ...LEGACY_RESUME_NOTES].some(exists), how: 'tekjobs resume <file> creates it from the import; tekjobs resume sync <url|file> replaces it later.' },
|
|
99
|
+
{ id: 'positioning', label: 'Positioning note: which story leads, the evidence rule, what is never claimed', done: exists('Profile/Positioning.md'), how: 'The interview writes it (save_profile with note=positioning); or write Profile/Positioning.md yourself.' },
|
|
100
|
+
{ id: 'voice', label: 'Voice note: how you write, with one letter you would send', done: exists('Profile/Voice.md'), how: 'The interview writes it (save_profile with note=voice); or write Profile/Voice.md yourself.' },
|
|
101
|
+
];
|
|
102
|
+
return { dir, steps, complete: steps.every((s) => s.done), writing, writingReady: writing.every((s) => s.done), jobs, profilePath: path.join(dir, 'Profile', 'Profile.md') };
|
|
82
103
|
}
|
|
83
104
|
|
|
84
105
|
/** Everything the interviewing LLM needs in one call: resume text, current notes, and the script to follow. */
|
|
@@ -95,16 +116,24 @@ export function onboardingMaterials(dir = VAULT) {
|
|
|
95
116
|
};
|
|
96
117
|
}
|
|
97
118
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
const PROFILE_NOTES = {
|
|
120
|
+
profile: { file: 'Profile.md', type: 'profile', min: 100 },
|
|
121
|
+
positioning: { file: 'Positioning.md', type: 'positioning', min: 80 },
|
|
122
|
+
voice: { file: 'Voice.md', type: 'voice', min: 60 },
|
|
123
|
+
};
|
|
124
|
+
/** Write one of the profile notes in full: Profile.md by default, or Positioning.md / Voice.md with `note`. The previous version is kept beside it. */
|
|
125
|
+
export function saveProfile(markdown, dir = VAULT, { note = 'profile' } = {}) {
|
|
126
|
+
const spec = PROFILE_NOTES[note];
|
|
127
|
+
if (!spec) throw Object.assign(new Error(`note must be one of ${Object.keys(PROFILE_NOTES).join(', ')}`), { status: 400 });
|
|
128
|
+
if (!markdown || markdown.trim().length < spec.min) throw Object.assign(new Error(`${spec.file} is too short to save; write the full note.`), { status: 400 });
|
|
129
|
+
let md = note === 'profile' ? markdown.replace(/^status: draft$/m, 'status: interviewed') : markdown;
|
|
130
|
+
if (!/^---\n/.test(md)) md = `---\ntype: ${spec.type}\nupdated: ${today()}${note === 'profile' ? '\nstatus: interviewed' : ''}\n---\n${md}`;
|
|
102
131
|
md = md.replace(/^updated: .*$/m, `updated: ${today()}`);
|
|
103
|
-
const file = path.join(dir, 'Profile',
|
|
132
|
+
const file = path.join(dir, 'Profile', spec.file);
|
|
104
133
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
105
|
-
if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dir, 'Profile',
|
|
134
|
+
if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dir, 'Profile', `${spec.file.replace(/\.md$/, '')}.before-${today()}.md`));
|
|
106
135
|
fs.writeFileSync(file, md);
|
|
107
|
-
return { saved: file };
|
|
136
|
+
return { saved: file, note };
|
|
108
137
|
}
|
|
109
138
|
|
|
110
139
|
/** Fetch a public page (portfolio, GitHub, LinkedIn export is a file, not a URL) as text for the interview to summarize. */
|
|
@@ -132,7 +161,9 @@ const INTERVIEW_SCRIPT = `You are onboarding a job seeker into TekJobs. Goal: wr
|
|
|
132
161
|
- Company stage or size preference, if any.
|
|
133
162
|
- 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.
|
|
134
163
|
- Earliest start date.
|
|
164
|
+
- How they write: two or three rules in their own words, and words they refuse to use. And anything that must never be claimed about them (a title they did not hold, work they did not do).
|
|
135
165
|
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.
|
|
166
|
+
3b. Write the two notes the application drafts read, from the same answers, and save each with save_profile: note=positioning for Profile/Positioning.md (headings: Which story leads · The evidence rule · What must never be claimed; the evidence rule is that every claim traces to a line of the resume), and note=voice for Profile/Voice.md (headings: Rules · A letter I would send; the letter is under 120 words, in their voice, using only facts from the resume). Show both to the user before saving and change what they object to.
|
|
136
167
|
4. Build the criteria from the current JSON (keep every key). Set:
|
|
137
168
|
- 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").
|
|
138
169
|
- titleExclude: add the user's hard exclusions as lowercase substrings (titles and words in titles; abbreviations like "sr." and "dir." are spelled out before matching).
|
|
@@ -142,7 +173,7 @@ const INTERVIEW_SCRIPT = `You are onboarding a job seeker into TekJobs. Goal: wr
|
|
|
142
173
|
- location.requireRemote true only if the user said remote only; add their metro to bayAreaTerms with bayAreaBoost 12 if hybrid there is acceptable.
|
|
143
174
|
Call set_criteria with the full JSON string. It is validated before writing.
|
|
144
175
|
5. Call run_scan with dry=true and poll scan_status every few seconds until running is false (two to three minutes; the scan runs on its own, so if this session ends first, the next one can pick up with scan_status). If you cannot poll between turns, pass wait=240 to run_scan and it answers when the scan is done. 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.
|
|
145
|
-
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.`;
|
|
176
|
+
6. Finish by telling the user where things live: Profile/Profile.md, Positioning.md, Voice.md, Targets/Search Criteria.md, Jobs/. Call onboarding_status once more and say plainly which of the two it reports: search ready, and writing ready. Remind them the daily scan runs on its own from here.`;
|
|
146
177
|
|
|
147
178
|
function criteriaNote(json) {
|
|
148
179
|
return `---
|
|
@@ -196,7 +227,9 @@ function readme(dir) {
|
|
|
196
227
|
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.
|
|
197
228
|
|
|
198
229
|
- \`Profile/Profile.md\` — who you are and what you want (written by the onboarding interview)
|
|
199
|
-
- \`Profile/Resume - Source.md\` — your resume as text
|
|
230
|
+
- \`Profile/Resume - Source.md\` — your resume as text, as imported
|
|
231
|
+
- \`Profile/Resume.md\` — the resume of record: what every draft is allowed to claim (created by the import; \`tekjobs resume sync\` replaces it)
|
|
232
|
+
- \`Profile/Positioning.md\` and \`Profile/Voice.md\` — how the story is told and how you write; the interview writes them
|
|
200
233
|
- \`Targets/Search Criteria.md\` — how postings are scored (JSON block)
|
|
201
234
|
- \`Targets/Companies.md\` — which boards are watched
|
|
202
235
|
- \`Jobs/\` — one note per match; change \`status:\` to move it through the pipeline
|
|
@@ -219,11 +219,13 @@ export function jobsFromEmail(raw, file = '') {
|
|
|
219
219
|
export async function fetchEmailInbox(criteria = {}) {
|
|
220
220
|
const cfg = criteria.email || {};
|
|
221
221
|
const dir = cfg.dir || (criteria._inbox ?? '');
|
|
222
|
-
|
|
223
|
-
|
|
222
|
+
// The inbox is optional. Nothing to read is a skip, not a failure: a first run with an empty Inbox/ is healthy,
|
|
223
|
+
// and a healthy run must not be reported as partial.
|
|
224
|
+
if (!dir) return { ok: true, jobs: [], skipped: 'no inbox folder configured' };
|
|
225
|
+
if (!fs.existsSync(dir)) return { ok: true, jobs: [], skipped: `no Inbox folder yet (${dir}); save alert emails into it as .eml to use it` };
|
|
224
226
|
|
|
225
227
|
const files = fs.readdirSync(dir).filter((f) => /\.(eml|txt|mht|mhtml)$/i.test(f));
|
|
226
|
-
if (!files.length) return { ok:
|
|
228
|
+
if (!files.length) return { ok: true, jobs: [], skipped: `no .eml files in ${dir}` };
|
|
227
229
|
|
|
228
230
|
const all = [];
|
|
229
231
|
const failures = [];
|
|
@@ -237,6 +239,7 @@ export async function fetchEmailInbox(criteria = {}) {
|
|
|
237
239
|
}
|
|
238
240
|
const byUrl = new Map();
|
|
239
241
|
for (const j of all) if (!byUrl.has(j.url)) byUrl.set(j.url, j);
|
|
240
|
-
if (!byUrl.size) return { ok: false, jobs: [], error:
|
|
242
|
+
if (!byUrl.size && failures.length) return { ok: false, jobs: [], error: `no jobs found; ${failures[0]}` };
|
|
243
|
+
if (!byUrl.size) return { ok: true, jobs: [], skipped: `no job links found in ${files.length} message(s)` };
|
|
241
244
|
return { ok: true, jobs: [...byUrl.values()] };
|
|
242
245
|
}
|