@timurtekb/tekjobs 0.48.0 → 0.50.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/CHANGELOG.md +12 -0
- package/README.md +2 -2
- package/app/dist/assets/{index-BAca4QLI.js → index-DmICnwI8.js} +5 -5
- package/app/dist/index.html +1 -1
- package/app/server/calendar-check.mjs +150 -0
- package/app/server/index.mjs +6 -0
- package/app/server/mcp.mjs +5 -0
- package/cli.mjs +21 -3
- package/package.json +1 -1
- package/run.cmd +3 -0
- package/run.sh +3 -1
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-DmICnwI8.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-DXU1rm72.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Calendar check: a read-only pass over the person's Google Calendar for interviews and other events about jobs
|
|
2
|
+
// on their board, through the same local CLI that writes letters and reads mail, with its Google Calendar
|
|
3
|
+
// connector. The run is boxed: only the connector's read tools are allowed and every write tool (create, update,
|
|
4
|
+
// delete, respond) plus Bash/Write/Edit are denied by name, so a run can read a calendar and nothing else.
|
|
5
|
+
//
|
|
6
|
+
// The model finds and extracts; everything after that is ordinary code. Each event is matched to a job note by
|
|
7
|
+
// company, then shown as a proposal. Confirming puts the event's time into the note's "Interview on" field (so
|
|
8
|
+
// the Calendar page and the .ics feed carry it), moves a note that is not yet interviewing to interviewing, and
|
|
9
|
+
// records the event on the note. Nothing is ever written to the calendar.
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { DATA_DIR } from '../../scraper/config.mjs';
|
|
13
|
+
import { sameCompany } from '../../scraper/linkedin.mjs';
|
|
14
|
+
import * as store from './store.mjs';
|
|
15
|
+
import { runLLM, runnerConfig } from './cover-letter.mjs';
|
|
16
|
+
|
|
17
|
+
const FILE = path.join(DATA_DIR, 'calendar-check.json');
|
|
18
|
+
const CAL = 'mcp__claude_ai_Google_Calendar__';
|
|
19
|
+
export const READ_TOOLS = ['list_calendars', 'list_events', 'search_events', 'get_event'].map((t) => `${CAL}${t}`);
|
|
20
|
+
export const DENY_TOOLS = ['create_event', 'update_event', 'delete_event', 'respond_to_event', 'suggest_time'].map((t) => `${CAL}${t}`)
|
|
21
|
+
.concat(['Bash', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'WebFetch', 'WebSearch']);
|
|
22
|
+
export const KINDS = ['interview', 'screen', 'onsite', 'offer-call', 'recruiter-call', 'deadline', 'other'];
|
|
23
|
+
const ACTIVE = new Set(['reviewing', 'applying', 'ready', 'applied', 'interviewing', 'offer']);
|
|
24
|
+
|
|
25
|
+
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return { lastRun: null, days: null, items: [] }; } }
|
|
26
|
+
function persist(d) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(d, null, 2)); }
|
|
27
|
+
|
|
28
|
+
/** The extraction prompt. The companies are a hint for recognition, not a filter: an interview at a company with no note is still reported. */
|
|
29
|
+
export function prompt({ days = 21, back = 7, companies = [] } = {}) {
|
|
30
|
+
return `You are checking a job seeker's Google Calendar for events about their job search. Use ONLY the Google Calendar list, search and get tools. Do not create, update, delete or respond to anything.
|
|
31
|
+
|
|
32
|
+
Look at every event from ${back} days ago to ${days} days ahead, on every calendar list_calendars returns. Report an event when its title, description, location or attendees show it is about a job application: an interview, a phone or video screen, an onsite, a take-home review, a recruiter or hiring-manager call, an offer call, or a deadline for an application. Skip everything else (meetings at the person's current job, personal events, reminders that name no company).
|
|
33
|
+
|
|
34
|
+
Decide the company from the title, the description, the organiser's or attendees' email domains, or the meeting link. Use the exact start time the event has.
|
|
35
|
+
|
|
36
|
+
Output ONLY a JSON array, no prose, no code fence, one object per event:
|
|
37
|
+
{"company": "company name", "role": "role title if the event or its description names one, else empty string", "kind": "${KINDS.join('|')}", "start": "YYYY-MM-DD HH:MM in the person's own time zone", "end": "YYYY-MM-DD HH:MM or empty", "summary": "the event title", "gist": "one plain sentence on what it is", "with": "the other people on it by name, comma separated, or empty", "location": "the meeting link or place, or empty", "eventId": "the event id", "calendar": "the calendar's name"}
|
|
38
|
+
|
|
39
|
+
If nothing matches, output [].${companies.length ? `
|
|
40
|
+
|
|
41
|
+
The person's own records show applications in flight at these companies (others may exist): ${companies.join(', ')}.` : ''}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const WHEN = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2}):(\d{2}))?/;
|
|
45
|
+
const when = (s) => { const m = String(s || '').trim().match(WHEN); return m ? `${m[1]}${m[2] ? ` ${m[2].padStart(2, '0')}:${m[3]}` : ''}` : ''; };
|
|
46
|
+
|
|
47
|
+
/** The model's text into clean entries, or an error that says what came back. */
|
|
48
|
+
export function parseOutput(text) {
|
|
49
|
+
const s = String(text || '');
|
|
50
|
+
const a = s.indexOf('['), b = s.lastIndexOf(']');
|
|
51
|
+
if (a < 0 || b < a) throw new Error(`The runner returned no JSON array. It said: ${s.slice(0, 200).replace(/\s+/g, ' ')}`);
|
|
52
|
+
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}`); }
|
|
53
|
+
if (!Array.isArray(arr)) throw new Error('The runner returned JSON that is not an array.');
|
|
54
|
+
return arr.filter((m) => m && typeof m === 'object' && m.company && when(m.start)).map((m) => ({
|
|
55
|
+
id: String(m.eventId || `${m.company}|${m.start}`).trim(), company: String(m.company).trim(), role: String(m.role || '').trim(),
|
|
56
|
+
kind: KINDS.includes(m.kind) ? m.kind : 'other', start: when(m.start), end: when(m.end), summary: String(m.summary || '').trim().slice(0, 160),
|
|
57
|
+
gist: String(m.gist || '').trim().slice(0, 300), with: String(m.with || '').trim().slice(0, 200), location: String(m.location || '').trim().slice(0, 300), calendar: String(m.calendar || '').trim().slice(0, 80),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const INTERVIEWISH = new Set(['interview', 'screen', 'onsite', 'offer-call']);
|
|
62
|
+
|
|
63
|
+
/** What confirming an event would do to the note it matched. */
|
|
64
|
+
export function suggestionFor(kind, noteStatus) {
|
|
65
|
+
if (!noteStatus) return { action: 'none', text: 'No note at this company; the event is noted here only.' };
|
|
66
|
+
if (kind === 'deadline') return { action: 'deadline', text: 'Puts the date in the note\'s Deadline field.' };
|
|
67
|
+
if (!INTERVIEWISH.has(kind)) return { action: 'record', text: 'Records the call on the note.' };
|
|
68
|
+
if (['interviewing', 'offer'].includes(noteStatus)) return { action: 'record', text: 'Records the time in the note\'s Interview on field.' };
|
|
69
|
+
return { action: 'status', status: 'interviewing', text: 'Moves the note to interviewing and records the time in Interview on.' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Each event matched to the notes: the one note at that company in flight, or a pick among several. */
|
|
73
|
+
export function reconcile(events, notes) {
|
|
74
|
+
const rank = (n) => (n.status === 'interviewing' || n.status === 'offer' ? 3 : ACTIVE.has(n.status) ? 2 : n.status === 'reviewing' ? 1 : 0);
|
|
75
|
+
return events.map((e) => {
|
|
76
|
+
const cands = notes.filter((n) => sameCompany(n.company, e.company)).sort((a, b) => rank(b) - rank(a) || (b.score || 0) - (a.score || 0));
|
|
77
|
+
const byRole = e.role ? cands.find((n) => n.title.toLowerCase().includes(e.role.toLowerCase()) || e.role.toLowerCase().includes(n.title.toLowerCase())) : null;
|
|
78
|
+
const note = byRole || (cands.length === 1 || (cands[0] && rank(cands[0]) >= 2) ? cands[0] : null);
|
|
79
|
+
return {
|
|
80
|
+
...e, state: 'pending', match: byRole ? 'role' : note ? 'company' : 'none',
|
|
81
|
+
noteId: note?.id || '', noteTitle: note?.title || '', noteStatus: note?.status || '',
|
|
82
|
+
candidates: cands.slice(0, 6).map((n) => ({ id: n.id, title: n.title, status: n.status })),
|
|
83
|
+
suggestion: suggestionFor(e.kind, note?.status || ''),
|
|
84
|
+
};
|
|
85
|
+
}).sort((a, b) => a.start.localeCompare(b.start));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------- running ----------------
|
|
89
|
+
const run = { running: false, startedAt: null, finishedAt: null, error: '', errorKind: '', days: null, lastOutput: '' };
|
|
90
|
+
export function items() {
|
|
91
|
+
const d = load();
|
|
92
|
+
return { ...run, runner: runnerConfig().command, lastRun: d.lastRun, lastDays: d.days, items: d.items || [], pending: (d.items || []).filter((i) => i.state === 'pending') };
|
|
93
|
+
}
|
|
94
|
+
export function start({ days } = {}) {
|
|
95
|
+
if (run.running) return items();
|
|
96
|
+
const ahead = Number(days) || 21;
|
|
97
|
+
Object.assign(run, { running: true, startedAt: new Date().toISOString(), finishedAt: null, error: '', errorKind: '', days: ahead });
|
|
98
|
+
const notes = store.listJobs();
|
|
99
|
+
const companies = [...new Set(notes.filter((n) => ACTIVE.has(n.status)).map((n) => n.company))].sort();
|
|
100
|
+
runLLM(prompt({ days: ahead, companies }), { timeoutMs: 15 * 60 * 1000, extraArgs: ['--allowedTools', ...READ_TOOLS, '--disallowedTools', ...DENY_TOOLS] })
|
|
101
|
+
.then((text) => {
|
|
102
|
+
run.lastOutput = String(text).slice(0, 4000);
|
|
103
|
+
const fresh = reconcile(parseOutput(text), store.listJobs());
|
|
104
|
+
const cur = load();
|
|
105
|
+
const known = new Map((cur.items || []).map((i) => [i.id, i]));
|
|
106
|
+
// An event already confirmed or dismissed keeps that state; a pending one is re-matched against today's notes.
|
|
107
|
+
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; });
|
|
108
|
+
for (const k of known.values()) if (!merged.some((i) => i.id === k.id)) merged.push(k);
|
|
109
|
+
merged.sort((a, b) => (a.start || '').localeCompare(b.start || ''));
|
|
110
|
+
persist({ lastRun: new Date().toISOString(), days: ahead, items: merged });
|
|
111
|
+
Object.assign(run, { running: false, finishedAt: new Date().toISOString() });
|
|
112
|
+
})
|
|
113
|
+
.catch((e) => Object.assign(run, { running: false, finishedAt: new Date().toISOString(), error: e.message, errorKind: e.kind || 'failed' }));
|
|
114
|
+
return items();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------- confirming ----------------
|
|
118
|
+
const fieldEmpty = (id, field) => { try { return !new RegExp(`^- \\*\\*${field}:\\*\\*\\s*\\S`, 'm').test(fs.readFileSync(store.getJob(id).path, 'utf8')); } catch { return false; } };
|
|
119
|
+
const line = (i) => `Calendar, ${i.start}${i.end ? ` to ${i.end.slice(-5)}` : ''} (${i.kind}): ${i.summary || i.gist}${i.with ? `, with ${i.with}` : ''}${i.location ? ` [${i.location}]` : ''}`;
|
|
120
|
+
|
|
121
|
+
/** The person confirms one event, optionally choosing the note. Writes the time, the status where it applies, and a line on the note. */
|
|
122
|
+
export function confirm(id, { noteId } = {}) {
|
|
123
|
+
const d = load();
|
|
124
|
+
const item = (d.items || []).find((i) => i.id === id);
|
|
125
|
+
if (!item) throw Object.assign(new Error(`No calendar item ${id}`), { status: 404 });
|
|
126
|
+
const target = noteId || item.noteId;
|
|
127
|
+
const s = target ? suggestionFor(item.kind, store.getJob(target).status) : suggestionFor(item.kind, '');
|
|
128
|
+
if (target) {
|
|
129
|
+
if (s.action === 'status') store.setStatus(target, s.status, 'app');
|
|
130
|
+
if (s.action === 'deadline') { if (fieldEmpty(target, 'Deadline')) store.saveApplicationDraft(target, { field: 'Deadline', value: item.start.slice(0, 10) }); }
|
|
131
|
+
else if (INTERVIEWISH.has(item.kind)) {
|
|
132
|
+
// The latest confirmed interview is the one on the calendar; an earlier round the person wrote by hand gives way to it.
|
|
133
|
+
const current = (() => { try { return (fs.readFileSync(store.getJob(target).path, 'utf8').match(/^- \*\*Interview on:\*\*\s*(\S.*)$/m) || [])[1] || ''; } catch { return ''; } })();
|
|
134
|
+
if (!current || item.start > current) store.saveApplicationDraft(target, { field: 'Interview on', value: item.start });
|
|
135
|
+
}
|
|
136
|
+
store.addNote(target, line(item), 'app');
|
|
137
|
+
}
|
|
138
|
+
const at = new Date().toISOString();
|
|
139
|
+
item.state = 'confirmed'; item.noteId = target || ''; item.resolved = { at, action: s.action, status: s.status || '' };
|
|
140
|
+
persist(d);
|
|
141
|
+
return items();
|
|
142
|
+
}
|
|
143
|
+
export function dismiss(id) {
|
|
144
|
+
const d = load();
|
|
145
|
+
const item = (d.items || []).find((i) => i.id === id);
|
|
146
|
+
if (!item) throw Object.assign(new Error(`No calendar item ${id}`), { status: 404 });
|
|
147
|
+
item.state = 'dismissed'; item.resolved = { at: new Date().toISOString(), action: 'dismissed' };
|
|
148
|
+
persist(d);
|
|
149
|
+
return items();
|
|
150
|
+
}
|
package/app/server/index.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import * as tailored from './tailored-resume.mjs';
|
|
|
10
10
|
import * as mail from './mail-check.mjs';
|
|
11
11
|
import * as people from './people.mjs';
|
|
12
12
|
import * as linkedin from './linkedin-import.mjs';
|
|
13
|
+
import * as calcheck from './calendar-check.mjs';
|
|
13
14
|
import { TOOLS } from './mcp.mjs';
|
|
14
15
|
import { buildJournal, renderJournal, writeJournal } from '../../scraper/journal.mjs';
|
|
15
16
|
|
|
@@ -75,6 +76,11 @@ const routes = [
|
|
|
75
76
|
['GET', /^\/api\/learned$/, () => store.learned()],
|
|
76
77
|
// The calendar: the dated lines the notes carry. The .ics feed is served outside the JSON routes below.
|
|
77
78
|
['GET', /^\/api\/calendar$/, () => store.calendar()],
|
|
79
|
+
// The Google Calendar check: read-only through the local CLI's connector; the person confirms each event.
|
|
80
|
+
['GET', /^\/api\/calendar\/items$/, () => calcheck.items()],
|
|
81
|
+
['POST', /^\/api\/calendar\/check$/, async (_, __, req) => calcheck.start(await readBody(req))],
|
|
82
|
+
['POST', /^\/api\/calendar\/items\/([^/]+)\/confirm$/, async (m, _, req) => calcheck.confirm(decodeURIComponent(m[1]), await readBody(req))],
|
|
83
|
+
['POST', /^\/api\/calendar\/items\/([^/]+)\/dismiss$/, (m) => calcheck.dismiss(decodeURIComponent(m[1]))],
|
|
78
84
|
// The day's journal: runs, found, decisions, mail, notes, read from the record. ?date=YYYY-MM-DD for another day.
|
|
79
85
|
['GET', /^\/api\/journal$/, (_, q) => { const date = q.get('date') || undefined; const j = buildJournal(date); return { ...j, markdown: renderJournal(j) }; }],
|
|
80
86
|
['POST', /^\/api\/journal$/, async (_, __, req) => { const b = await readBody(req); return writeJournal(b.date || undefined); }],
|
package/app/server/mcp.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import * as tailored from './tailored-resume.mjs';
|
|
|
11
11
|
import * as mail from './mail-check.mjs';
|
|
12
12
|
import * as people from './people.mjs';
|
|
13
13
|
import * as linkedin from './linkedin-import.mjs';
|
|
14
|
+
import * as calcheck from './calendar-check.mjs';
|
|
14
15
|
|
|
15
16
|
// The version a client shows beside the server is the package's, not a constant that goes stale.
|
|
16
17
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version; } catch { return '0.0.0'; } })();
|
|
@@ -39,6 +40,8 @@ export const TOOLS = [
|
|
|
39
40
|
{ name: 'summary', description: 'Pipeline counts by status, pay band and kind; last scan; current floor, stretch and score bar.', inputSchema: { type: 'object', properties: {} } },
|
|
40
41
|
{ name: 'run_scan', description: 'Start a scan of every board now. It returns at once; the scan runs on its own (it survives this session ending) and takes two to three minutes. Poll scan_status. Pass dry=true to score without writing notes. Pass criteria=<preset name> to score this one run with a named criteria preset instead of the active Search Criteria (see list_criteria_presets). Pass wait=<seconds> (up to 300) to answer only when the scan has finished, for a client that cannot poll between turns; the scan keeps running if the wait ends first.', inputSchema: { type: 'object', properties: { dry: { type: 'boolean' }, criteria: { type: 'string' }, wait: { type: 'number', description: 'seconds to wait for the scan to finish, up to 300; omit to return at once' } } } },
|
|
41
42
|
{ name: 'mail_check', description: 'Start a read-only pass over the person\'s mailbox for application updates (confirmations, rejections, interview invitations) through the local CLI\'s Gmail connector, with only the Gmail read tools allowed. Runs in the background for a few minutes; poll mail_items. days: how far back (default: since the last check, or 21 days the first time).', inputSchema: { type: 'object', properties: { days: { type: 'number' } } } },
|
|
43
|
+
{ name: 'calendar_check', description: 'Start a read-only pass over the person\'s Google Calendar for interviews, screens, recruiter calls and deadlines about jobs on their board, through the local CLI\'s Google Calendar connector with only its read tools allowed. Runs in the background for a minute or two; poll calendar_items. days: how far ahead (default 21). Nothing is ever written to the calendar.', inputSchema: { type: 'object', properties: { days: { type: 'number' } } } },
|
|
44
|
+
{ name: 'calendar_items', description: 'The calendar events found so far, each matched to a job note (by role, by company, or none) with what confirming it would do: the time into the note\'s Interview on field, the note to interviewing where it is not yet. Confirming is done by the person in the app; an agent reads these and tells them what is waiting.', inputSchema: { type: 'object', properties: {} } },
|
|
42
45
|
{ name: 'mail_items', description: 'The application emails found so far, each matched to a job note (exact role, same company, or none) with what confirming it would do. Confirming is done by the person in the app; an agent can read these and tell them what is waiting.', inputSchema: { type: 'object', properties: {} } },
|
|
43
46
|
{ name: 'preview_criteria', description: 'What a proposed criteria JSON would do to the notes that exist, without saving or scanning: how many rise above or fall below the bar, who enters or leaves the top 20, the biggest movers. Covers title terms, recency and pay exactly; description, seniority and location rules need a scan.', inputSchema: { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] } },
|
|
44
47
|
{ name: 'outcomes', description: 'What the search is producing: the funnel (found, reviewed, shortlisted, applied, interviewing, offer), response rate, median days from discovery to application, applications waiting without a response at 7/14/21 days with the oldest listed, and applied/response counts by source.', inputSchema: { type: 'object', properties: {} } },
|
|
@@ -97,6 +100,8 @@ export async function call(name, a = {}) {
|
|
|
97
100
|
}
|
|
98
101
|
case 'mail_check': return mail.start({ sinceDays: a.days });
|
|
99
102
|
case 'mail_items': return mail.items();
|
|
103
|
+
case 'calendar_check': return calcheck.start({ days: a.days });
|
|
104
|
+
case 'calendar_items': return calcheck.items();
|
|
100
105
|
case 'preview_criteria': return store.previewCriteria(a.raw);
|
|
101
106
|
case 'rescore_notes': return store.rescoreNotes({ dry: !!a.dry });
|
|
102
107
|
case 'outcomes': return store.outcomes();
|
package/cli.mjs
CHANGED
|
@@ -34,7 +34,7 @@ const HELP = `tekjobs — a local job-search machine
|
|
|
34
34
|
(for location, description or seniority rule changes); notes already
|
|
35
35
|
at these weights are left alone
|
|
36
36
|
tekjobs schedule [--time HH:MM] [--print]
|
|
37
|
-
run the scan
|
|
37
|
+
run the scan, then the read-only mail and calendar passes, every morning: creates the Windows task
|
|
38
38
|
(07:30 by default), or prints the crontab or launchd line for macOS and
|
|
39
39
|
Linux; --print shows the command without installing anything
|
|
40
40
|
tekjobs import linkedin <zip|folder> your LinkedIn data export into the search: who you know at each
|
|
@@ -46,6 +46,8 @@ const HELP = `tekjobs — a local job-search machine
|
|
|
46
46
|
tekjobs down stop what \`up\` started
|
|
47
47
|
tekjobs ps what \`up\` has running
|
|
48
48
|
tekjobs board <slug> [--from ats] [--move] where a failed board answers now; --move rewrites the watchlist row
|
|
49
|
+
tekjobs calendar check [--days N] read the Google Calendar, through the local CLI's connector with its read tools
|
|
50
|
+
only, for interviews and calls about jobs on the board; confirm each in the app
|
|
49
51
|
tekjobs journal [YYYY-MM-DD] [--write] the day's journal from the record (runs, found, decisions, mail); --write
|
|
50
52
|
puts it at the end of Logs/<date>.md, as every real scan does
|
|
51
53
|
tekjobs serve the app + API on http://127.0.0.1:8787, in this terminal
|
|
@@ -204,6 +206,22 @@ async function main() {
|
|
|
204
206
|
if (!s.dry) console.log(` Index: ${s.indexPath}. Job notes now show who you know at each company.`);
|
|
205
207
|
return;
|
|
206
208
|
}
|
|
209
|
+
if (cmd === 'calendar') {
|
|
210
|
+
const cc = await import('./app/server/calendar-check.mjs');
|
|
211
|
+
if (rest[0] === 'check') {
|
|
212
|
+
const s = cc.start({ days: Number(opt('--days')) || undefined });
|
|
213
|
+
console.log(`Reading the calendar through ${s.runner}, ${s.days} days ahead, read tools only. This takes a minute or two.`);
|
|
214
|
+
while (cc.items().running) await new Promise((r) => setTimeout(r, 3000));
|
|
215
|
+
const r = cc.items();
|
|
216
|
+
if (r.error) return console.error(`Calendar check failed (${r.errorKind}): ${r.error}`);
|
|
217
|
+
console.log(`${r.pending.length} event${r.pending.length === 1 ? '' : 's'} waiting for a decision in the app's Calendar page.`);
|
|
218
|
+
for (const i of r.pending) console.log(` ${i.start} ${i.kind.padEnd(14)} ${i.company} — ${i.summary || i.gist}${i.noteId ? ` → ${i.noteTitle} (${i.noteStatus})` : ' → no note'}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const r = cc.items();
|
|
222
|
+
console.log(`${r.pending.length} pending calendar event${r.pending.length === 1 ? '' : 's'}; last read ${r.lastRun || 'never'}. Run: tekjobs calendar check`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
207
225
|
if (cmd === 'journal') {
|
|
208
226
|
const { buildJournal, renderJournal, writeJournal } = await import('./scraper/journal.mjs');
|
|
209
227
|
const date = rest.find((x) => /^\d{4}-\d{2}-\d{2}$/.test(x));
|
|
@@ -317,7 +335,7 @@ function schedule() {
|
|
|
317
335
|
const printOnly = rest.includes('--print');
|
|
318
336
|
if (process.platform === 'win32') {
|
|
319
337
|
const cmd = `schtasks /Create /F /SC DAILY /ST ${time} /TN "TekJobs Daily Scan" /TR "\\"${path.join(ROOT, 'run.cmd')}\\""`;
|
|
320
|
-
console.log(`Windows Task Scheduler entry "TekJobs Daily Scan", daily at ${time}, running run.cmd (the scan, then the mail
|
|
338
|
+
console.log(`Windows Task Scheduler entry "TekJobs Daily Scan", daily at ${time}, running run.cmd (the scan, then the read-only mail and calendar passes):\n ${cmd}`);
|
|
321
339
|
if (printOnly) return;
|
|
322
340
|
const r = spawnSync(cmd, { shell: true, encoding: 'utf8' });
|
|
323
341
|
if (r.status === 0) console.log('Installed. See it in Task Scheduler, or remove it with: schtasks /Delete /TN "TekJobs Daily Scan" /F');
|
|
@@ -341,5 +359,5 @@ function schedule() {
|
|
|
341
359
|
EOF
|
|
342
360
|
launchctl load ${plist}`);
|
|
343
361
|
}
|
|
344
|
-
console.log('\nBoth run run.sh: the scan, then the read-only mail
|
|
362
|
+
console.log('\nBoth run run.sh: the scan, then the read-only mail and calendar passes, appending to data/runs.log.');
|
|
345
363
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timurtekb/tekjobs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
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.cmd
CHANGED
|
@@ -8,3 +8,6 @@ node run.mjs >> data\runs.log 2>&1
|
|
|
8
8
|
rem Then the mailbox: a read-only pass through the local CLI's Gmail connector, matched to the notes for you to confirm in the app.
|
|
9
9
|
echo ----- mail %date% %time% ----- >> data\runs.log
|
|
10
10
|
node cli.mjs mail >> data\runs.log 2>&1
|
|
11
|
+
rem Then the calendar: a read-only pass through the Google Calendar connector for interviews and calls about jobs on the board, matched to the notes for you to confirm.
|
|
12
|
+
echo ----- calendar %date% %time% ----- >> data\runs.log
|
|
13
|
+
node cli.mjs calendar check >> data\runs.log 2>&1
|
package/run.sh
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Daily TekJobs scan for cron and launchd: the scan, then the read-only mail
|
|
2
|
+
# Daily TekJobs scan for cron and launchd: the scan, then the read-only mail and calendar passes. `tekjobs schedule` prints
|
|
3
3
|
# the one line that calls this at 07:30; run.cmd is the Windows twin.
|
|
4
4
|
cd "$(dirname "$0")" || exit 1
|
|
5
5
|
export TEKJOBS_RUN_VIA=schedule
|
|
@@ -9,4 +9,6 @@ mkdir -p data
|
|
|
9
9
|
node run.mjs
|
|
10
10
|
echo "----- mail $(date) -----"
|
|
11
11
|
node cli.mjs mail
|
|
12
|
+
echo "----- calendar $(date) -----"
|
|
13
|
+
node cli.mjs calendar check
|
|
12
14
|
} >> data/runs.log 2>&1
|