openings 0.1.58 → 0.1.59
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 +1 -1
- package/src/index.ts +1 -0
- package/src/tools.ts +88 -4
- package/src/version.ts +1 -1
- package/src/watch-profile.ts +121 -0
package/package.json
CHANGED
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";
|
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:
|
|
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
|
|
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.
|
|
1
|
+
export const VERSION = "0.1.59";
|
|
@@ -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
|
+
}
|