@jsonresume/jobs 0.14.1 → 0.14.2
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/LICENSE +21 -0
- package/bin/cli.js +2 -2
- package/package.json +9 -2
- package/src/formatters.js +54 -2
- package/src/formatters.test.js +107 -0
- package/src/tui/App.js +108 -265
- package/src/tui/SplitPane.js +58 -0
- package/src/tui/aiHelpers.js +129 -0
- package/src/tui/aiHelpers.test.js +200 -0
- package/src/tui/appKeyHandler.js +89 -0
- package/src/tui/appKeyHandler.test.js +144 -0
- package/src/tui/claudeDossier.js +97 -0
- package/src/tui/claudeDossier.test.js +40 -0
- package/src/tui/dossierCache.js +26 -0
- package/src/tui/dossierCache.test.js +40 -0
- package/src/tui/dossierPrompt.js +80 -0
- package/src/tui/dossierStream.js +48 -0
- package/src/tui/dossierStream.test.js +81 -0
- package/src/tui/gptReview.js +47 -0
- package/src/tui/jobFilters.js +69 -0
- package/src/tui/jobFilters.test.js +80 -0
- package/src/tui/useAI.js +35 -302
- package/src/tui/useJobs.js +7 -4
package/src/tui/useAI.js
CHANGED
|
@@ -1,50 +1,15 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from 'react';
|
|
2
2
|
import { writeFileSync } from 'fs';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
),
|
|
14
|
-
...(resume?.work || [])
|
|
15
|
-
.slice(0, 5)
|
|
16
|
-
.map(
|
|
17
|
-
(w) =>
|
|
18
|
-
`${w.position} at ${w.name}${
|
|
19
|
-
w.startDate ? ` (${w.startDate})` : ''
|
|
20
|
-
}: ${w.summary || ''}`
|
|
21
|
-
),
|
|
22
|
-
...(resume?.projects || [])
|
|
23
|
-
.slice(0, 3)
|
|
24
|
-
.map((p) => `Project: ${p.name} — ${p.description || ''}`),
|
|
25
|
-
]
|
|
26
|
-
.filter(Boolean)
|
|
27
|
-
.join('\n');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function buildJobText(job, rawContent) {
|
|
31
|
-
return [
|
|
32
|
-
`Title: ${job.title}`,
|
|
33
|
-
`Company: ${job.company}`,
|
|
34
|
-
`Salary: ${job.salary || 'Not listed'}`,
|
|
35
|
-
`Remote: ${job.remote || 'Not specified'}`,
|
|
36
|
-
job.experience ? `Experience: ${job.experience}` : '',
|
|
37
|
-
job.type ? `Type: ${job.type}` : '',
|
|
38
|
-
job.url ? `HN Post: ${job.url}` : '',
|
|
39
|
-
`Description: ${job.description || ''}`,
|
|
40
|
-
`Skills: ${(job.skills || []).map((s) => s.name || s).join(', ')}`,
|
|
41
|
-
...(job.qualifications || []).map((q) => `Qualification: ${q}`),
|
|
42
|
-
...(job.responsibilities || []).map((r) => `Responsibility: ${r}`),
|
|
43
|
-
rawContent ? `\nFull original posting:\n${rawContent}` : '',
|
|
44
|
-
]
|
|
45
|
-
.filter(Boolean)
|
|
46
|
-
.join('\n');
|
|
47
|
-
}
|
|
3
|
+
import {
|
|
4
|
+
buildResumeText,
|
|
5
|
+
buildJobText,
|
|
6
|
+
buildDossierPrompt,
|
|
7
|
+
extractEnrichment,
|
|
8
|
+
dossierFilename,
|
|
9
|
+
} from './aiHelpers.js';
|
|
10
|
+
import { resolveClaudePath, runClaudeDossier } from './claudeDossier.js';
|
|
11
|
+
import { runJobSummary, runBatchReview } from './gptReview.js';
|
|
12
|
+
import { dossierStatus, seedDossierFlags } from './dossierCache.js';
|
|
48
13
|
|
|
49
14
|
export function useAI(resume) {
|
|
50
15
|
const [text, setText] = useState('');
|
|
@@ -73,33 +38,7 @@ export function useAI(resume) {
|
|
|
73
38
|
setError(null);
|
|
74
39
|
setText('');
|
|
75
40
|
try {
|
|
76
|
-
|
|
77
|
-
resume?.basics?.label,
|
|
78
|
-
resume?.basics?.summary,
|
|
79
|
-
...(resume?.skills || []).map(
|
|
80
|
-
(s) => `${s.name}: ${(s.keywords || []).join(', ')}`
|
|
81
|
-
),
|
|
82
|
-
]
|
|
83
|
-
.filter(Boolean)
|
|
84
|
-
.join('\n');
|
|
85
|
-
|
|
86
|
-
const jobText = [
|
|
87
|
-
`Title: ${job.title}`,
|
|
88
|
-
`Company: ${job.company}`,
|
|
89
|
-
`Salary: ${job.salary || 'Not listed'}`,
|
|
90
|
-
`Remote: ${job.remote || 'Not specified'}`,
|
|
91
|
-
`Description: ${job.description || ''}`,
|
|
92
|
-
`Skills: ${(job.skills || []).map((s) => s.name).join(', ')}`,
|
|
93
|
-
].join('\n');
|
|
94
|
-
|
|
95
|
-
const { text: result } = await generateText({
|
|
96
|
-
model: openai('gpt-4o-mini'),
|
|
97
|
-
system:
|
|
98
|
-
'You are a career advisor. Given a job posting and resume, provide a concise fit analysis in 4-5 bullet points. Cover: why it fits, skill gaps, salary assessment, and remote compatibility. Be direct and opinionated.',
|
|
99
|
-
prompt: `Resume:\n${resumeText}\n\nJob:\n${jobText}`,
|
|
100
|
-
maxTokens: 400,
|
|
101
|
-
});
|
|
102
|
-
setText(result);
|
|
41
|
+
setText(await runJobSummary(resume, job));
|
|
103
42
|
} catch (err) {
|
|
104
43
|
setError(err.message);
|
|
105
44
|
} finally {
|
|
@@ -160,10 +99,9 @@ export function useAI(resume) {
|
|
|
160
99
|
}
|
|
161
100
|
|
|
162
101
|
// Check if claude CLI is available
|
|
163
|
-
const { execSync } = await import('child_process');
|
|
164
102
|
let claudePath;
|
|
165
103
|
try {
|
|
166
|
-
claudePath =
|
|
104
|
+
claudePath = await resolveClaudePath();
|
|
167
105
|
} catch {
|
|
168
106
|
setError(
|
|
169
107
|
'Claude Code CLI not found. Install it: npm install -g @anthropic-ai/claude-code'
|
|
@@ -201,181 +139,20 @@ export function useAI(resume) {
|
|
|
201
139
|
|
|
202
140
|
const resumeText = buildResumeText(currentResume);
|
|
203
141
|
const jobText = buildJobText(job, rawContent);
|
|
204
|
-
|
|
205
|
-
const prompt = `You are a job search research assistant. A candidate is considering applying to this role. Do thorough research and produce a comprehensive dossier.
|
|
206
|
-
|
|
207
|
-
## Candidate Resume
|
|
208
|
-
${resumeText}
|
|
209
|
-
|
|
210
|
-
## Job Posting
|
|
211
|
-
${jobText}
|
|
212
|
-
|
|
213
|
-
## Your Task
|
|
214
|
-
|
|
215
|
-
Research everything you can and produce a complete dossier covering:
|
|
216
|
-
|
|
217
|
-
### 1. Compensation Context
|
|
218
|
-
- Market rate for this role/level/location
|
|
219
|
-
- How the listed salary compares
|
|
220
|
-
- Negotiation leverage points
|
|
221
|
-
|
|
222
|
-
### 2. AI Engineering Culture
|
|
223
|
-
- Does the company use or encourage AI coding tools (Copilot, Claude Code, Cursor, agentic workflows)?
|
|
224
|
-
- Any public statements, blog posts, or job descriptions mentioning AI-assisted development?
|
|
225
|
-
- Is the engineering culture likely to embrace engineers who ship faster using AI tools, or is there resistance?
|
|
226
|
-
- Look for signals: do they mention "AI-native", "agentic", "LLM-augmented" workflows, or similar?
|
|
227
|
-
- Are there concerns about AI tool usage (IP policies, code review friction, etc.)?
|
|
228
|
-
- Rating: AI-Forward / AI-Friendly / Neutral / Unknown / AI-Resistant
|
|
229
|
-
|
|
230
|
-
### 3. Company Deep Dive
|
|
231
|
-
- What the company does, their products/services
|
|
232
|
-
- Funding stage, size, recent news
|
|
233
|
-
- Tech stack and engineering culture
|
|
234
|
-
- Leadership team
|
|
235
|
-
- Employee sentiment and Glassdoor/Blind highlights
|
|
236
|
-
- Any red flags or concerns
|
|
237
|
-
|
|
238
|
-
### 4. Role Analysis
|
|
239
|
-
- What you'd actually be doing day-to-day
|
|
240
|
-
- Team context — who you'd work with
|
|
241
|
-
- Growth trajectory for this role
|
|
242
|
-
- How this role fits into the company's current priorities
|
|
243
|
-
|
|
244
|
-
### 5. Fit Assessment
|
|
245
|
-
- Matching skills and experience (be specific, reference the candidate's actual background)
|
|
246
|
-
- Skill gaps to acknowledge or address
|
|
247
|
-
- Adjacent experience that transfers
|
|
248
|
-
- Overall fit rating: Strong / Good / Stretch
|
|
249
|
-
|
|
250
|
-
### 6. Cover Letter Talking Points
|
|
251
|
-
- 5-7 specific bullet points to mention, each referencing the candidate's real experience
|
|
252
|
-
- What angle to take (technical depth? leadership? domain expertise?)
|
|
253
|
-
- What to emphasize vs. downplay
|
|
254
|
-
|
|
255
|
-
### 7. Contact Info
|
|
256
|
-
- Email addresses from the posting
|
|
257
|
-
- Who posted (HN username from the URL if available)
|
|
258
|
-
- Best way to reach out
|
|
259
|
-
|
|
260
|
-
### 8. Interview Prep
|
|
261
|
-
- Questions they'll likely ask for this role
|
|
262
|
-
- Questions the candidate should ask
|
|
263
|
-
- Topics to research before interviewing
|
|
264
|
-
|
|
265
|
-
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations.
|
|
266
|
-
|
|
267
|
-
### IMPORTANT: Structured Data Block
|
|
268
|
-
At the very end of your response, output a JSON code block tagged \`\`\`enrichment with corrected/discovered job metadata. Only include fields where you found better data than what's in the posting. This helps improve the job database:
|
|
269
|
-
\`\`\`enrichment
|
|
270
|
-
{
|
|
271
|
-
"salary": "$X - $Y" or null if unknown,
|
|
272
|
-
"remote": "Full" | "Hybrid" | "None" | null,
|
|
273
|
-
"location": { "city": "...", "countryCode": "XX", "region": "..." } or null,
|
|
274
|
-
"experience": "Junior" | "Mid" | "Senior" | "Staff" | "Lead" | null,
|
|
275
|
-
"type": "Full-time" | "Contract" | "Part-time" | null
|
|
276
|
-
}
|
|
277
|
-
\`\`\``;
|
|
142
|
+
const prompt = buildDossierPrompt(resumeText, jobText);
|
|
278
143
|
|
|
279
144
|
try {
|
|
280
|
-
const
|
|
281
|
-
// Remove CLAUDECODE env var to allow nested claude sessions
|
|
282
|
-
const env = { ...process.env };
|
|
283
|
-
delete env.CLAUDECODE;
|
|
284
|
-
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
285
|
-
delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
286
|
-
|
|
287
|
-
const child = spawn(
|
|
145
|
+
const finalResult = await runClaudeDossier({
|
|
288
146
|
claudePath,
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
'WebSearch',
|
|
296
|
-
'WebFetch',
|
|
297
|
-
'--',
|
|
298
|
-
prompt,
|
|
299
|
-
],
|
|
300
|
-
{ stdio: ['ignore', 'pipe', 'pipe'], env }
|
|
301
|
-
);
|
|
302
|
-
childRef.current = child;
|
|
303
|
-
|
|
304
|
-
let finalResult = '';
|
|
305
|
-
let buffer = '';
|
|
306
|
-
let statusLine = '';
|
|
307
|
-
|
|
308
|
-
function processLine(line) {
|
|
309
|
-
if (!line.trim()) return;
|
|
310
|
-
try {
|
|
311
|
-
const event = JSON.parse(line);
|
|
312
|
-
|
|
313
|
-
if (event.type === 'assistant') {
|
|
314
|
-
// Extract text from assistant message content
|
|
315
|
-
const content = event.message?.content || [];
|
|
316
|
-
for (const block of content) {
|
|
317
|
-
if (block.type === 'text' && block.text) {
|
|
318
|
-
finalResult = block.text;
|
|
319
|
-
updateText(
|
|
320
|
-
statusLine ? `${statusLine}\n\n${finalResult}` : finalResult
|
|
321
|
-
);
|
|
322
|
-
} else if (block.type === 'tool_use') {
|
|
323
|
-
// Show what Claude is doing (e.g. WebSearch, WebFetch)
|
|
324
|
-
const name = block.name || 'tool';
|
|
325
|
-
const input = block.input || {};
|
|
326
|
-
if (name === 'WebSearch' || name === 'WebFetch') {
|
|
327
|
-
statusLine = `🔍 ${name}: ${
|
|
328
|
-
input.query || input.url || ''
|
|
329
|
-
}`;
|
|
330
|
-
} else {
|
|
331
|
-
statusLine = `⚙ Using ${name}…`;
|
|
332
|
-
}
|
|
333
|
-
updateText(
|
|
334
|
-
finalResult ? `${statusLine}\n\n${finalResult}` : statusLine
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (event.type === 'result' && event.result) {
|
|
341
|
-
finalResult = event.result;
|
|
342
|
-
updateText(finalResult);
|
|
343
|
-
}
|
|
344
|
-
} catch {
|
|
345
|
-
// Not valid JSON, skip
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
child.stdout.on('data', (chunk) => {
|
|
350
|
-
buffer += chunk.toString();
|
|
351
|
-
// Process complete JSON lines
|
|
352
|
-
const lines = buffer.split('\n');
|
|
353
|
-
buffer = lines.pop() || '';
|
|
354
|
-
for (const line of lines) {
|
|
355
|
-
processLine(line);
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
child.stderr.on('data', () => {});
|
|
360
|
-
|
|
361
|
-
await new Promise((resolve, reject) => {
|
|
362
|
-
child.on('close', (code) => {
|
|
147
|
+
prompt,
|
|
148
|
+
onText: updateText,
|
|
149
|
+
onChild: (child) => {
|
|
150
|
+
childRef.current = child;
|
|
151
|
+
},
|
|
152
|
+
onEnd: (child) => {
|
|
363
153
|
// Only clear childRef if it's still our process
|
|
364
154
|
if (childRef.current === child) childRef.current = null;
|
|
365
|
-
|
|
366
|
-
if (buffer.trim()) processLine(buffer);
|
|
367
|
-
if (code === 0) {
|
|
368
|
-
resolve();
|
|
369
|
-
} else if (code !== null) {
|
|
370
|
-
reject(new Error(`Claude exited with code ${code}`));
|
|
371
|
-
} else {
|
|
372
|
-
resolve(); // killed
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
child.on('error', (err) => {
|
|
376
|
-
if (childRef.current === child) childRef.current = null;
|
|
377
|
-
reject(err);
|
|
378
|
-
});
|
|
155
|
+
},
|
|
379
156
|
});
|
|
380
157
|
|
|
381
158
|
// Save to server if we got output
|
|
@@ -384,15 +161,12 @@ At the very end of your response, output a JSON code block tagged \`\`\`enrichme
|
|
|
384
161
|
await api.saveDossier(job.id, finalResult);
|
|
385
162
|
} catch {}
|
|
386
163
|
// Extract and save enrichment data if present
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
);
|
|
391
|
-
if (enrichMatch) {
|
|
392
|
-
const enriched = JSON.parse(enrichMatch[1]);
|
|
164
|
+
const enriched = extractEnrichment(finalResult);
|
|
165
|
+
if (enriched) {
|
|
166
|
+
try {
|
|
393
167
|
await api.enrichJob(job.id, enriched);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
396
170
|
}
|
|
397
171
|
} catch (err) {
|
|
398
172
|
if (err.message?.includes('exited with code')) {
|
|
@@ -429,28 +203,7 @@ At the very end of your response, output a JSON code block tagged \`\`\`enrichme
|
|
|
429
203
|
setError(null);
|
|
430
204
|
setText('');
|
|
431
205
|
try {
|
|
432
|
-
|
|
433
|
-
.filter(Boolean)
|
|
434
|
-
.join('\n');
|
|
435
|
-
|
|
436
|
-
const jobsList = jobs
|
|
437
|
-
.slice(0, 15)
|
|
438
|
-
.map(
|
|
439
|
-
(j, i) =>
|
|
440
|
-
`${i + 1}. [${j.similarity}] ${j.title} at ${j.company} | ${
|
|
441
|
-
j.salary || 'no salary'
|
|
442
|
-
} | ${j.remote || 'no remote info'}`
|
|
443
|
-
)
|
|
444
|
-
.join('\n');
|
|
445
|
-
|
|
446
|
-
const { text: result } = await generateText({
|
|
447
|
-
model: openai('gpt-4o-mini'),
|
|
448
|
-
system:
|
|
449
|
-
'You are a career advisor. Rank these jobs by fit for the candidate. For each, give a 1-line verdict. Be direct.',
|
|
450
|
-
prompt: `Resume:\n${resumeText}\n\nJobs:\n${jobsList}`,
|
|
451
|
-
maxTokens: 600,
|
|
452
|
-
});
|
|
453
|
-
setText(result);
|
|
206
|
+
setText(await runBatchReview(resume, jobs));
|
|
454
207
|
} catch (err) {
|
|
455
208
|
setError(err.message);
|
|
456
209
|
} finally {
|
|
@@ -477,42 +230,22 @@ At the very end of your response, output a JSON code block tagged \`\`\`enrichme
|
|
|
477
230
|
const content =
|
|
478
231
|
textRef.current || dossierCache.current.get(job?.id)?.text || '';
|
|
479
232
|
if (!content) return null;
|
|
480
|
-
const
|
|
481
|
-
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
482
|
-
.replace(/-+/g, '-')
|
|
483
|
-
.replace(/^-|-$/g, '')
|
|
484
|
-
.toLowerCase()
|
|
485
|
-
.slice(0, 50);
|
|
486
|
-
const filename = `dossier-${company}.md`;
|
|
233
|
+
const filename = dossierFilename(job);
|
|
487
234
|
writeFileSync(filename, content, 'utf-8');
|
|
488
235
|
return filename;
|
|
489
236
|
}, []);
|
|
490
237
|
|
|
491
238
|
// Seed cache with server-side dossier flags from job list
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
for (const job of jobs) {
|
|
495
|
-
if (job.has_dossier && !dossierCache.current.has(job.id)) {
|
|
496
|
-
dossierCache.current.set(job.id, {
|
|
497
|
-
text: '',
|
|
498
|
-
done: true,
|
|
499
|
-
loading: false,
|
|
500
|
-
});
|
|
501
|
-
changed = true;
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
if (changed) bumpTick();
|
|
239
|
+
const seedFlags = useCallback((jobs) => {
|
|
240
|
+
if (seedDossierFlags(dossierCache.current, jobs)) bumpTick();
|
|
505
241
|
}, []);
|
|
506
242
|
|
|
507
243
|
// Expose dossier status for job list icons
|
|
508
244
|
// Returns: 'generating' | 'done' | null
|
|
509
|
-
const getDossierStatus = useCallback(
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (entry.done) return 'done';
|
|
514
|
-
return null;
|
|
515
|
-
}, []);
|
|
245
|
+
const getDossierStatus = useCallback(
|
|
246
|
+
(jobId) => dossierStatus(dossierCache.current, jobId),
|
|
247
|
+
[]
|
|
248
|
+
);
|
|
516
249
|
|
|
517
250
|
const regenerateDossier = useCallback(
|
|
518
251
|
(job, api) => {
|
|
@@ -535,7 +268,7 @@ At the very end of your response, output a JSON code block tagged \`\`\`enrichme
|
|
|
535
268
|
mode,
|
|
536
269
|
hasActiveProcess: Boolean(childRef.current),
|
|
537
270
|
getDossierStatus,
|
|
538
|
-
seedDossierFlags,
|
|
271
|
+
seedDossierFlags: seedFlags,
|
|
539
272
|
summarizeJob,
|
|
540
273
|
dossier,
|
|
541
274
|
regenerateDossier,
|
package/src/tui/useJobs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
2
2
|
import { getCached, setCache, updateCachedJob } from '../cache.js';
|
|
3
|
+
import { normalizeLocation } from '../formatters.js';
|
|
3
4
|
|
|
4
5
|
export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
|
|
5
6
|
const [allJobs, setAllJobs] = useState([]);
|
|
@@ -10,7 +11,7 @@ export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
|
|
|
10
11
|
|
|
11
12
|
// Build API params from active filters array
|
|
12
13
|
const params = useMemo(() => {
|
|
13
|
-
const p = { top:
|
|
14
|
+
const p = { top: 200, days: 90 };
|
|
14
15
|
if (searchId) p.searchId = searchId;
|
|
15
16
|
if (api.mode) p.mode = api.mode;
|
|
16
17
|
for (const f of activeFilters || []) {
|
|
@@ -119,9 +120,11 @@ export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
|
|
|
119
120
|
|
|
120
121
|
for (const f of activeFilters || []) {
|
|
121
122
|
if (f.type === 'remote') {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
123
|
+
// location may be an object {city,region,countryCode} (canonical wire
|
|
124
|
+
// shape) or a historic string; normalizeLocation handles both and also
|
|
125
|
+
// folds in the separate `remote` field. The old `/remote/i.test(...)`
|
|
126
|
+
// silently failed on object locations (it tested "[object Object]").
|
|
127
|
+
filtered = filtered.filter((j) => normalizeLocation(j).remote);
|
|
125
128
|
}
|
|
126
129
|
if (f.type === 'globalRemote') {
|
|
127
130
|
filtered = filtered.filter((j) => j.global_remote === true);
|