openings 0.1.48 → 0.1.49

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.ts +136 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
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/tools.ts CHANGED
@@ -9,15 +9,21 @@ import type { PrepareJobSearchResult } from "./job-search-preparation.ts";
9
9
 
10
10
  export interface ToolDefinition {
11
11
  name: "prepare_job_search" | "get_job_coverage" | "recommend_jobs" | "analyze_job_fit" | "optimize_resume" | "search_jobs" | "get_job";
12
+ /** Human-readable name for a consent screen or tool picker, where a snake_case identifier reads poorly. */
13
+ title: string;
12
14
  description: string;
13
15
  inputSchema: Record<string, unknown>;
14
- /** Every tool only reads: nothing here applies, submits, or writes on the candidate's behalf. */
15
- annotations: { readOnlyHint: true; destructiveHint: false; idempotentHint: true; openWorldHint: boolean };
16
+ /** The shape of structuredContent. Deliberately permissive: a caller may rely on the fields named here, and a
17
+ * result that grows a field must not fail validation in an app that pinned an older schema. */
18
+ outputSchema: Record<string, unknown>;
19
+ /** Nothing here applies, submits, or writes on the candidate's behalf. Preparation is the one tool that writes
20
+ * 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 };
16
22
  }
17
23
 
18
24
  /** Tools that reach employer boards over the network are open-world; the rest read the local index. */
19
25
  const annotationsFor = (name: ToolDefinition["name"]): ToolDefinition["annotations"] =>
20
- ({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: name === "prepare_job_search" || name === "recommend_jobs" || name === "get_job" });
26
+ ({ readOnlyHint: name !== "prepare_job_search", destructiveHint: false, idempotentHint: true, openWorldHint: name === "prepare_job_search" || name === "recommend_jobs" || name === "get_job" });
21
27
 
22
28
  interface JobWorkflows {
23
29
  prepareJobSearch(input: unknown): Promise<PrepareJobSearchResult>;
@@ -31,30 +37,44 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
31
37
  const definitions: Array<Omit<ToolDefinition, "annotations">> = [
32
38
  {
33
39
  name: "prepare_job_search",
34
- description: "Initialize the local job index: the first call downloads the shared index of every verified source (thousands of employers) and returns ready; only missing or stale sources are crawled, in batches of at most 25. Report coverage from the result rather than calling again once nextAction is ready. This may use the network and write only job data under the local Openings data directory; it never processes a resume.",
40
+ title: "Prepare job search",
41
+ description: "Download and refresh the local job index so searches have data to read. The first call fetches the shared index of every verified source (thousands of employers); later calls crawl only missing or stale sources, at most 25 per call, returning a continuation token until nextAction reports ready. Call it when a search says setup is needed, not before every search, and not at all on the hosted server, where the index is already prepared and this returns ready at once. Uses the network and writes job data under the local Openings data directory; it never reads, writes or transmits a resume.",
35
42
  inputSchema: {
36
43
  type: "object",
37
44
  properties: {
38
- countries: { ...countryArray(), minItems: 1, maxItems: 20 },
39
- continuation: { type: "string", description: "Opaque token returned by the preceding preparation batch" },
45
+ countries: { ...countryArray(), minItems: 1, maxItems: 20, description: "Two-letter codes whose sources should be prepared, such as IN or US" },
46
+ continuation: { type: "string", description: "Opaque token from the previous call's result. Pass it back to prepare the next batch of at most 25 sources; omit it to start" },
40
47
  },
41
48
  required: ["countries"],
42
49
  additionalProperties: false,
43
50
  },
51
+ outputSchema: output({
52
+ status: { type: "string", enum: ["ready", "partial"], description: "ready means searches can run now; partial means sources are still missing" },
53
+ nextAction: { type: "string", enum: ["ready", "call_again", "retry_later"], description: "call_again means pass continuation back for the next batch; retry_later means the network refused and waiting is the fix" },
54
+ continuation: { type: "string", description: "Token for the next batch; present only when nextAction is call_again" },
55
+ note: { type: "string" },
56
+ networkAttempted: { type: "boolean", description: "Whether this call actually reached out to employer boards" },
57
+ sources: { type: "object", additionalProperties: true, description: "How many sources are in the catalog, indexed, fresh, stale, missing and pending" },
58
+ coverage: coverageSchema(),
59
+ crawl: { type: "object", additionalProperties: true, description: "What this batch crawled: selected, succeeded, and the sources that failed" },
60
+ }, ["status", "nextAction", "coverage"]),
44
61
  },
45
62
  {
46
63
  name: "get_job_coverage",
47
- description: "Report current job-level coverage for one or more countries before a candidate supplies a resume.",
64
+ title: "Check job coverage",
65
+ description: "Report how many live roles the index holds for each country and how recently they were posted. Use it to set expectations before asking a candidate for anything, or to judge whether preparation is worth running; use search_jobs instead to see the roles themselves. Reads the index already on hand and never crawls.",
48
66
  inputSchema: {
49
67
  type: "object",
50
- properties: { countries: { ...countryArray(), minItems: 1, maxItems: 20 } },
68
+ properties: { countries: { ...countryArray(), minItems: 1, maxItems: 20, description: "Two-letter codes to report coverage for, such as IN or US" } },
51
69
  required: ["countries"],
52
70
  additionalProperties: false,
53
71
  },
72
+ outputSchema: coverageSchema(),
54
73
  },
55
74
  {
56
75
  name: "recommend_jobs",
57
- description: "Parse a resume, apply explicit job intent, rank evidence-grounded matches, separate direct, hidden title-family, and stretch opportunities, and optionally refresh the local snapshot once.",
76
+ title: "Recommend jobs from a resume",
77
+ description: "Rank jobs against a resume, quoting the evidence for each match, and separate direct matches from hidden title-family and stretch roles. Use it when the candidate has chosen to share a resume and wants matching; prefer search_jobs for plain filtering, which needs no resume and answers faster. The resume is parsed in memory for this call and never stored. When matches are thin it may refresh the local snapshot once, per the refresh policy.",
58
78
  inputSchema: {
59
79
  type: "object",
60
80
  properties: {
@@ -62,50 +82,95 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
62
82
  intent: intentSchema(),
63
83
  ranking: {
64
84
  type: "object",
85
+ description: "How matches are scored and how weak a match may be before it is dropped",
65
86
  properties: {
66
- mode: { type: "string", enum: ["evidence", "keyword"], default: "evidence" },
87
+ mode: { type: "string", enum: ["evidence", "keyword"], default: "evidence", description: "evidence scores a match only on requirements the resume text supports; keyword scores on term overlap alone" },
67
88
  minimumPercent: { type: "number", minimum: 0, maximum: 100, default: 0 },
68
89
  },
69
90
  additionalProperties: false,
70
91
  },
71
92
  refresh: {
72
93
  type: "object",
94
+ description: "Whether this call may crawl employer boards before ranking. Crawling costs seconds; the default only does it when the result would otherwise be thin",
73
95
  properties: {
74
- policy: { type: "string", enum: ["auto", "never", "always"], default: "auto" },
75
- minimumMatches: { type: "integer", minimum: 0, default: 5 },
76
- staleDays: { type: "number", minimum: 0, default: 14 },
96
+ policy: { type: "string", enum: ["auto", "never", "always"], default: "auto", description: "auto crawls once only when matches are thin and the snapshot is stale; never keeps the snapshot as it is; always crawls first" },
97
+ minimumMatches: { type: "integer", minimum: 0, default: 5, description: "With auto, the match count below which a refresh is worth the wait" },
98
+ staleDays: { type: "number", minimum: 0, default: 14, description: "With auto, how old the snapshot must be before a refresh is considered" },
77
99
  },
78
100
  additionalProperties: false,
79
101
  },
80
- limit: { type: "integer", minimum: 1, maximum: 100, default: 20 },
102
+ limit: { type: "integer", minimum: 1, maximum: 100, default: 20, description: "How many ranked matches to return" },
81
103
  },
82
104
  required: ["resume", "intent"], additionalProperties: false,
83
105
  },
106
+ outputSchema: output({
107
+ outcome: { type: "string", enum: ["matches", "widened", "no_matches"], description: "widened means the date window had to open up to find anything; no_matches means say so rather than searching again silently" },
108
+ matches: { type: "array", description: "Ranked matches, each carrying the job, its scores, and the resume text that supports them", items: { type: "object", additionalProperties: true } },
109
+ explanation: { type: "string", description: "Why the result looks the way it does, in words meant for the candidate" },
110
+ nextMoves: { type: "array", items: { type: "string" }, description: "What to try next when the result is thin; relay these instead of inventing advice" },
111
+ window: { type: "object", additionalProperties: true, description: "Which age windows were walked and which one the results came from" },
112
+ profile: { type: "object", additionalProperties: true, description: "What was read from the resume. It is returned so claims can be checked, and is not stored" },
113
+ exploration: { type: "object", additionalProperties: true, description: "Direct, hidden title-family and stretch groupings" },
114
+ filteredOut: { type: "object", additionalProperties: true, description: "Counts by reason, with a small sample, for roles the intent excluded" },
115
+ assumptions: { type: "array", items: { type: "string" }, description: "Anything inferred rather than stated; worth repeating to the candidate" },
116
+ ranking: { type: "object", additionalProperties: true },
117
+ coverage: coverageSchema(),
118
+ snapshot: { type: "object", additionalProperties: true, description: "Age and size of the index these matches came from" },
119
+ refresh: { type: "object", additionalProperties: true, description: "Whether this call crawled, and what it found" },
120
+ shortfall: { type: "object", additionalProperties: true, description: "Present when fewer matches came back than asked for" },
121
+ nextActions: { type: "array", items: { type: "string" } },
122
+ }, ["outcome", "matches", "explanation"]),
84
123
  },
85
124
  {
86
125
  name: "analyze_job_fit",
87
- description: "Analyze one stable job id against explicit, verbatim resume evidence; report support, gaps, screening risks, and interview preparation without inventing candidate facts.",
126
+ title: "Analyze fit for one job",
127
+ description: "Explain how one job fits a resume: which requirements the resume supports, which it does not, the screening risks, and what to prepare for an interview, each tied to text quoted from the resume rather than inferred. Use it after search_jobs or recommend_jobs has produced a job id and the candidate wants depth on a single role instead of a list. The resume is parsed in memory for this call and never stored.",
88
128
  inputSchema: {
89
129
  type: "object",
90
- properties: { jobId: { type: "string", minLength: 1 }, resume: resumeSchema(), intent: intentSchema() },
130
+ properties: {
131
+ jobId: { type: "string", minLength: 1, description: "Stable job id from search_jobs or recommend_jobs" },
132
+ resume: resumeSchema(), intent: intentSchema(),
133
+ },
91
134
  required: ["jobId", "resume"], additionalProperties: false,
92
135
  },
136
+ outputSchema: output({
137
+ job: jobSchema("The job that was analyzed"),
138
+ assessment: { type: "object", additionalProperties: true, description: "The overall read, with its reasoning" },
139
+ scores: { type: "object", additionalProperties: true },
140
+ supported: { type: "array", items: { type: "object", additionalProperties: true }, description: "Requirements the resume supports, each with the quoted evidence" },
141
+ partiallySupported: { type: "array", items: { type: "object", additionalProperties: true }, description: "Requirements with partial evidence, and what is missing" },
142
+ unsupported: { type: "array", items: { type: "string" }, description: "Requirements the resume does not evidence at all" },
143
+ screeningRisks: { type: "array", items: { type: "string" }, description: "What is likely to stop this application early" },
144
+ interviewPreparationGaps: { type: "array", items: { type: "string" } },
145
+ profile: { type: "object", additionalProperties: true, description: "What was read from the resume; not stored" },
146
+ }, ["job", "assessment"]),
93
147
  },
94
148
  {
95
149
  name: "optimize_resume",
96
- description: "Propose an evidence-grounded resume revision for one selected job without overwriting the original or inserting unsupported claims.",
150
+ title: "Optimize a resume for one job",
151
+ description: "Propose a revision of a resume for one specific job, as suggestions, a unified diff or revised markdown. It rewrites emphasis and wording only: the original is never overwritten and no claim the resume does not already support is added. Use it after analyze_job_fit has shown which gaps are real. The resume is parsed in memory for this call and never stored.",
97
152
  inputSchema: {
98
153
  type: "object",
99
154
  properties: {
100
- jobId: { type: "string", minLength: 1 },
155
+ jobId: { type: "string", minLength: 1, description: "Stable job id from search_jobs or recommend_jobs" },
101
156
  resume: resumeSchema(),
102
- output: { type: "string", enum: ["suggestions", "unified_diff", "revised_markdown"] },
157
+ output: { type: "string", enum: ["suggestions", "unified_diff", "revised_markdown"], description: "suggestions lists changes to consider; unified_diff shows them as a patch; revised_markdown returns the rewritten resume" },
103
158
  },
104
159
  required: ["jobId", "resume", "output"], additionalProperties: false,
105
160
  },
161
+ outputSchema: output({
162
+ output: { type: "string", enum: ["suggestions", "unified_diff", "revised_markdown"], description: "Which form was produced" },
163
+ suggestions: { type: "array", items: { type: "object", additionalProperties: true }, description: "Each proposed change with the evidence behind it" },
164
+ content: { type: "string", description: "The diff or rewritten resume, when that form was asked for" },
165
+ gaps: { type: "array", items: { type: "string" }, description: "What the resume cannot honestly claim, and so was left alone" },
166
+ job: jobSchema("The job the revision targets"),
167
+ profile: { type: "object", additionalProperties: true },
168
+ originalOverwritten: { type: "boolean", description: "Always false: the candidate's resume is never modified in place" },
169
+ }, ["output", "suggestions", "originalOverwritten"]),
106
170
  },
107
171
  {
108
172
  name: "search_jobs",
173
+ title: "Search jobs",
109
174
  description: "Search jobs immediately from role, country, location and optional stated experience. No resume required. Newest first; automatically widens 7, 14, 30 days then all dates until 5 matches. For another page reuse the same filters and returned window.daysUsed as maxAgeDays, with pagination.nextOffset. Resume-based ranking is optional via recommend_jobs.",
110
175
  inputSchema: {
111
176
  type: "object",
@@ -115,16 +180,23 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
115
180
  country: { type: "string", pattern: "^[A-Za-z]{2}$", description: "Two-letter country code for job eligibility, such as IN or DE" },
116
181
  remote: { type: "boolean", description: "True for remote-only; false for non-remote-only" },
117
182
  maxAgeDays: { type: "integer", minimum: 0, maximum: 365, description: "Omit to widen 7/14/30/all until 5 matches. Explicit positive values exclude undated roles; 0 includes all dates." },
118
- limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
119
- offset: { type: "integer", minimum: 0, maximum: 1000000, default: 0 },
183
+ limit: { type: "integer", minimum: 1, maximum: 100, default: 50, description: "How many jobs to return in this page" },
184
+ offset: { type: "integer", minimum: 0, maximum: 1000000, default: 0, description: "Where the page starts. Use pagination.nextOffset from the previous result rather than counting by hand" },
120
185
  experienceYears: { type: "number", minimum: 0, maximum: 60, description: "Years of experience to compare with the posting's stated min/max range; not a qualification or fit verdict." },
121
186
  includeUnknownExperience: { type: "boolean", default: false, description: "With experienceYears, also keep roles without a stated range, clearly unknown rather than matched." },
122
187
  },
123
188
  additionalProperties: false,
124
189
  },
190
+ outputSchema: output({
191
+ jobs: { type: "array", items: jobSchema("One matching job"), description: "Matches, newest first" },
192
+ window: { type: "object", additionalProperties: true, description: "The age window the results came from, and whether it had to widen. Say which window was used" },
193
+ pagination: { type: "object", additionalProperties: true, description: "offset, limit, total and nextOffset. nextOffset is null on the last page" },
194
+ guidance: { type: "string", description: "How to present these results honestly; meant for the assistant, not the candidate" },
195
+ }, ["jobs", "window", "pagination"]),
125
196
  },
126
197
  {
127
198
  name: "get_job",
199
+ title: "Get job details",
128
200
  description: "Get the full description and application URL for a job returned by recommend_jobs or search_jobs.",
129
201
  inputSchema: {
130
202
  type: "object",
@@ -132,6 +204,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
132
204
  required: ["id"],
133
205
  additionalProperties: false,
134
206
  },
207
+ outputSchema: output({ job: jobSchema("The full job, including its description and the employer's application URL") }, ["job"]),
135
208
  },
136
209
  ];
137
210
 
@@ -191,9 +264,10 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
191
264
  function resumeSchema(): Record<string, unknown> {
192
265
  return {
193
266
  type: "object",
267
+ description: "The resume itself, passed inline. It is parsed in memory for this call and never written to disk, logged, or sent anywhere else.",
194
268
  properties: {
195
269
  content: { type: "string", minLength: 1, description: "Resume content supplied directly; filesystem paths are not accepted" },
196
- format: { type: "string", enum: ["text", "markdown", "pdf_base64", "docx_base64"] },
270
+ format: { type: "string", enum: ["text", "markdown", "pdf_base64", "docx_base64"], description: "How content is encoded: plain text, markdown, or base64 of a PDF or DOCX file" },
197
271
  },
198
272
  required: ["content", "format"], additionalProperties: false,
199
273
  };
@@ -202,16 +276,53 @@ function resumeSchema(): Record<string, unknown> {
202
276
  function intentSchema(): Record<string, unknown> {
203
277
  return {
204
278
  type: "object",
279
+ description: "What the candidate is actually looking for, stated explicitly rather than guessed from the resume. Every field is optional; the ones given narrow the result, and the excluded* fields remove roles the candidate does not want to see.",
205
280
  properties: {
206
- roles: stringArray(), countries: countryArray(), locations: stringArray(), remote: { type: "boolean" }, seniority: stringArray(),
207
- requiredSkills: stringArray(), excludedTerms: stringArray(),
208
- excludedCountries: countryArray(), excludedLocations: stringArray(), excludedRoles: stringArray(),
281
+ roles: { ...stringArray(), description: "Role titles the candidate is looking for, in their own words, such as \"backend engineer\" or \"data analyst\". Matched against the job title" },
282
+ countries: { ...countryArray(), description: "Two-letter codes the candidate may work in, such as IN or US. This is eligibility to work, not where the office is" },
283
+ locations: { ...stringArray(), description: "Cities or regions to keep, matched as case-insensitive substrings of the job's location, such as \"Bengaluru\" or \"Delhi NCR\"" },
284
+ remote: { type: "boolean", description: "True to keep only remote roles, false to drop them; omit to keep both" },
285
+ seniority: { ...stringArray(), description: "Levels to keep, in the posting's own vocabulary, such as \"senior\" or \"lead\". Not a years-of-experience filter: use experienceYears in search_jobs for that" },
286
+ requiredSkills: { ...stringArray(), description: "Skills a role must state to be kept, such as \"kubernetes\". Each is matched as a whole word" },
287
+ excludedTerms: { ...stringArray(), description: "Words that disqualify a role, matched as whole words in the title, such as \"intern\" or \"sales\"" },
288
+ excludedCountries: { ...countryArray(), description: "Two-letter codes to drop even when a role is otherwise eligible" },
289
+ excludedLocations: { ...stringArray(), description: "Cities or regions to drop, matched like locations" },
290
+ excludedRoles: { ...stringArray(), description: "Role titles to drop, matched like roles" },
209
291
  maxAgeDays: { type: "integer", minimum: 0, maximum: 365, description: "Only roles posted within this many days. Default 30 (undated roles kept); an explicit value also drops undated roles; 0 includes older roles" },
210
292
  },
211
293
  additionalProperties: false,
212
294
  };
213
295
  }
214
296
 
297
+ /** An object schema that documents the fields a caller can rely on without forbidding the ones it does not know. */
298
+ function output(properties: Record<string, unknown>, required: string[] = []): Record<string, unknown> {
299
+ return { type: "object", properties, ...(required.length ? { required } : {}), additionalProperties: true };
300
+ }
301
+
302
+ function jobSchema(description: string): Record<string, unknown> {
303
+ return {
304
+ type: "object", description, additionalProperties: true,
305
+ properties: {
306
+ id: { type: "string", description: "Stable id to pass to get_job, analyze_job_fit or optimize_resume" },
307
+ title: { type: "string" }, company: { type: "string" }, location: { type: "string" },
308
+ remote: { type: "boolean" }, workMode: { type: "string", enum: ["remote", "hybrid", "onsite", "unknown"] },
309
+ url: { type: "string", description: "The employer's own posting, which is where an application is made" },
310
+ updatedAt: { type: "string", description: "When the board says the role was posted or last updated; absent when the board states none" },
311
+ age: { type: "string", enum: ["new", "older", "stale", "undated"], description: "Bucketed posting age; undated means the board gave no date, not that the role is fresh" },
312
+ postedDaysAgo: { type: "number" },
313
+ experience: { type: ["object", "null"], description: "Years the posting itself states as { min, max }; null when it states none, absent when the description was not read", additionalProperties: true },
314
+ eligibleCountries: { type: "array", items: { type: "string" }, description: "Two-letter codes the role is open to" },
315
+ },
316
+ };
317
+ }
318
+
319
+ function coverageSchema(): Record<string, unknown> {
320
+ return output({
321
+ snapshotUpdatedAt: { type: "string", description: "When the index this answer came from was last refreshed" },
322
+ countries: { type: "array", description: "Per-country live and recent role counts", items: { type: "object", additionalProperties: true } },
323
+ });
324
+ }
325
+
215
326
  function assertToolKeys(input: Record<string, unknown>, allowed: string[], tool: string): void {
216
327
  const unknown = Object.keys(input).find((key) => !allowed.includes(key));
217
328
  if (unknown) throw new Error(`${tool} does not accept field: ${unknown}`);