@jsonresume/jobs 0.10.0 → 0.12.0

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/src/tui/useAI.js CHANGED
@@ -1,12 +1,64 @@
1
- import { useState, useCallback } from 'react';
1
+ import { useState, useCallback, useRef } from 'react';
2
+ import { writeFileSync } from 'fs';
2
3
  import { generateText } from 'ai';
3
4
  import { openai } from '@ai-sdk/openai';
4
5
 
6
+ function buildResumeText(resume) {
7
+ return [
8
+ resume?.basics?.name,
9
+ resume?.basics?.label,
10
+ resume?.basics?.summary,
11
+ ...(resume?.skills || []).map(
12
+ (s) => `${s.name}: ${(s.keywords || []).join(', ')}`
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
+ }
48
+
5
49
  export function useAI(resume) {
6
50
  const [text, setText] = useState('');
7
51
  const [loading, setLoading] = useState(false);
8
52
  const [error, setError] = useState(null);
53
+ const [mode, setMode] = useState('ai'); // 'ai' | 'cover'
9
54
  const hasKey = Boolean(process.env.OPENAI_API_KEY);
55
+ const childRef = useRef(null);
56
+ const dossierJobId = useRef(null);
57
+ // Local cache: jobId → { text, done, loading }
58
+ const dossierCache = useRef(new Map());
59
+ // Bump to trigger re-renders when dossier status changes
60
+ const [, setDossierTick] = useState(0);
61
+ const bumpTick = () => setDossierTick((t) => t + 1);
10
62
 
11
63
  const summarizeJob = useCallback(
12
64
  async (job) => {
@@ -16,6 +68,7 @@ export function useAI(resume) {
16
68
  );
17
69
  return;
18
70
  }
71
+ setMode('ai');
19
72
  setLoading(true);
20
73
  setError(null);
21
74
  setText('');
@@ -56,6 +109,291 @@ export function useAI(resume) {
56
109
  [resume, hasKey]
57
110
  );
58
111
 
112
+ const dossier = useCallback(
113
+ async (job, api) => {
114
+ const cached = dossierCache.current.get(job.id);
115
+
116
+ // If dossier is already loading or loaded for this job, just show it
117
+ if (cached && (cached.loading || cached.done)) {
118
+ setMode('cover');
119
+ setText(cached.text || '');
120
+ setLoading(cached.loading || false);
121
+ setError(null);
122
+ dossierJobId.current = job.id;
123
+ return;
124
+ }
125
+
126
+ setMode('cover');
127
+ setError(null);
128
+ setText('');
129
+ dossierJobId.current = job.id;
130
+ dossierCache.current.set(job.id, {
131
+ text: '',
132
+ done: false,
133
+ loading: true,
134
+ });
135
+ bumpTick();
136
+
137
+ // Helper to update both state and cache
138
+ // Only updates visible state if this job is still the active dossier
139
+ const updateText = (val) => {
140
+ const entry = dossierCache.current.get(job.id);
141
+ if (entry) entry.text = val;
142
+ if (dossierJobId.current === job.id) setText(val);
143
+ };
144
+
145
+ // Check server for existing dossier
146
+ try {
147
+ const { content } = await api.fetchDossier(job.id);
148
+ if (content) {
149
+ updateText(content);
150
+ dossierCache.current.set(job.id, {
151
+ text: content,
152
+ done: true,
153
+ loading: false,
154
+ });
155
+ bumpTick();
156
+ return;
157
+ }
158
+ } catch {
159
+ /* no cached dossier */
160
+ }
161
+
162
+ // Check if claude CLI is available
163
+ const { execSync } = await import('child_process');
164
+ let claudePath;
165
+ try {
166
+ claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
167
+ } catch {
168
+ setError(
169
+ 'Claude Code CLI not found. Install it: npm install -g @anthropic-ai/claude-code'
170
+ );
171
+ return;
172
+ }
173
+
174
+ // Kill any existing claude process
175
+ if (childRef.current) {
176
+ try {
177
+ childRef.current.kill();
178
+ } catch {}
179
+ childRef.current = null;
180
+ }
181
+
182
+ setLoading(true);
183
+
184
+ // Fetch resume if not available yet
185
+ let currentResume = resume;
186
+ if (!currentResume) {
187
+ try {
188
+ const me = await api.fetchMe();
189
+ currentResume = me.resume;
190
+ } catch {
191
+ /* proceed without resume */
192
+ }
193
+ }
194
+
195
+ // Fetch full job detail for raw content
196
+ let rawContent = '';
197
+ try {
198
+ const detail = await api.fetchJobDetail(job.id);
199
+ rawContent = detail.raw_content || detail.full_description || '';
200
+ } catch {}
201
+
202
+ const resumeText = buildResumeText(currentResume);
203
+ 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
+ try {
268
+ const { spawn } = await import('child_process');
269
+ // Remove CLAUDECODE env var to allow nested claude sessions
270
+ const env = { ...process.env };
271
+ delete env.CLAUDECODE;
272
+ delete env.CLAUDE_CODE_ENTRYPOINT;
273
+ delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
274
+
275
+ const child = spawn(
276
+ claudePath,
277
+ [
278
+ '--print',
279
+ '--output-format',
280
+ 'stream-json',
281
+ '--verbose',
282
+ '--allowedTools',
283
+ 'WebSearch',
284
+ 'WebFetch',
285
+ '--',
286
+ prompt,
287
+ ],
288
+ { stdio: ['ignore', 'pipe', 'pipe'], env }
289
+ );
290
+ childRef.current = child;
291
+
292
+ let finalResult = '';
293
+ let buffer = '';
294
+ let statusLine = '';
295
+
296
+ function processLine(line) {
297
+ if (!line.trim()) return;
298
+ try {
299
+ const event = JSON.parse(line);
300
+
301
+ if (event.type === 'assistant') {
302
+ // Extract text from assistant message content
303
+ const content = event.message?.content || [];
304
+ for (const block of content) {
305
+ if (block.type === 'text' && block.text) {
306
+ finalResult = block.text;
307
+ updateText(
308
+ statusLine ? `${statusLine}\n\n${finalResult}` : finalResult
309
+ );
310
+ } else if (block.type === 'tool_use') {
311
+ // Show what Claude is doing (e.g. WebSearch, WebFetch)
312
+ const name = block.name || 'tool';
313
+ const input = block.input || {};
314
+ if (name === 'WebSearch' || name === 'WebFetch') {
315
+ statusLine = `🔍 ${name}: ${
316
+ input.query || input.url || ''
317
+ }`;
318
+ } else {
319
+ statusLine = `⚙ Using ${name}…`;
320
+ }
321
+ updateText(
322
+ finalResult ? `${statusLine}\n\n${finalResult}` : statusLine
323
+ );
324
+ }
325
+ }
326
+ }
327
+
328
+ if (event.type === 'result' && event.result) {
329
+ finalResult = event.result;
330
+ updateText(finalResult);
331
+ }
332
+ } catch {
333
+ // Not valid JSON, skip
334
+ }
335
+ }
336
+
337
+ child.stdout.on('data', (chunk) => {
338
+ buffer += chunk.toString();
339
+ // Process complete JSON lines
340
+ const lines = buffer.split('\n');
341
+ buffer = lines.pop() || '';
342
+ for (const line of lines) {
343
+ processLine(line);
344
+ }
345
+ });
346
+
347
+ child.stderr.on('data', () => {});
348
+
349
+ await new Promise((resolve, reject) => {
350
+ child.on('close', (code) => {
351
+ // Only clear childRef if it's still our process
352
+ if (childRef.current === child) childRef.current = null;
353
+ // Process any remaining buffer
354
+ if (buffer.trim()) processLine(buffer);
355
+ if (code === 0) {
356
+ resolve();
357
+ } else if (code !== null) {
358
+ reject(new Error(`Claude exited with code ${code}`));
359
+ } else {
360
+ resolve(); // killed
361
+ }
362
+ });
363
+ child.on('error', (err) => {
364
+ if (childRef.current === child) childRef.current = null;
365
+ reject(err);
366
+ });
367
+ });
368
+
369
+ // Save to server if we got output
370
+ if (finalResult.trim()) {
371
+ try {
372
+ await api.saveDossier(job.id, finalResult);
373
+ } catch {}
374
+ }
375
+ } catch (err) {
376
+ if (err.message?.includes('exited with code')) {
377
+ setError(`Claude failed — ${err.message}`);
378
+ } else if (err.message !== 'killed') {
379
+ setError(err.message);
380
+ }
381
+ } finally {
382
+ const entry = dossierCache.current.get(job.id);
383
+ if (entry) {
384
+ entry.done = true;
385
+ entry.loading = false;
386
+ }
387
+ bumpTick();
388
+ if (dossierJobId.current === job.id) {
389
+ childRef.current = null;
390
+ setLoading(false);
391
+ }
392
+ }
393
+ },
394
+ [resume]
395
+ );
396
+
59
397
  const batchReview = useCallback(
60
398
  async (jobs) => {
61
399
  if (!hasKey) {
@@ -64,6 +402,7 @@ export function useAI(resume) {
64
402
  );
65
403
  return;
66
404
  }
405
+ setMode('ai');
67
406
  setLoading(true);
68
407
  setError(null);
69
408
  setText('');
@@ -99,13 +438,92 @@ export function useAI(resume) {
99
438
  [resume, hasKey]
100
439
  );
101
440
 
441
+ const cancel = useCallback(() => {
442
+ if (childRef.current) {
443
+ try {
444
+ childRef.current.kill();
445
+ } catch {}
446
+ childRef.current = null;
447
+ }
448
+ }, []);
449
+
450
+ const textRef = useRef('');
451
+ textRef.current = text;
452
+
453
+ const exportDossier = useCallback((job) => {
454
+ // Use visible text state, or fall back to cache
455
+ const content =
456
+ textRef.current || dossierCache.current.get(job?.id)?.text || '';
457
+ if (!content) return null;
458
+ const company = (job?.company || 'unknown')
459
+ .replace(/[^a-zA-Z0-9]+/g, '-')
460
+ .replace(/-+/g, '-')
461
+ .replace(/^-|-$/g, '')
462
+ .toLowerCase()
463
+ .slice(0, 50);
464
+ const filename = `dossier-${company}.md`;
465
+ writeFileSync(filename, content, 'utf-8');
466
+ return filename;
467
+ }, []);
468
+
469
+ // Seed cache with server-side dossier flags from job list
470
+ const seedDossierFlags = useCallback((jobs) => {
471
+ let changed = false;
472
+ for (const job of jobs) {
473
+ if (job.has_dossier && !dossierCache.current.has(job.id)) {
474
+ dossierCache.current.set(job.id, {
475
+ text: '',
476
+ done: true,
477
+ loading: false,
478
+ });
479
+ changed = true;
480
+ }
481
+ }
482
+ if (changed) bumpTick();
483
+ }, []);
484
+
485
+ // Expose dossier status for job list icons
486
+ // Returns: 'generating' | 'done' | null
487
+ const getDossierStatus = useCallback((jobId) => {
488
+ const entry = dossierCache.current.get(jobId);
489
+ if (!entry) return null;
490
+ if (entry.loading) return 'generating';
491
+ if (entry.done) return 'done';
492
+ return null;
493
+ }, []);
494
+
495
+ const regenerateDossier = useCallback(
496
+ (job, api) => {
497
+ // Clear cached dossier so dossier() starts fresh
498
+ dossierCache.current.delete(job.id);
499
+ cancel();
500
+ setText('');
501
+ setError(null);
502
+ bumpTick();
503
+ dossier(job, api);
504
+ },
505
+ [dossier, cancel]
506
+ );
507
+
102
508
  return {
103
509
  text,
104
510
  loading,
105
511
  error,
106
512
  hasKey,
513
+ mode,
514
+ hasActiveProcess: Boolean(childRef.current),
515
+ getDossierStatus,
516
+ seedDossierFlags,
107
517
  summarizeJob,
518
+ dossier,
519
+ regenerateDossier,
108
520
  batchReview,
109
- clear: () => setText(''),
521
+ exportDossier,
522
+ cancel,
523
+ clear: () => {
524
+ cancel();
525
+ setText('');
526
+ dossierJobId.current = null;
527
+ },
110
528
  };
111
529
  }
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
2
2
  import { getCached, setCache, updateCachedJob } from '../cache.js';
3
3
 
4
- export function useJobs(api, activeFilters, tab, searchId) {
4
+ export function useJobs(api, activeFilters, tab, searchId, getDossierStatus) {
5
5
  const [allJobs, setAllJobs] = useState([]);
6
6
  const [loading, setLoading] = useState(true);
7
7
  const [reranking, setReranking] = useState(false);
@@ -12,6 +12,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
12
12
  const params = useMemo(() => {
13
13
  const p = { top: 100, days: 30 };
14
14
  if (searchId) p.searchId = searchId;
15
+ if (api.mode) p.mode = api.mode;
15
16
  for (const f of activeFilters || []) {
16
17
  if (f.type === 'days') p.days = Number(f.value) || 30;
17
18
  if (f.type === 'remote') p.remote = true;
@@ -19,7 +20,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
19
20
  if (f.type === 'search') p.search = f.value || '';
20
21
  }
21
22
  return p;
22
- }, [activeFilters, searchId]);
23
+ }, [activeFilters, searchId, api.mode]);
23
24
 
24
25
  const paramsKey = JSON.stringify(params);
25
26
 
@@ -134,6 +135,22 @@ export function useJobs(api, activeFilters, tab, searchId) {
134
135
  }
135
136
  }
136
137
 
138
+ if (tab === 'new')
139
+ return filtered.filter(
140
+ (j) =>
141
+ !j.state &&
142
+ !j.has_dossier &&
143
+ (!getDossierStatus ||
144
+ (getDossierStatus(j.id) !== 'done' &&
145
+ getDossierStatus(j.id) !== 'generating'))
146
+ );
147
+ if (tab === 'reviewed')
148
+ return filtered.filter(
149
+ (j) =>
150
+ !j.state &&
151
+ (j.has_dossier ||
152
+ (getDossierStatus && getDossierStatus(j.id) === 'done'))
153
+ );
137
154
  if (tab === 'interested')
138
155
  return filtered.filter((j) => j.state === 'interested');
139
156
  if (tab === 'applied') return filtered.filter((j) => j.state === 'applied');
@@ -144,7 +161,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
144
161
  return filtered.filter(
145
162
  (j) => j.state !== 'not_interested' && j.state !== 'dismissed'
146
163
  );
147
- }, [allJobs, activeFilters, tab]);
164
+ }, [allJobs, activeFilters, tab, getDossierStatus]);
148
165
 
149
166
  const markJob = useCallback(
150
167
  async (id, state, feedback) => {