openings 0.1.59 → 0.1.61

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
4
4
  "description": "A free, candidate-safe job-search substrate for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/abhay-avagama/hiring-agent.git" },
@@ -1,3 +1,4 @@
1
+ import excludedData from "../data/excluded.json" with { type: "json" };
1
2
  import { atomicJson } from "./atomic-file.ts";
2
3
  import { readEnrichmentRegistry, mergeEnrichmentLeads, type EnrichmentLead, type LeadAttempt } from "./enrichment-registry.ts";
3
4
  import { withFileLock } from "./file-lock.ts";
@@ -7,6 +8,22 @@ import type { Ats, Company, SourceVerification } from "./types.ts";
7
8
 
8
9
  type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
9
10
 
11
+ /**
12
+ * Sources that must never be admitted, by provider and token.
13
+ *
14
+ * Deleting a row from the catalog does not hold: the lead stays in the registry and the next verification run
15
+ * puts it straight back, which is how two employers excluded on terms grounds could have quietly returned. The
16
+ * reason travels with the entry so a later reader does not have to guess why.
17
+ */
18
+ const EXCLUDED: ReadonlySet<string> = new Set(
19
+ excludedData.entries.map((entry) => `${entry.ats}:${entry.token.toLowerCase()}`),
20
+ );
21
+
22
+ /** Why a given source is excluded, for reporting. */
23
+ export function exclusionReason(ats: string, token: string): string | undefined {
24
+ return excludedData.entries.find((entry) => entry.ats === ats && entry.token.toLowerCase() === token.toLowerCase())?.reason;
25
+ }
26
+
10
27
  /** Providers whose public board is accepted as identity on its own. Workday boards are identified by tenant; their crawls are heavier but they carry the large employers. */
11
28
  export const BOARD_TIER_PROVIDERS: ReadonlySet<Ats> = new Set(["greenhouse", "lever", "ashby", "recruitee", "smartrecruiters", "workable", "breezy", "workday", "freshteam", "keka", "zohorecruit"]);
12
29
 
@@ -24,7 +41,7 @@ export interface BoardVerificationReport {
24
41
  generatedAt: string;
25
42
  considered: number;
26
43
  selected: number;
27
- skipped: { inCatalog: number; coolingDown: number; unsupported: number; deferred: number };
44
+ skipped: { inCatalog: number; coolingDown: number; unsupported: number; deferred: number; excluded: number };
28
45
  verified: number;
29
46
  added: string[];
30
47
  rejected: Array<{ sourceKey: string; reason: "unreachable" | "invalid_payload" | "empty_board" | "duplicate_slug"; detail: string }>;
@@ -50,10 +67,11 @@ export async function verifyBoards(registryPath: string, catalogPath: string, op
50
67
  const catalog = await readCatalog(catalogPath);
51
68
  const registry = await readEnrichmentRegistry(registryPath);
52
69
  const knownSources = new Set(Object.values(catalog).map((entry) => `${entry.ats}:${entry.token.toLowerCase()}`));
53
- const skipped = { inCatalog: 0, coolingDown: 0, unsupported: 0, deferred: 0 };
70
+ const skipped = { inCatalog: 0, coolingDown: 0, unsupported: 0, deferred: 0, excluded: 0 };
54
71
  const eligible: EnrichmentLead[] = [];
55
72
  for (const lead of registry.leads) {
56
73
  if (!BOARD_TIER_PROVIDERS.has(lead.ats) || !resolveSource(lead.sourceUrl)) { skipped.unsupported += 1; continue; }
74
+ if (EXCLUDED.has(`${lead.ats}:${lead.token.toLowerCase()}`)) { skipped.excluded += 1; continue; }
57
75
  if (knownSources.has(`${lead.ats}:${lead.token.toLowerCase()}`)) { skipped.inCatalog += 1; continue; }
58
76
  // Keka tokens contain tenant/UUID; the provider-issued routing UUID is not a company name.
59
77
  const boardName = lead.ats === "keka" ? lead.token.split("/")[0]! : lead.token;
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { ALL_PROVIDERS, type Ats } from "./types.ts";
3
3
  import { readFile } from "node:fs/promises";
4
- import { relative, resolve } from "node:path";
4
+ import { join, relative, resolve } from "node:path";
5
5
  import { createRuntime } from "./runtime.ts";
6
6
  import { atomicJson } from "./atomic-file.ts";
7
7
  import { verifyBoards } from "./board-verification.ts";
@@ -11,6 +11,9 @@ import { runSourceDiscovery, runYcSourceDiscovery } from "./source-discovery.ts"
11
11
  import { discoverAndPromote } from "./source-discovery-pipeline.ts";
12
12
  import { traceCareerSources } from "./career-tracing.ts";
13
13
  import { discoverCommonCrawlSources } from "./common-crawl-discovery.ts";
14
+ import { createLocalWatches } from "./local-watches.ts";
15
+ import { notifyDesktop } from "./desktop-notify.ts";
16
+ import type { CandidateProfile } from "./candidate-profile.ts";
14
17
  import { discoverCatalog } from "./catalog-discovery.ts";
15
18
  import { parseArgs } from "node:util";
16
19
  import { generateYcCompanySeeds } from "./company-seeds.ts";
@@ -31,6 +34,9 @@ import { mergeAttemptedRoundLeads, prepareRecruiteeRoundArtifacts } from "./recr
31
34
  const HELP = `Openings — search public company job boards
32
35
 
33
36
  Usage:
37
+ openings watch add RESUME.txt [--label NAME] [--country IN,US] watch for roles that fit, on this machine
38
+ openings watch list | check ID [--peek] | stop ID
39
+ openings watch run [--country IN,US] [--no-refresh] what a scheduler calls: refresh, match, notify
34
40
  openings crawl [--workday-countries IN,US] [--country CODE | --companies FILE] [--concurrency N] [--source-cache-hours N] [--source-limit N] [--delay-ms N] [--workday-page-delay-ms N] [--timeout-ms N] [--data-dir PATH]
35
41
  openings snapshot export [--input FILE] [--output-dir PATH]
36
42
  openings coverage report --country CODE [--snapshot FILE] [--catalog FILE] [--candidates FILE] [--registry FILE] [--output FILE] [--as-of ISO]
@@ -110,6 +116,58 @@ export async function run(args: string[]): Promise<number> {
110
116
  return 0;
111
117
  }
112
118
 
119
+ if (command === "watch") {
120
+ const sub = rest[0];
121
+ /** The value after a flag, or undefined. Local to this command; the other commands parse their own. */
122
+ const argValue = (args: string[], flag: string) => {
123
+ const at = args.indexOf(flag);
124
+ return at >= 0 && args[at + 1] && !args[at + 1]!.startsWith("--") ? args[at + 1] : undefined;
125
+ };
126
+ const dataDir = argValue(rest, "--data-dir") ?? process.env.OPENINGS_DATA_DIR ?? join(process.cwd(), ".openings");
127
+ const runtime = createRuntime({ dataDir });
128
+ const watches = createLocalWatches({
129
+ dataDir,
130
+ // Whatever the local index already holds; a monitor never fetches on its own behalf.
131
+ jobs: async () => (await runtime.search({ maxAgeDays: 0, limit: 200_000 }, { offline: true, staleDays: 3650 })).jobs.map((job) => ({
132
+ id: job.id, title: job.title, company: job.company, location: job.location, remote: job.remote,
133
+ countries: job.eligibleCountries ?? [], skills: (job as { skills?: string }).skills ?? "",
134
+ ...(job.experience === undefined || job.experience === null ? {} : { experience: job.experience }),
135
+ })),
136
+ profileFromResume: async (input) => (await runtime.recommend({ ...(input as object), limit: 1 }) as { profile: CandidateProfile }).profile,
137
+ notify: async (title, body) => { await notifyDesktop(title, body); },
138
+ });
139
+ if (sub === "add") {
140
+ const file = rest[1];
141
+ if (!file || file.startsWith("--")) return fail("watch add needs a resume file: openings watch add RESUME.txt [--label NAME] [--country IN]");
142
+ const content = await Bun.file(file).text();
143
+ const countries = (argValue(rest, "--country") ?? "").split(",").map((c) => c.trim().toUpperCase()).filter(Boolean);
144
+ const result = await watches.create({ resume: { content, format: file.toLowerCase().endsWith(".md") ? "markdown" : "text" },
145
+ intent: countries.length ? { countries } : {}, label: argValue(rest, "--label") });
146
+ console.log(JSON.stringify(result, null, 2));
147
+ return 0;
148
+ }
149
+ if (sub === "list") { console.log(JSON.stringify(await watches.list(), null, 2)); return 0; }
150
+ if (sub === "check") {
151
+ const id = rest[1];
152
+ if (!id || id.startsWith("--")) return fail("watch check needs a monitor id; `openings watch list` shows them");
153
+ console.log(JSON.stringify(await watches.check({ id, peek: rest.includes("--peek") }), null, 2));
154
+ return 0;
155
+ }
156
+ if (sub === "stop") {
157
+ const id = rest[1];
158
+ if (!id) return fail("watch stop needs a monitor id");
159
+ console.log(JSON.stringify(await watches.stop({ id }), null, 2));
160
+ return 0;
161
+ }
162
+ if (sub === "run") {
163
+ // What a scheduler calls. Refresh first so the monitor is reading today's index, then tell the desktop.
164
+ if (!rest.includes("--no-refresh")) await runtime.prepareJobSearch({ countries: (argValue(rest, "--country") ?? "IN,US").split(",") }).catch(() => undefined);
165
+ console.log(JSON.stringify(await watches.sweep(), null, 2));
166
+ return 0;
167
+ }
168
+ return fail("watch takes add, list, check, stop or run");
169
+ }
170
+
113
171
  if (command === "coverage") {
114
172
  if (rest[0] !== "report") return fail("coverage requires the `report` subcommand");
115
173
  const parsed = parseCoverageReport(rest.slice(1));
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A desktop notification, using whatever the machine already has.
3
+ *
4
+ * No dependency and no daemon: each platform ships something that puts a line on screen, and a monitor that runs
5
+ * on your own machine should not need a package to tell you it found something. Failure is silent by design —
6
+ * a missing notifier must never stop a monitor from recording what it found.
7
+ */
8
+ const QUOTE = /["\\]/g;
9
+
10
+ export async function notifyDesktop(title: string, body: string): Promise<boolean> {
11
+ const safeTitle = title.replace(QUOTE, "").slice(0, 120);
12
+ const safeBody = body.replace(QUOTE, "").slice(0, 240);
13
+ const attempts: string[][] = process.platform === "darwin"
14
+ ? [["osascript", "-e", `display notification "${safeBody}" with title "${safeTitle}"`]]
15
+ : process.platform === "win32"
16
+ ? [["powershell", "-NoProfile", "-Command",
17
+ `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null; ` +
18
+ `Write-Output "${safeTitle}: ${safeBody}"`]]
19
+ : [["notify-send", safeTitle, safeBody], ["kdialog", "--passivepopup", `${safeTitle}\n${safeBody}`, "10"]];
20
+ for (const command of attempts) {
21
+ try {
22
+ const proc = Bun.spawn(command, { stdout: "ignore", stderr: "ignore" });
23
+ if (await proc.exited === 0) return true;
24
+ } catch { /* try the next one, or give up quietly */ }
25
+ }
26
+ return false;
27
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Monitors that live on the candidate's own machine.
3
+ *
4
+ * The hosted monitor stores a derived profile on our server and emails what it finds. This one stores the same
5
+ * profile in the user's data directory and tells their desktop instead, which means the resume, the profile and
6
+ * the record of what has been seen never leave the machine at all. There is no account, no email address, and
7
+ * nothing for us to lose.
8
+ *
9
+ * It satisfies the same interface the hosted monitors do, so the four MCP tools behave identically whether an
10
+ * assistant is talking to a local install or to the hosted server.
11
+ */
12
+ import { join } from "node:path";
13
+ import { atomicJson } from "./atomic-file.ts";
14
+ import { describeWatchProfile, matchWatch, toWatchProfile, type WatchableJob, type WatchProfile } from "./watch-profile.ts";
15
+ import type { CandidateProfile } from "./candidate-profile.ts";
16
+
17
+ export interface StoredWatch {
18
+ id: string;
19
+ label?: string;
20
+ profile: WatchProfile;
21
+ createdAt: string;
22
+ lastCheckedAt?: string;
23
+ /** Job ids already reported. Kept so a monitor says "new" and means it. */
24
+ seen: string[];
25
+ }
26
+
27
+ interface WatchFile { version: 1; watches: StoredWatch[] }
28
+
29
+ const CAP_SEEN = 4_000;
30
+
31
+ export interface LocalWatchOptions {
32
+ dataDir: string;
33
+ /** Live roles to match against; the caller supplies them from whichever index it already holds. */
34
+ jobs(): Promise<WatchableJob[]>;
35
+ /** Parses a resume into facts. The same parser the one-off tools use. */
36
+ profileFromResume(input: unknown): Promise<CandidateProfile>;
37
+ notify?(title: string, body: string): Promise<void>;
38
+ now?: () => Date;
39
+ }
40
+
41
+ export function createLocalWatches(options: LocalWatchOptions) {
42
+ const now = options.now ?? (() => new Date());
43
+ const path = join(options.dataDir, "watches.json");
44
+
45
+ async function read(): Promise<WatchFile> {
46
+ try {
47
+ const file = await Bun.file(path).json() as WatchFile;
48
+ return file?.version === 1 && Array.isArray(file.watches) ? file : { version: 1, watches: [] };
49
+ } catch { return { version: 1, watches: [] }; }
50
+ }
51
+ const write = (file: WatchFile) => atomicJson(path, file);
52
+
53
+ return {
54
+ async create(input: unknown) {
55
+ const body = (input ?? {}) as { resume?: unknown; intent?: Record<string, unknown>; label?: string };
56
+ if (!body.resume) throw new Error("watch_jobs needs a resume to derive a profile from");
57
+ const parsed = await options.profileFromResume({ resume: body.resume });
58
+ const intent = (body.intent ?? {}) as { countries?: string[]; locations?: string[]; remote?: boolean; excludedTerms?: string[] };
59
+ const profile = toWatchProfile(parsed, intent);
60
+ const file = await read();
61
+ const watch: StoredWatch = {
62
+ id: `w_${now().getTime().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
63
+ ...(body.label ? { label: String(body.label).slice(0, 60) } : {}),
64
+ profile, createdAt: now().toISOString(), seen: [],
65
+ };
66
+ // Everything live counts as already seen, or the first check would report the whole index as new.
67
+ const jobs = await options.jobs();
68
+ const matches = matchWatch(profile, jobs, 20);
69
+ watch.seen = matchWatch(profile, jobs, 100_000).map((m) => m.job.id).slice(0, CAP_SEEN);
70
+ watch.lastCheckedAt = now().toISOString();
71
+ file.watches.push(watch);
72
+ await write(file);
73
+ return {
74
+ id: watch.id, holding: describeWatchProfile(profile), profile,
75
+ matchesNow: matches.map((m) => ({ ...m.job, why: m.reasons })),
76
+ emailing: false,
77
+ storedAt: path,
78
+ note: "This monitor is on your machine. The resume was read once and discarded; what is kept is the profile shown above, in a file you can open or delete.",
79
+ };
80
+ },
81
+
82
+ async list() {
83
+ const file = await read();
84
+ return { watches: file.watches.map((w) => ({
85
+ id: w.id, label: w.label, holding: describeWatchProfile(w.profile),
86
+ createdAt: w.createdAt, lastCheckedAt: w.lastCheckedAt, reported: w.seen.length,
87
+ })), storedAt: path };
88
+ },
89
+
90
+ async check(input: unknown) {
91
+ const body = (input ?? {}) as { id?: string; limit?: number; peek?: boolean };
92
+ const file = await read();
93
+ const watch = file.watches.find((w) => w.id === body.id);
94
+ if (!watch) throw new Error(`No monitor with id ${body.id}. Call list_watches to see them.`);
95
+ const jobs = await options.jobs();
96
+ const seen = new Set(watch.seen);
97
+ const fresh = matchWatch(watch.profile, jobs, 100_000).filter((m) => !seen.has(m.job.id));
98
+ const limited = fresh.slice(0, Math.min(body.limit ?? 20, 50));
99
+ const checkedAt = now().toISOString();
100
+ if (!body.peek) {
101
+ // Everything found is marked seen, not only what was returned, or the rest would arrive again tomorrow.
102
+ watch.seen = [...fresh.map((m) => m.job.id), ...watch.seen].slice(0, CAP_SEEN);
103
+ watch.lastCheckedAt = checkedAt;
104
+ await write(file);
105
+ }
106
+ return {
107
+ matches: limited.map((m) => ({ ...m.job, why: m.reasons })),
108
+ totalNew: fresh.length, checkedAt, newSince: watch.lastCheckedAt ?? watch.createdAt,
109
+ holding: describeWatchProfile(watch.profile),
110
+ };
111
+ },
112
+
113
+ async stop(input: unknown) {
114
+ const id = ((input ?? {}) as { id?: string }).id;
115
+ const file = await read();
116
+ const before = file.watches.length;
117
+ file.watches = file.watches.filter((w) => w.id !== id);
118
+ await write(file);
119
+ return { stopped: before !== file.watches.length,
120
+ deleted: before !== file.watches.length ? "the monitor, its profile, and the record of what it had reported" : "nothing; no monitor had that id" };
121
+ },
122
+
123
+ /** Check every monitor and tell the desktop about anything new. This is what a scheduled run calls. */
124
+ async sweep() {
125
+ const file = await read();
126
+ const jobs = await options.jobs();
127
+ const results: Array<{ id: string; label?: string; found: number }> = [];
128
+ for (const watch of file.watches) {
129
+ const seen = new Set(watch.seen);
130
+ const fresh = matchWatch(watch.profile, jobs, 100_000).filter((m) => !seen.has(m.job.id));
131
+ if (fresh.length) {
132
+ watch.seen = [...fresh.map((m) => m.job.id), ...watch.seen].slice(0, CAP_SEEN);
133
+ const first = fresh[0]!;
134
+ await options.notify?.(
135
+ `${fresh.length} new role${fresh.length === 1 ? "" : "s"} for ${watch.label ?? "your monitor"}`,
136
+ `${first.job.title} at ${first.job.company}${fresh.length > 1 ? ` and ${fresh.length - 1} more` : ""}`,
137
+ ).catch(() => undefined);
138
+ }
139
+ watch.lastCheckedAt = now().toISOString();
140
+ results.push({ id: watch.id, label: watch.label, found: fresh.length });
141
+ }
142
+ await write(file);
143
+ return { checked: file.watches.length, results };
144
+ },
145
+ };
146
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.59";
1
+ export const VERSION = "0.1.61";