@timurtekb/tekjobs 0.47.0 → 0.49.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.
@@ -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
+ }
@@ -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
 
@@ -73,6 +74,13 @@ const routes = [
73
74
  ['GET', /^\/api\/profile\/summary$/, () => store.profileSummary()],
74
75
  // The one-page confirmation after the interview: roles, where, pay, exclusions, proof points, what remains.
75
76
  ['GET', /^\/api\/learned$/, () => store.learned()],
77
+ // The calendar: the dated lines the notes carry. The .ics feed is served outside the JSON routes below.
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]))],
76
84
  // The day's journal: runs, found, decisions, mail, notes, read from the record. ?date=YYYY-MM-DD for another day.
77
85
  ['GET', /^\/api\/journal$/, (_, q) => { const date = q.get('date') || undefined; const j = buildJournal(date); return { ...j, markdown: renderJournal(j) }; }],
78
86
  ['POST', /^\/api\/journal$/, async (_, __, req) => { const b = await readBody(req); return writeJournal(b.date || undefined); }],
@@ -114,6 +122,7 @@ function serveStatic(req, res, pathname) {
114
122
  const server = http.createServer(async (req, res) => {
115
123
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
116
124
  try {
125
+ if (url.pathname === '/api/calendar.ics' && req.method === 'GET') { res.writeHead(200, { 'content-type': 'text/calendar; charset=utf-8', 'content-disposition': 'inline; filename="tekjobs.ics"', 'cache-control': 'no-store' }); return res.end(store.calendarIcs()); }
117
126
  const printView = url.pathname.match(/^\/api\/jobs\/([^/]+)\/resume\.html$/);
118
127
  if (printView && req.method === 'GET') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(tailored.html(decodeURIComponent(printView[1]))); }
119
128
  for (const [method, re, handler] of routes) {
@@ -50,7 +50,7 @@ Report every message that is about one of the person's job applications: confirm
50
50
  The person's own records show applications at these companies (others may exist): ${companies.join(', ')}.` : ''}
51
51
 
52
52
  Output ONLY a JSON array, no prose, no code fence, one object per message:
53
- {"company": "company name as the email gives it", "role": "role title if stated, else empty string", "kind": "confirmation|rejection|advance|scheduling|info-request|outreach|other", "date": "YYYY-MM-DD", "gist": "one plain sentence", "from": "the sender exactly as the header shows it, Display Name <address>", "fromName": "the human who wrote, from the sender display name or the signature; empty only when no human is named. For a LinkedIn relay address (inmail-hit-reply@linkedin.com, hit-reply@linkedin.com) the display name is the person, so read the message if the search result does not show it; required for outreach", "postingUrl": "a link in the email to the job posting or to the application itself, if there is one, else empty string; never a scheduling, meeting, calendar or unsubscribe link", "reqId": "a requisition or job id the email quotes (R-12345, JR361297, Job ID 10502343), else empty string", "messageId": "gmail message id", "subject": "subject line"}
53
+ {"company": "company name as the email gives it", "role": "role title if stated, else empty string", "kind": "confirmation|rejection|advance|scheduling|info-request|outreach|other", "date": "YYYY-MM-DD", "gist": "one plain sentence", "from": "the sender exactly as the header shows it, Display Name <address>", "fromName": "the human who wrote, from the sender display name or the signature; empty only when no human is named. For a LinkedIn relay address (inmail-hit-reply@linkedin.com, hit-reply@linkedin.com) the display name is the person, so read the message if the search result does not show it; required for outreach", "postingUrl": "a link in the email to the job posting or to the application itself, if there is one, else empty string; never a scheduling, meeting, calendar or unsubscribe link", "when": "for an interview or scheduling email that names a date and time, that moment as YYYY-MM-DD HH:MM in the person's own time zone (date alone if no time is given), else empty string", "reqId": "a requisition or job id the email quotes (R-12345, JR361297, Job ID 10502343), else empty string", "messageId": "gmail message id", "subject": "subject line"}
54
54
 
55
55
  If nothing matches, output [].`;
56
56
  }
@@ -67,6 +67,7 @@ export function parseOutput(text) {
67
67
  date: /^\d{4}-\d{2}-\d{2}$/.test(String(m.date || '')) ? m.date : '', gist: String(m.gist || '').trim().slice(0, 300),
68
68
  postingUrl: /^https?:\/\//i.test(String(m.postingUrl || '').trim()) && !/calendly|meeting|schedul|zoom\.us|goodtime|modernloop|unsubscribe|list-manage/i.test(String(m.postingUrl)) ? String(m.postingUrl).trim().slice(0, 500) : '', reqId: String(m.reqId || '').trim().slice(0, 60),
69
69
  from: String(m.from || '').trim(), fromName: String(m.fromName || '').trim().slice(0, 80), messageId: String(m.messageId).trim(), subject: String(m.subject || '').trim().slice(0, 200),
70
+ when: /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2})?$/.test(String(m.when || '').trim()) ? String(m.when).trim() : '',
70
71
  }));
71
72
  }
72
73
 
@@ -200,6 +201,10 @@ const mailLine = (i) => `Mail, ${i.date || 'undated'} (${i.kind}): ${i.gist} [me
200
201
  function appliedOnEmpty(id) {
201
202
  try { return /^- \*\*Applied on:\*\*\s*$/m.test(fs.readFileSync(store.getJob(id).path, 'utf8')); } catch { return false; }
202
203
  }
204
+ /** Empty or absent: an older note may not carry the field at all. */
205
+ function fieldEmpty(id, field) {
206
+ try { return !new RegExp(`^- \\*\\*${field}:\\*\\*\\s*\\S`, 'm').test(fs.readFileSync(store.getJob(id).path, 'utf8')); } catch { return false; }
207
+ }
203
208
 
204
209
  /**
205
210
  * Do what the group's strongest email suggests, to the note the person chose (or the matched one), record
@@ -231,6 +236,9 @@ export async function confirm(id, { noteId } = {}) {
231
236
  }
232
237
  if (s.action === 'status' || s.action === 'create') store.setStatus(target, s.status, 'app');
233
238
  if (appliedOn && members.some((m) => m.kind === 'confirmation') && appliedOnEmpty(target)) store.saveApplicationDraft(target, { field: 'Applied on', value: appliedOn });
239
+ // A scheduling email that names a time puts the interview on the calendar; the latest one named wins, and a date the person already wrote is kept.
240
+ const when = members.filter((m) => (m.kind === 'advance' || m.kind === 'scheduling') && m.when).map((m) => m.when).sort().pop();
241
+ if (when && fieldEmpty(target, 'Interview on')) store.saveApplicationDraft(target, { field: 'Interview on', value: when });
234
242
  // Oldest first, so the note's Notes read in the order the mail arrived.
235
243
  for (const m of [...members].sort((a, b) => (a.date || '').localeCompare(b.date || ''))) store.addNote(target, mailLine(m), 'app');
236
244
  // The humans who wrote go on the note's People and get the mail on their own Log; machines do not.
@@ -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();
@@ -273,6 +273,7 @@ export const APPLICATION_FIELDS = [
273
273
  { field: 'Contact / referral', hint: 'Who, and how you know them.', required: false },
274
274
  { field: 'Deadline', hint: 'When this closes, if it says.', required: false },
275
275
  { field: 'Applied on', hint: 'Filled when you actually send it.', required: false },
276
+ { field: 'Interview on', hint: 'Date and time, like 2026-10-14 15:00, in your own time zone. The Calendar page and the .ics feed read it; a confirmed scheduling email fills it.', required: false },
276
277
  { field: 'Follow-up due', hint: 'When to chase, if nothing comes back.', required: false },
277
278
  ];
278
279
  const FIELD_NAMES = APPLICATION_FIELDS.map((f) => f.field);
@@ -1144,6 +1145,52 @@ export function learned() {
1144
1145
  };
1145
1146
  }
1146
1147
 
1148
+ // ---------- calendar ----------
1149
+ // The dated lines the notes carry, read as events. Nothing comes from a calendar service: the packet's Interview
1150
+ // on, Follow-up due, Deadline and Applied on fields are the calendar, and the .ics feed is the same record for
1151
+ // the calendar app on this machine.
1152
+ const DATE_FIELDS = [['Interview on', 'interview'], ['Follow-up due', 'follow-up'], ['Deadline', 'deadline'], ['Applied on', 'applied']];
1153
+ const parseWhen = (s) => { const m = String(s).match(/(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2}):(\d{2}))?/); return m ? { date: m[1], time: m[2] ? `${m[2].padStart(2, '0')}:${m[3]}` : '' } : null; };
1154
+ const shiftDay = (iso, days) => new Date(Date.parse(iso) + days * 864e5).toISOString().slice(0, 10);
1155
+ export function calendarEvents({ from = '', to = '' } = {}) {
1156
+ const out = [];
1157
+ for (const r of listJobs()) {
1158
+ let text; try { text = fs.readFileSync(notePath(r.id), 'utf8'); } catch { continue; }
1159
+ for (const [field, kind] of DATE_FIELDS) {
1160
+ const m = text.match(new RegExp(`^- \\*\\*${escapeRe(field)}:\\*\\*\\s*(.+)$`, 'm'));
1161
+ if (!m) continue;
1162
+ const when = parseWhen(m[1]);
1163
+ if (!when) continue;
1164
+ out.push({ id: r.id, company: r.company, title: r.title, status: r.status, kind, field, date: when.date, time: when.time, raw: m[1].trim(), url: r.url || '' });
1165
+ }
1166
+ }
1167
+ return out.filter((e) => (!from || e.date >= from) && (!to || e.date <= to)).sort((a, b) => (a.date + (a.time || '')).localeCompare(b.date + (b.time || '')));
1168
+ }
1169
+ export function calendar() {
1170
+ const today = isoDay();
1171
+ const events = calendarEvents({ from: shiftDay(today, -14) });
1172
+ return { today, events, upcoming: events.filter((e) => e.date >= today), past: events.filter((e) => e.date < today).reverse(), ics: '/api/calendar.ics' };
1173
+ }
1174
+ /** The feed: interviews (timed when the note has a time, in local time), follow-ups and deadlines as all-day events. Applied dates stay in the notes. */
1175
+ export function calendarIcs() {
1176
+ const esc = (s) => String(s || '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
1177
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
1178
+ const words = { interview: 'Interview', 'follow-up': 'Follow up', deadline: 'Deadline' };
1179
+ const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//TekJobs//Calendar//EN', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:TekJobs', 'X-WR-CALDESC:Interviews\, follow-ups and deadlines from the job notes'];
1180
+ for (const e of calendarEvents().filter((e) => e.kind !== 'applied')) {
1181
+ const d = e.date.replace(/-/g, '');
1182
+ lines.push('BEGIN:VEVENT', `UID:${e.kind}-${e.id.replace(/[^A-Za-z0-9]+/g, '-')}@tekjobs.local`, `DTSTAMP:${stamp}`);
1183
+ if (e.time) { const t = e.time.replace(':', ''); lines.push(`DTSTART:${d}T${t}00`); }
1184
+ else { lines.push(`DTSTART;VALUE=DATE:${d}`, `DTEND;VALUE=DATE:${shiftDay(e.date, 1).replace(/-/g, '')}`); }
1185
+ lines.push(`SUMMARY:${esc(`${words[e.kind]}: ${e.company} — ${e.title}`)}`);
1186
+ lines.push(`DESCRIPTION:${esc(`${e.field}: ${e.raw}. Status ${e.status}. Note: Jobs/${e.id}.md`)}`);
1187
+ if (e.url) lines.push(`URL:${esc(e.url)}`);
1188
+ lines.push('END:VEVENT');
1189
+ }
1190
+ lines.push('END:VCALENDAR');
1191
+ return lines.join('\r\n') + '\r\n';
1192
+ }
1193
+
1147
1194
  export function saveProfileNote(key, markdown) {
1148
1195
  const note = PROFILE_NOTES.find((n) => n.key === key);
1149
1196
  if (!note) throw Object.assign(new Error(`No profile note "${key}"`), { status: 404 });
package/cli.mjs CHANGED
@@ -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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurtekb/tekjobs",
3
- "version": "0.47.0",
3
+ "version": "0.49.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",