@jsonresume/jobs 0.9.0 → 0.11.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,19 +1,74 @@
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) => {
13
65
  if (!hasKey) {
14
- setError('Set OPENAI_API_KEY to enable AI features');
66
+ setError(
67
+ 'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
68
+ );
15
69
  return;
16
70
  }
71
+ setMode('ai');
17
72
  setLoading(true);
18
73
  setError(null);
19
74
  setText('');
@@ -54,12 +109,292 @@ export function useAI(resume) {
54
109
  [resume, hasKey]
55
110
  );
56
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. Company Deep Dive
218
+ - What the company does, their products/services
219
+ - Funding stage, size, recent news
220
+ - Tech stack and engineering culture
221
+ - Leadership team
222
+ - Employee sentiment and Glassdoor/Blind highlights
223
+ - Any red flags or concerns
224
+
225
+ ### 2. Role Analysis
226
+ - What you'd actually be doing day-to-day
227
+ - Team context — who you'd work with
228
+ - Growth trajectory for this role
229
+ - How this role fits into the company's current priorities
230
+
231
+ ### 3. Fit Assessment
232
+ - Matching skills and experience (be specific, reference the candidate's actual background)
233
+ - Skill gaps to acknowledge or address
234
+ - Adjacent experience that transfers
235
+ - Overall fit rating: Strong / Good / Stretch
236
+
237
+ ### 4. Cover Letter Talking Points
238
+ - 5-7 specific bullet points to mention, each referencing the candidate's real experience
239
+ - What angle to take (technical depth? leadership? domain expertise?)
240
+ - What to emphasize vs. downplay
241
+
242
+ ### 5. Contact Info
243
+ - Email addresses from the posting
244
+ - Who posted (HN username from the URL if available)
245
+ - Best way to reach out
246
+
247
+ ### 6. Interview Prep
248
+ - Questions they'll likely ask for this role
249
+ - Questions the candidate should ask
250
+ - Topics to research before interviewing
251
+
252
+ ### 7. Compensation Context
253
+ - Market rate for this role/level/location
254
+ - How the listed salary compares
255
+ - Negotiation leverage points
256
+
257
+ Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations.`;
258
+
259
+ try {
260
+ const { spawn } = await import('child_process');
261
+ // Remove CLAUDECODE env var to allow nested claude sessions
262
+ const env = { ...process.env };
263
+ delete env.CLAUDECODE;
264
+ delete env.CLAUDE_CODE_ENTRYPOINT;
265
+ delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
266
+
267
+ const child = spawn(
268
+ claudePath,
269
+ [
270
+ '--print',
271
+ '--output-format',
272
+ 'stream-json',
273
+ '--verbose',
274
+ '--allowedTools',
275
+ 'WebSearch',
276
+ 'WebFetch',
277
+ '--',
278
+ prompt,
279
+ ],
280
+ { stdio: ['ignore', 'pipe', 'pipe'], env }
281
+ );
282
+ childRef.current = child;
283
+
284
+ let finalResult = '';
285
+ let buffer = '';
286
+ let statusLine = '';
287
+
288
+ function processLine(line) {
289
+ if (!line.trim()) return;
290
+ try {
291
+ const event = JSON.parse(line);
292
+
293
+ if (event.type === 'assistant') {
294
+ // Extract text from assistant message content
295
+ const content = event.message?.content || [];
296
+ for (const block of content) {
297
+ if (block.type === 'text' && block.text) {
298
+ finalResult = block.text;
299
+ updateText(
300
+ statusLine ? `${statusLine}\n\n${finalResult}` : finalResult
301
+ );
302
+ } else if (block.type === 'tool_use') {
303
+ // Show what Claude is doing (e.g. WebSearch, WebFetch)
304
+ const name = block.name || 'tool';
305
+ const input = block.input || {};
306
+ if (name === 'WebSearch' || name === 'WebFetch') {
307
+ statusLine = `🔍 ${name}: ${
308
+ input.query || input.url || ''
309
+ }`;
310
+ } else {
311
+ statusLine = `⚙ Using ${name}…`;
312
+ }
313
+ updateText(
314
+ finalResult ? `${statusLine}\n\n${finalResult}` : statusLine
315
+ );
316
+ }
317
+ }
318
+ }
319
+
320
+ if (event.type === 'result' && event.result) {
321
+ finalResult = event.result;
322
+ updateText(finalResult);
323
+ }
324
+ } catch {
325
+ // Not valid JSON, skip
326
+ }
327
+ }
328
+
329
+ child.stdout.on('data', (chunk) => {
330
+ buffer += chunk.toString();
331
+ // Process complete JSON lines
332
+ const lines = buffer.split('\n');
333
+ buffer = lines.pop() || '';
334
+ for (const line of lines) {
335
+ processLine(line);
336
+ }
337
+ });
338
+
339
+ child.stderr.on('data', () => {});
340
+
341
+ await new Promise((resolve, reject) => {
342
+ child.on('close', (code) => {
343
+ // Only clear childRef if it's still our process
344
+ if (childRef.current === child) childRef.current = null;
345
+ // Process any remaining buffer
346
+ if (buffer.trim()) processLine(buffer);
347
+ if (code === 0) {
348
+ resolve();
349
+ } else if (code !== null) {
350
+ reject(new Error(`Claude exited with code ${code}`));
351
+ } else {
352
+ resolve(); // killed
353
+ }
354
+ });
355
+ child.on('error', (err) => {
356
+ if (childRef.current === child) childRef.current = null;
357
+ reject(err);
358
+ });
359
+ });
360
+
361
+ // Save to server if we got output
362
+ if (finalResult.trim()) {
363
+ try {
364
+ await api.saveDossier(job.id, finalResult);
365
+ } catch {}
366
+ }
367
+ } catch (err) {
368
+ if (err.message?.includes('exited with code')) {
369
+ setError(`Claude failed — ${err.message}`);
370
+ } else if (err.message !== 'killed') {
371
+ setError(err.message);
372
+ }
373
+ } finally {
374
+ const entry = dossierCache.current.get(job.id);
375
+ if (entry) {
376
+ entry.done = true;
377
+ entry.loading = false;
378
+ }
379
+ bumpTick();
380
+ if (dossierJobId.current === job.id) {
381
+ childRef.current = null;
382
+ setLoading(false);
383
+ }
384
+ }
385
+ },
386
+ [resume]
387
+ );
388
+
57
389
  const batchReview = useCallback(
58
390
  async (jobs) => {
59
391
  if (!hasKey) {
60
- setError('Set OPENAI_API_KEY to enable AI features');
392
+ setError(
393
+ 'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
394
+ );
61
395
  return;
62
396
  }
397
+ setMode('ai');
63
398
  setLoading(true);
64
399
  setError(null);
65
400
  setText('');
@@ -95,13 +430,61 @@ export function useAI(resume) {
95
430
  [resume, hasKey]
96
431
  );
97
432
 
433
+ const cancel = useCallback(() => {
434
+ if (childRef.current) {
435
+ try {
436
+ childRef.current.kill();
437
+ } catch {}
438
+ childRef.current = null;
439
+ }
440
+ }, []);
441
+
442
+ const textRef = useRef('');
443
+ textRef.current = text;
444
+
445
+ const exportDossier = useCallback((job) => {
446
+ // Use visible text state, or fall back to cache
447
+ const content =
448
+ textRef.current || dossierCache.current.get(job?.id)?.text || '';
449
+ if (!content) return null;
450
+ const company = (job?.company || 'unknown')
451
+ .replace(/[^a-zA-Z0-9]+/g, '-')
452
+ .replace(/-+/g, '-')
453
+ .replace(/^-|-$/g, '')
454
+ .toLowerCase()
455
+ .slice(0, 50);
456
+ const filename = `dossier-${company}.md`;
457
+ writeFileSync(filename, content, 'utf-8');
458
+ return filename;
459
+ }, []);
460
+
461
+ // Expose dossier status for job list icons
462
+ // Returns: 'generating' | 'done' | null
463
+ const getDossierStatus = useCallback((jobId) => {
464
+ const entry = dossierCache.current.get(jobId);
465
+ if (!entry) return null;
466
+ if (entry.loading) return 'generating';
467
+ if (entry.done) return 'done';
468
+ return null;
469
+ }, []);
470
+
98
471
  return {
99
472
  text,
100
473
  loading,
101
474
  error,
102
475
  hasKey,
476
+ mode,
477
+ hasActiveProcess: Boolean(childRef.current),
478
+ getDossierStatus,
103
479
  summarizeJob,
480
+ dossier,
104
481
  batchReview,
105
- clear: () => setText(''),
482
+ exportDossier,
483
+ cancel,
484
+ clear: () => {
485
+ cancel();
486
+ setText('');
487
+ dossierJobId.current = null;
488
+ },
106
489
  };
107
490
  }
@@ -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
 
@@ -1,68 +0,0 @@
1
- import { Box, Text } from 'ink';
2
- import { h } from './h.js';
3
- import { formatSalary, formatLocation, stateIcon } from '../formatters.js';
4
-
5
- export default function PreviewPane({ job }) {
6
- if (!job) return null;
7
-
8
- const cols = process.stdout.columns || 80;
9
- const loc = formatLocation(job.location, job.remote);
10
- const sal = formatSalary(job.salary, job.salary_usd);
11
- const icon = stateIcon(job.state);
12
-
13
- const skills = job.skills
14
- ? job.skills
15
- .slice(0, 8)
16
- .map((s) => s.name || s)
17
- .join(' · ')
18
- : null;
19
-
20
- const desc = job.description
21
- ? job.description.replace(/\n/g, ' ').slice(0, cols * 2)
22
- : null;
23
-
24
- return h(
25
- Box,
26
- {
27
- flexDirection: 'column',
28
- borderStyle: 'single',
29
- borderColor: 'gray',
30
- borderTop: true,
31
- borderBottom: false,
32
- borderLeft: false,
33
- borderRight: false,
34
- paddingX: 1,
35
- },
36
- // Title line
37
- h(
38
- Box,
39
- { gap: 1 },
40
- h(Text, { bold: true, color: 'white' }, job.title || '—'),
41
- h(Text, { dimColor: true }, 'at'),
42
- h(Text, { bold: true, color: 'cyan' }, job.company || '—'),
43
- job.state ? h(Text, null, ` ${icon}`) : null
44
- ),
45
- // Meta line
46
- h(
47
- Box,
48
- { gap: 2 },
49
- h(Text, { dimColor: true }, `📍 ${loc}`),
50
- h(Text, { dimColor: true }, `💰 ${sal}`),
51
- job.experience
52
- ? h(Text, { dimColor: true }, `📊 ${job.experience}`)
53
- : null,
54
- job.url ? h(Text, { color: 'blue', dimColor: true }, 'o:open') : null
55
- ),
56
- // Skills
57
- skills
58
- ? h(
59
- Box,
60
- null,
61
- h(Text, { color: 'yellow' }, 'Skills: '),
62
- h(Text, { dimColor: true }, skills)
63
- )
64
- : null,
65
- // Description snippet
66
- desc ? h(Text, { dimColor: true, wrap: 'truncate-end' }, desc) : null
67
- );
68
- }