openings 0.1.58 → 0.1.60

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.58",
3
+ "version": "0.1.60",
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" },
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
+ }
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export type { CountryJobCoverage, JobCoverageSummary } from "./job-coverage.ts";
41
41
  export { createHostedRuntime } from "./runtime.ts";
42
42
  export type { SnapshotStore } from "./crawler.ts";
43
43
  export { createToolHandler, SCOPES, scopeForTool, type Scope } from "./tools.ts";
44
+ export { describeWatchProfile, matchWatch, toWatchProfile, type WatchableJob, type WatchMatch, type WatchProfile } from "./watch-profile.ts";
44
45
  export { createMcpHandler, FLOW_INSTRUCTIONS } from "./mcp.ts";
45
46
  export type { UpdateNotice } from "./update-check.ts";
46
47
  export { VERSION } from "./version.ts";
@@ -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/tools.ts CHANGED
@@ -8,7 +8,8 @@ import type { JobCoverageSummary } from "./job-coverage.ts";
8
8
  import type { PrepareJobSearchResult } from "./job-search-preparation.ts";
9
9
 
10
10
  export interface ToolDefinition {
11
- name: "prepare_job_search" | "get_job_coverage" | "recommend_jobs" | "analyze_job_fit" | "optimize_resume" | "search_jobs" | "get_job";
11
+ name: "prepare_job_search" | "get_job_coverage" | "recommend_jobs" | "analyze_job_fit" | "optimize_resume" | "search_jobs" | "get_job"
12
+ | "watch_jobs" | "list_watches" | "check_watch" | "stop_watch";
12
13
  /** Human-readable name for a consent screen or tool picker, where a snake_case identifier reads poorly. */
13
14
  title: string;
14
15
  description: string;
@@ -18,12 +19,16 @@ export interface ToolDefinition {
18
19
  outputSchema: Record<string, unknown>;
19
20
  /** Nothing here applies, submits, or writes on the candidate's behalf. Preparation is the one tool that writes
20
21
  * at all: it caches the job index on disk, so claiming readOnlyHint for it would be untrue. */
21
- annotations: { readOnlyHint: boolean; destructiveHint: false; idempotentHint: true; openWorldHint: boolean };
22
+ annotations: { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: true; openWorldHint: boolean };
22
23
  }
23
24
 
24
25
  /** Tools that reach employer boards over the network are open-world; the rest read the local index. */
26
+ const WRITES = new Set<ToolDefinition["name"]>(["prepare_job_search", "watch_jobs", "stop_watch"]);
27
+ /** Monitors need somewhere to persist, which a local install does not have. The host says whether it offers them. */
28
+ const WATCH_TOOLS = new Set<ToolDefinition["name"]>(["watch_jobs", "list_watches", "check_watch", "stop_watch"]);
25
29
  const annotationsFor = (name: ToolDefinition["name"]): ToolDefinition["annotations"] =>
26
- ({ readOnlyHint: name !== "prepare_job_search", destructiveHint: false, idempotentHint: true, openWorldHint: name === "prepare_job_search" || name === "recommend_jobs" || name === "get_job" });
30
+ ({ readOnlyHint: !WRITES.has(name), destructiveHint: name === "stop_watch", idempotentHint: true,
31
+ openWorldHint: name === "prepare_job_search" || name === "recommend_jobs" || name === "get_job" });
27
32
 
28
33
  interface JobWorkflows {
29
34
  prepareJobSearch(input: unknown): Promise<PrepareJobSearchResult>;
@@ -31,6 +36,13 @@ interface JobWorkflows {
31
36
  recommend(input: unknown): Promise<RecommendJobsResult>;
32
37
  analyzeJobFit(input: unknown): Promise<AnalyzeJobFitResult>;
33
38
  optimizeResume(input: unknown): Promise<OptimizeResumeResult>;
39
+ /** Monitors. Absent where the host does not offer them, in which case the four tools are not listed at all. */
40
+ watches?: {
41
+ create(input: unknown): Promise<unknown>;
42
+ list(): Promise<unknown>;
43
+ check(input: unknown): Promise<unknown>;
44
+ stop(input: unknown): Promise<unknown>;
45
+ };
34
46
  }
35
47
 
36
48
  /**
@@ -44,6 +56,10 @@ export type Scope = (typeof SCOPES)[keyof typeof SCOPES];
44
56
  const TOOL_SCOPE: Record<ToolDefinition["name"], Scope> = {
45
57
  prepare_job_search: SCOPES.jobs, get_job_coverage: SCOPES.jobs, search_jobs: SCOPES.jobs, get_job: SCOPES.jobs,
46
58
  recommend_jobs: SCOPES.resume, analyze_job_fit: SCOPES.resume, optimize_resume: SCOPES.resume,
59
+ // Creating or changing a monitor reads a resume. Reading one back does not, so an agent can poll a monitor
60
+ // with a job-search token and never hold resume access at all.
61
+ watch_jobs: SCOPES.resume, stop_watch: SCOPES.resume,
62
+ list_watches: SCOPES.jobs, check_watch: SCOPES.jobs,
47
63
  };
48
64
 
49
65
  /** The scope a tool needs, for a caller that wants to explain the boundary rather than discover it by refusal. */
@@ -222,6 +238,65 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
222
238
  guidance: { type: "string", description: "How to present these results honestly; meant for the assistant, not the candidate" },
223
239
  }, ["jobs", "window", "pagination"]),
224
240
  },
241
+ {
242
+ name: "watch_jobs",
243
+ title: "Watch for roles that fit",
244
+ description: "Create a standing monitor from a resume, so new roles that fit are found without asking again. What is kept is the search the resume produced, never the resume: the skills, titles, seniority and years that matching uses, which the result shows back in plain words so the candidate can read, change or stop it. Use it when someone wants to be told about new roles over time; use recommend_jobs for a one-off ranking right now.",
245
+ inputSchema: {
246
+ type: "object",
247
+ properties: {
248
+ resume: resumeSchema(),
249
+ intent: intentSchema(),
250
+ email: { type: "string", description: "Where to send new matches. Omit to create a monitor the assistant checks with check_watch instead of one that emails" },
251
+ label: { type: "string", description: "A short name for this monitor, so a person with several can tell them apart" },
252
+ },
253
+ required: ["resume"], additionalProperties: false,
254
+ },
255
+ outputSchema: output({
256
+ id: { type: "string", description: "Pass this to check_watch or stop_watch" },
257
+ holding: { type: "string", description: "What is stored, in plain words. Read it back to the candidate: it is the whole privacy claim" },
258
+ profile: { type: "object", additionalProperties: true, description: "The stored search itself, which contains no resume text" },
259
+ matchesNow: { type: "array", items: { type: "object", additionalProperties: true }, description: "Roles matching today, so the monitor is useful immediately" },
260
+ emailing: { type: "boolean", description: "Whether new matches will be emailed; false means the assistant must call check_watch" },
261
+ }, ["id", "holding"]),
262
+ },
263
+ {
264
+ name: "list_watches",
265
+ title: "List monitors",
266
+ description: "Every monitor on this account, with what each one holds and when it last found something. Reads no resume.",
267
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
268
+ outputSchema: output({ watches: { type: "array", items: { type: "object", additionalProperties: true } } }, ["watches"]),
269
+ },
270
+ {
271
+ name: "check_watch",
272
+ title: "What is new for a monitor",
273
+ description: "Roles matching a monitor that it has not reported before, newest first, each with the reason it matched. Reads no resume, so an assistant can poll a monitor without ever holding resume access. Calling it marks those roles as seen, so the next call returns only what is new after that.",
274
+ inputSchema: {
275
+ type: "object",
276
+ properties: {
277
+ id: { type: "string", minLength: 1, description: "The monitor id from watch_jobs or list_watches" },
278
+ limit: { type: "integer", minimum: 1, maximum: 50, default: 20, description: "How many new roles to return" },
279
+ peek: { type: "boolean", default: false, description: "True to look without marking anything as seen" },
280
+ },
281
+ required: ["id"], additionalProperties: false,
282
+ },
283
+ outputSchema: output({
284
+ matches: { type: "array", items: { type: "object", additionalProperties: true }, description: "Each carries the job, why it matched, and the employer's own posting URL" },
285
+ checkedAt: { type: "string" },
286
+ newSince: { type: "string", description: "Roles are new relative to this moment, not to when they were posted" },
287
+ }, ["matches"]),
288
+ },
289
+ {
290
+ name: "stop_watch",
291
+ title: "Stop a monitor",
292
+ description: "Delete a monitor and everything stored with it, including the record of what it has already reported. Nothing is kept.",
293
+ inputSchema: {
294
+ type: "object",
295
+ properties: { id: { type: "string", minLength: 1, description: "The monitor id" } },
296
+ required: ["id"], additionalProperties: false,
297
+ },
298
+ outputSchema: output({ stopped: { type: "boolean" }, deleted: { type: "string", description: "What was removed" } }, ["stopped"]),
299
+ },
225
300
  {
226
301
  name: "get_job",
227
302
  title: "Get job details",
@@ -238,7 +313,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
238
313
 
239
314
  return {
240
315
  // A client sees only what it may call. Listing a tool it would be refused teaches it to try and fail.
241
- list: () => definitions.filter((definition) => allowed(definition.name))
316
+ list: () => definitions.filter((definition) => allowed(definition.name) && (workflows.watches !== undefined || !WATCH_TOOLS.has(definition.name)))
242
317
  .map((definition) => ({ ...definition, annotations: annotationsFor(definition.name) })),
243
318
  async call(name: string, input: Record<string, unknown>) {
244
319
  const needed = TOOL_SCOPE[name as ToolDefinition["name"]];
@@ -251,9 +326,18 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
251
326
  },
252
327
  };
253
328
 
329
+ const requireWatches = () => {
330
+ if (!workflows.watches) throw new Error("Monitors are only available on the hosted server, where there is an account to attach one to.");
331
+ return workflows.watches;
332
+ };
333
+
254
334
  async function dispatch(name: string, input: Record<string, unknown>): Promise<unknown> {
255
335
  if (name === "prepare_job_search") return workflows.prepareJobSearch(input);
256
336
  if (name === "get_job_coverage") return workflows.getJobCoverage(input);
337
+ if (name === "watch_jobs") return requireWatches().create(input);
338
+ if (name === "list_watches") return requireWatches().list();
339
+ if (name === "check_watch") return requireWatches().check(input);
340
+ if (name === "stop_watch") return requireWatches().stop(input);
257
341
  if (name === "recommend_jobs") return workflows.recommend(input);
258
342
  if (name === "analyze_job_fit") return workflows.analyzeJobFit(input);
259
343
  if (name === "optimize_resume") return workflows.optimizeResume(input);
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.58";
1
+ export const VERSION = "0.1.60";
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The part of a candidate we are willing to remember.
3
+ *
4
+ * A monitor has to persist something or it cannot tell you what is new. Resumes are not that something: the
5
+ * privacy page says a resume is read for one request and never stored, and that promise is the product rather
6
+ * than a detail of it. What survives here is the search the resume produced — the skills, titles, seniority and
7
+ * years that matching actually uses — and never a word of the document itself.
8
+ *
9
+ * Evidence spans are dropped deliberately. They are verbatim quotes from the resume, and keeping them would put
10
+ * the document back in the database through a side door.
11
+ */
12
+ import type { CandidateProfile } from "./candidate-profile.ts";
13
+
14
+ export interface WatchProfile {
15
+ /** Skills named in the resume, lower-cased, with no surrounding text. */
16
+ skills: string[];
17
+ /** Role titles the candidate has held, as written, with no employer or dates attached. */
18
+ roles: string[];
19
+ seniority?: "intern" | "junior" | "mid" | "senior" | "lead" | "staff" | "principal" | "manager";
20
+ roleFamilies: string[];
21
+ /** Years as derived from dated work, not as claimed anywhere in prose. */
22
+ approximateYears?: number;
23
+ /** What the candidate asked for, which is theirs to state rather than ours to infer. */
24
+ countries: string[];
25
+ locations: string[];
26
+ remoteOnly?: boolean;
27
+ excludedTerms: string[];
28
+ }
29
+
30
+ const CAP = 40;
31
+ const clean = (values: Iterable<string>, cap = CAP): string[] => {
32
+ const out: string[] = [];
33
+ const seen = new Set<string>();
34
+ for (const raw of values) {
35
+ const value = raw.trim().toLowerCase().slice(0, 64);
36
+ // A fact long enough to be a sentence is a sentence, and a sentence from a resume is resume text.
37
+ if (!value || value.length > 64 || value.split(/\s+/).length > 5 || seen.has(value)) continue;
38
+ seen.add(value); out.push(value);
39
+ if (out.length >= cap) break;
40
+ }
41
+ return out;
42
+ };
43
+
44
+ /** Reduce a parsed resume to the little we keep. Everything not named here is discarded. */
45
+ export function toWatchProfile(profile: CandidateProfile, intent: {
46
+ countries?: string[]; locations?: string[]; remote?: boolean; excludedTerms?: string[];
47
+ } = {}): WatchProfile {
48
+ const facts = profile.facts ?? [];
49
+ const seniority = profile.inferences?.find((i) => i.kind === "seniority");
50
+ const years = profile.inferences?.find((i) => i.kind === "approximate_experience_years");
51
+ return {
52
+ skills: clean(facts.filter((f) => f.kind === "skill").map((f) => f.value)),
53
+ roles: clean(facts.filter((f) => f.kind === "role").map((f) => f.value), 12),
54
+ ...(seniority ? { seniority: seniority.value as WatchProfile["seniority"] } : {}),
55
+ roleFamilies: clean(profile.inferences?.filter((i) => i.kind === "role_family").map((i) => String(i.value)) ?? [], 6),
56
+ ...(years && typeof years.value === "number" ? { approximateYears: years.value } : {}),
57
+ countries: clean(intent.countries ?? [], 8).map((c) => c.toUpperCase()),
58
+ locations: clean(intent.locations ?? [], 12),
59
+ ...(intent.remote === true ? { remoteOnly: true } : {}),
60
+ excludedTerms: clean(intent.excludedTerms ?? [], 16),
61
+ };
62
+ }
63
+
64
+ /** What the candidate is told we hold, in their words rather than ours. A monitor they cannot read is a monitor
65
+ * they cannot trust, so this is shown whenever one is created, listed or edited. */
66
+ export function describeWatchProfile(profile: WatchProfile): string {
67
+ const parts: string[] = [];
68
+ if (profile.roles.length) parts.push(`roles like ${profile.roles.slice(0, 3).join(", ")}`);
69
+ if (profile.seniority) parts.push(`at ${profile.seniority} level`);
70
+ if (profile.approximateYears !== undefined) parts.push(`around ${profile.approximateYears} years of experience`);
71
+ if (profile.skills.length) parts.push(`skills including ${profile.skills.slice(0, 6).join(", ")}`);
72
+ if (profile.countries.length) parts.push(`open to ${profile.countries.join(", ")}`);
73
+ if (profile.locations.length) parts.push(`in ${profile.locations.slice(0, 4).join(", ")}`);
74
+ if (profile.remoteOnly) parts.push("remote only");
75
+ if (profile.excludedTerms.length) parts.push(`not ${profile.excludedTerms.slice(0, 4).join(", ")}`);
76
+ return parts.join("; ") || "no details were derived from the resume";
77
+ }
78
+
79
+ /** The job fields a monitor needs. Deliberately the same shape the search already produces. */
80
+ export interface WatchableJob {
81
+ id: string; title: string; company: string; location: string; remote: boolean;
82
+ countries: string[]; skills?: string; experience?: { min: number; max?: number } | null;
83
+ }
84
+
85
+ /** Why a role matched, so a candidate is told rather than asked to trust a score. */
86
+ export interface WatchMatch { job: WatchableJob; score: number; reasons: string[] }
87
+
88
+ /**
89
+ * Roles worth telling this candidate about. The rule is deliberately plain: a role has to look like work they
90
+ * have done, in a place they said they would work, and not be something they asked to avoid. A clever ranking
91
+ * that cannot be explained in one line is worse here than a simple one that can, because the candidate is reading
92
+ * an email rather than watching a demo.
93
+ */
94
+ export function matchWatch(profile: WatchProfile, jobs: readonly WatchableJob[], limit = 20): WatchMatch[] {
95
+ const skills = new Set(profile.skills);
96
+ const roleWords = new Set(profile.roles.flatMap((role) => role.split(/\s+/)).filter((w) => w.length > 3));
97
+ const excluded = profile.excludedTerms;
98
+ const matches: WatchMatch[] = [];
99
+ for (const job of jobs) {
100
+ const title = (job.title || "").toLowerCase();
101
+ const haystack = `${title} ${(job.skills || "").toLowerCase()}`;
102
+ if (excluded.some((term) => title.includes(term))) continue;
103
+ if (profile.remoteOnly && !job.remote) continue;
104
+ if (profile.countries.length && !job.countries.some((code) => profile.countries.includes(code))) continue;
105
+ if (profile.locations.length && !job.remote) {
106
+ const where = (job.location || "").toLowerCase();
107
+ if (!profile.locations.some((place) => where.includes(place))) continue;
108
+ }
109
+ const titleHits = [...roleWords].filter((word) => title.includes(word));
110
+ const skillHits = [...skills].filter((skill) => haystack.includes(skill));
111
+ if (!titleHits.length && !skillHits.length) continue;
112
+ // Stated years are the employer's requirement, so a role far above the candidate is noise rather than a stretch.
113
+ if (profile.approximateYears !== undefined && job.experience && job.experience.min > profile.approximateYears + 3) continue;
114
+ const reasons: string[] = [];
115
+ if (titleHits.length) reasons.push(`the title matches your work: ${titleHits.slice(0, 3).join(", ")}`);
116
+ if (skillHits.length) reasons.push(`asks for ${skillHits.slice(0, 4).join(", ")}`);
117
+ if (job.experience) reasons.push(`wants ${job.experience.min}${job.experience.max ? `-${job.experience.max}` : "+"} years`);
118
+ matches.push({ job, score: titleHits.length * 2 + skillHits.length, reasons });
119
+ }
120
+ return matches.sort((left, right) => right.score - left.score).slice(0, limit);
121
+ }