openings 0.1.34 → 0.1.36
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/.codex-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/openings/SKILL.md +2 -2
- package/src/candidate-profile.ts +11 -0
- package/src/catalog.ts +11 -4
- package/src/mcp.ts +6 -4
- package/src/runtime.ts +15 -3
- package/src/tools.ts +28 -11
- package/src/types.ts +5 -0
- package/src/version.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.36",
|
|
4
4
|
"description": "Find evidence-grounded jobs, including relevant roles you may not have searched for, without accounts or API keys.",
|
|
5
5
|
"author": { "name": "Openings contributors" },
|
|
6
6
|
"license": "MIT",
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ The [job-seeker quickstart](docs/job-seeker-quickstart.md) has sample prompts, a
|
|
|
61
61
|
| `recommend_jobs` | Ranks jobs against a resume and explicit intent. Returns direct, hidden, and stretch results with evidence. Performs at most one scoped refresh; `refresh.policy: "never"` guarantees no crawl. |
|
|
62
62
|
| `analyze_job_fit` | Explains one job against verbatim resume evidence: supported, transferable, unsupported, and screening risks. |
|
|
63
63
|
| `optimize_resume` | Proposes grounded suggestions, an additive diff, or revised Markdown. Never invents experience. |
|
|
64
|
-
| `search_jobs` |
|
|
64
|
+
| `search_jobs` | Resume-free keyword, location, country, remote, and stated-experience search, newest first. `country` takes a two-letter code such as `IN`. Optional `experienceYears` compares stated min/max ranges, excluding unknowns unless `includeUnknownExperience: true`. Results include `pagination.total` and `pagination.nextOffset`; request the next page with that `offset`, unchanged filters, and `maxAgeDays` set to the returned `window.daysUsed`. Restart pagination after index refresh; this is not a frozen snapshot cursor. Without `maxAgeDays`, search widens through 7/14/30/all days until at least five results exist. |
|
|
65
65
|
| `get_job` | Returns one job with its full description. |
|
|
66
66
|
|
|
67
67
|
There is deliberately no form-fill, apply, or submit tool. The only thing Openings writes is your own job index.
|
package/package.json
CHANGED
package/skills/openings/SKILL.md
CHANGED
|
@@ -12,8 +12,8 @@ Use `prepare_job_search` to bootstrap the private local index and set honest cou
|
|
|
12
12
|
## Search
|
|
13
13
|
|
|
14
14
|
1. Ask for the target countries and call `prepare_job_search` before requesting a resume. Explain that first-time setup runs in resumable batches and uses the network only to build a private local job index. While `nextAction` is `call_again`, call the tool again with the same countries and the returned opaque `continuation`; do not ask for a resume between batches. On `retry_later`, disclose failures and offer to continue with the indexed coverage or retry later without the old continuation. On `ready`, present its job-level source, job, and employer counts exactly as returned; never substitute discovery-cohort counts or recalculate coverage.
|
|
15
|
-
2. Ask only for missing constraints that materially change the
|
|
16
|
-
3.
|
|
15
|
+
2. Ask only for missing constraints that materially change the search. Search immediately with `search_jobs` when role and country are known; do not require or repeatedly request a resume. Use `experienceYears` only when supplied, explain that it compares stated posting ranges, and offer `includeUnknownExperience` when strict results are thin. Unknown experience is not proof of fit or a gap. To show another page, keep the filters, set `maxAgeDays` to the returned `window.daysUsed`, and use `pagination.nextOffset`; restart after an index refresh.
|
|
16
|
+
3. Offer resume matching as optional. When the user chooses it, supply their resume content, explicit intent, and their chosen `ranking.mode` (`evidence` by default or `keyword`) to `recommend_jobs`; never pass an arbitrary filesystem path. Use `ranking.minimumPercent` only when the user requests a cutoff.
|
|
17
17
|
4. Present the returned coverage summary alongside the recommendations without recalculating it.
|
|
18
18
|
5. Present direct, hidden title-family, and stretch results separately. For a hidden result, quote the returned title expansions and whether each came from explicit intent or validated resume fact IDs; never invent or broaden an alias in the skill.
|
|
19
19
|
6. Present both returned percentages, the selected score, assumptions, evidence, gaps, refresh status, and failures without recalculating fit. Never describe keyword overlap as demonstrated competence.
|
package/src/candidate-profile.ts
CHANGED
|
@@ -36,6 +36,14 @@ export class ResumeInputError extends Error {
|
|
|
36
36
|
) { super(message); }
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** A PDF or DOCX pasted as text: a file signature, or control characters no resume contains. */
|
|
40
|
+
function looksBinary(content: string): boolean {
|
|
41
|
+
const head = content.slice(0, 2048);
|
|
42
|
+
if (/^%PDF-|^PK\u0003\u0004|^\{\\rtf/.test(content.trimStart())) return true;
|
|
43
|
+
const control = head.match(/[\u0000-\u0008\u000e-\u001f]/g)?.length ?? 0;
|
|
44
|
+
return control > 0 && control / Math.max(head.length, 1) > 0.01;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
export function parseCandidateProfile(input: unknown): CandidateProfile {
|
|
40
48
|
if (!isRecord(input) || typeof input.format !== "string" || typeof input.content !== "string") {
|
|
41
49
|
throw new ResumeInputError("invalid_resume_input", "Resume must contain text or Markdown content");
|
|
@@ -43,6 +51,9 @@ export function parseCandidateProfile(input: unknown): CandidateProfile {
|
|
|
43
51
|
if (input.format === "pdf_base64" || input.format === "docx_base64") {
|
|
44
52
|
throw new ResumeInputError("unsupported_resume_format", `Resume format ${input.format} is not supported yet`, input.format, ["text", "markdown"]);
|
|
45
53
|
}
|
|
54
|
+
if (looksBinary(input.content)) {
|
|
55
|
+
throw new ResumeInputError("invalid_resume_input", "That looks like a file's raw bytes, not resume text. Export the resume as text, or paste the text itself.");
|
|
56
|
+
}
|
|
46
57
|
if (!(["text", "markdown"] as string[]).includes(input.format)) {
|
|
47
58
|
throw new ResumeInputError("invalid_resume_input", "Resume must contain text or Markdown content");
|
|
48
59
|
}
|
package/src/catalog.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { CASCADE_MINIMUM, CASCADE_WINDOWS, type Company, type Job, type JobAge,
|
|
|
2
2
|
import { decodeEntities, providerSpec, type JsonGet } from "./providers.ts";
|
|
3
3
|
import { crawlSite, sitePostingsToJobs } from "./jobposting-site.ts";
|
|
4
4
|
import { classifyJob, isEligibleForCountry, normalizeLocation } from "./locations.ts";
|
|
5
|
+
import { statedExperience } from "./experience.ts";
|
|
5
6
|
|
|
6
7
|
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
7
8
|
|
|
@@ -107,7 +108,7 @@ export function createCatalog(options: CatalogOptions): Catalog {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
export const DEFAULT_MAX_AGE_DAYS = 30;
|
|
110
|
-
export type SearchResult = JobSummary[] & { window?: SearchWindow };
|
|
111
|
+
export type SearchResult = JobSummary[] & { window?: SearchWindow; pagination?: { offset: number; limit: number; total: number; nextOffset: number | null } };
|
|
111
112
|
|
|
112
113
|
/** One window: dated jobs inside it; 0 means everything including undated. */
|
|
113
114
|
export function withinWindow(job: Pick<Job, "updatedAt">, days: number, now: number): boolean {
|
|
@@ -118,8 +119,12 @@ export function withinWindow(job: Pick<Job, "updatedAt">, days: number, now: num
|
|
|
118
119
|
|
|
119
120
|
/** Keyword search newest first. Without maxAgeDays it walks 7, 14, 30, then everything until CASCADE_MINIMUM results appear; the window used rides on the result. */
|
|
120
121
|
export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.now()): SearchResult {
|
|
121
|
-
const matched = jobs.filter((job) => matches(job, query))
|
|
122
|
-
|
|
122
|
+
const matched = jobs.filter((job) => matches(job, query)).filter((job) => {
|
|
123
|
+
if (query.experienceYears === undefined) return true;
|
|
124
|
+
const experience = job.experience === undefined ? statedExperience(job.description) : job.experience;
|
|
125
|
+
return experience ? query.experienceYears >= experience.min && (experience.max === undefined || query.experienceYears <= experience.max) : query.includeUnknownExperience === true;
|
|
126
|
+
});
|
|
127
|
+
const run = (days: number) => matched.filter((job) => withinWindow(job, days, now)).sort((left, right) => postedTime(right) - postedTime(left) || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
|
|
123
128
|
const steps: SearchWindow["steps"] = [];
|
|
124
129
|
let found: Job[] = [];
|
|
125
130
|
for (const days of query.maxAgeDays !== undefined ? [query.maxAgeDays] : CASCADE_WINDOWS) {
|
|
@@ -127,7 +132,9 @@ export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.n
|
|
|
127
132
|
steps.push({ days, results: found.length });
|
|
128
133
|
if (found.length >= CASCADE_MINIMUM) break;
|
|
129
134
|
}
|
|
130
|
-
const
|
|
135
|
+
const offset = query.offset ?? 0, limit = query.limit ?? 50;
|
|
136
|
+
const result = found.slice(offset, offset + limit).map((job) => toSummary(query.experienceYears !== undefined && job.experience === undefined ? { ...job, experience: statedExperience(job.description) } : job, now)) as SearchResult;
|
|
137
|
+
Object.defineProperty(result, "pagination", { value: { offset, limit, total: found.length, nextOffset: offset + limit < found.length ? offset + limit : null }, enumerable: false });
|
|
131
138
|
Object.defineProperty(result, "window", { value: { daysUsed: steps[steps.length - 1]!.days, widened: steps.length > 1, steps } satisfies SearchWindow, enumerable: false });
|
|
132
139
|
return result;
|
|
133
140
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface ToolHandler {
|
|
|
19
19
|
|
|
20
20
|
/** Read by the AI app before its first call: the order of operations that keeps results honest. */
|
|
21
21
|
export const FLOW_INSTRUCTIONS = [
|
|
22
|
-
"Openings flow:
|
|
22
|
+
"Openings flow: ask only for missing search constraints, such as role and country. Call prepare_job_search when setup is needed, then search_jobs immediately; a resume is optional, never a prerequisite to explore jobs. Offer recommend_jobs when the person wants evidence-based matching and chooses to share a resume. Follow preparation.nextAction rather than assuming setup succeeded.",
|
|
23
23
|
"Freshness: results start with roles posted in the last 7 days and widen to 14, 30, then everything only when fewer than 5 appear; each result carries age (new, older, stale, undated) and postedDaysAgo. Say which window results came from and present older or stale roles as possibly still open, never as current.",
|
|
24
24
|
"Experience: a job's experience field is the years its posting states ({ min, max }); null means the posting states none, and absent means it was not read. Quote it as the employer's requirement.",
|
|
25
25
|
"Every match carries applyUrl, the employer's own posting; include it. When recommend_jobs reports no_matches, relay its explanation and nextMoves instead of searching silently.",
|
|
@@ -48,11 +48,13 @@ export function createMcpHandler(tools: ToolHandler, options: { update?: () => U
|
|
|
48
48
|
if (args !== undefined && !isRecord(args)) throw new Error("tools/call arguments must be an object");
|
|
49
49
|
try {
|
|
50
50
|
const result = await tools.call(name, args ?? {});
|
|
51
|
-
|
|
51
|
+
const payload = withUpdate(result);
|
|
52
|
+
// Both shapes: text for models that read it, structuredContent for hosts that render it.
|
|
53
|
+
return { ...base, result: { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], structuredContent: payload, isError: false } };
|
|
52
54
|
} catch (error) {
|
|
53
55
|
const details = errorDetails(error);
|
|
54
|
-
const
|
|
55
|
-
return { ...base, result: { content: [{ type: "text", text: JSON.stringify(
|
|
56
|
+
const failure = withUpdate({ error: { message: error instanceof Error ? error.message : String(error), ...(details ?? {}) } });
|
|
57
|
+
return { ...base, result: { content: [{ type: "text", text: JSON.stringify(failure, null, 2) }], structuredContent: failure, isError: true } };
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
if (request.method.startsWith("notifications/")) return null;
|
package/src/runtime.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { SnapshotStore } from "./crawler.ts";
|
|
3
|
-
import type { Company, SearchQuery } from "./types.ts";
|
|
3
|
+
import type { Company, Job, SearchQuery } from "./types.ts";
|
|
4
4
|
import { fetchJobDescription, fetchSourceJobs } from "./catalog.ts";
|
|
5
5
|
import { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
|
|
6
6
|
import { createUsageReporter, type UsageReporter } from "./usage.ts";
|
|
@@ -74,12 +74,24 @@ export function createHostedRuntime(options: { sources: Company[]; store: Snapsh
|
|
|
74
74
|
const recommender = createJobRecommender({ sources: options.sources, store: options.store, crawl: noCrawl, now: options.now });
|
|
75
75
|
const coverage = createJobCoverageReader({ sources: options.sources, store: options.store });
|
|
76
76
|
const preparation = createJobSearchPreparer({ sources: options.sources, store: options.store, crawl: noCrawl, now: options.now, freshnessDays: 3650 });
|
|
77
|
-
|
|
77
|
+
// Workday and a few other boards keep descriptions off their listings, so the shared index has none: read one on demand.
|
|
78
|
+
async function withDescription(job: Job | null): Promise<Job | null> {
|
|
79
|
+
if (!job || job.description.trim()) return job;
|
|
80
|
+
const [ats, slug] = job.id.split(":", 3);
|
|
81
|
+
const company = options.sources.find((source) => source.ats === ats && source.slug === slug);
|
|
82
|
+
if (!company) return job;
|
|
83
|
+
try {
|
|
84
|
+
const description = await fetchJobDescription(company, job, globalThis.fetch);
|
|
85
|
+
return description.trim() ? { ...job, description } : job;
|
|
86
|
+
} catch { return job; } // the employer's board is unreachable; the summary is still useful
|
|
87
|
+
}
|
|
88
|
+
const snapshotJob = async (id: string) => (await local.get(id, { offline: true, staleDays: 3650 })).job;
|
|
89
|
+
const getSelectedJob = createSelectedJobLookup({ getSnapshotJob: snapshotJob, getDetailedJob: async (id) => withDescription(await snapshotJob(id)) });
|
|
78
90
|
const analyzer = createJobFitAnalyzer({ getJob: getSelectedJob });
|
|
79
91
|
const optimizer = createResumeOptimizer({ analyzeJobFit: analyzer.analyze });
|
|
80
92
|
return {
|
|
81
93
|
search: (query: SearchQuery) => local.search(query, { offline: true, staleDays: 3650 }),
|
|
82
|
-
get: (id: string) => local.get(id, { offline: true, staleDays: 3650 }),
|
|
94
|
+
get: async (id: string) => { const found = await local.get(id, { offline: true, staleDays: 3650 }); return { ...found, job: await withDescription(found.job) }; },
|
|
83
95
|
prepareJobSearch: preparation.prepare, getJobCoverage: coverage.getCoverage, recommend: recommender.recommend, analyzeJobFit: analyzer.analyze, optimizeResume: optimizer.optimize,
|
|
84
96
|
};
|
|
85
97
|
}
|
package/src/tools.ts
CHANGED
|
@@ -11,8 +11,14 @@ export interface ToolDefinition {
|
|
|
11
11
|
name: "prepare_job_search" | "get_job_coverage" | "recommend_jobs" | "analyze_job_fit" | "optimize_resume" | "search_jobs" | "get_job";
|
|
12
12
|
description: string;
|
|
13
13
|
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 };
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/** Tools that reach employer boards over the network are open-world; the rest read the local index. */
|
|
19
|
+
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" });
|
|
21
|
+
|
|
16
22
|
interface JobWorkflows {
|
|
17
23
|
prepareJobSearch(input: unknown): Promise<PrepareJobSearchResult>;
|
|
18
24
|
getJobCoverage(input: unknown): Promise<JobCoverageSummary>;
|
|
@@ -22,7 +28,7 @@ interface JobWorkflows {
|
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, options: { onCall?(name: string, input: Record<string, unknown>, result: unknown): void } = {}) {
|
|
25
|
-
const definitions: ToolDefinition
|
|
31
|
+
const definitions: Array<Omit<ToolDefinition, "annotations">> = [
|
|
26
32
|
{
|
|
27
33
|
name: "prepare_job_search",
|
|
28
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.",
|
|
@@ -100,7 +106,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
100
106
|
},
|
|
101
107
|
{
|
|
102
108
|
name: "search_jobs",
|
|
103
|
-
description: "
|
|
109
|
+
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.",
|
|
104
110
|
inputSchema: {
|
|
105
111
|
type: "object",
|
|
106
112
|
properties: {
|
|
@@ -108,8 +114,11 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
108
114
|
location: { type: "string", description: "Case-insensitive location substring" },
|
|
109
115
|
country: { type: "string", pattern: "^[A-Za-z]{2}$", description: "Two-letter country code for job eligibility, such as IN or DE" },
|
|
110
116
|
remote: { type: "boolean", description: "True for remote-only; false for non-remote-only" },
|
|
111
|
-
maxAgeDays: { type: "integer", minimum: 0, maximum: 365, description: "
|
|
117
|
+
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." },
|
|
112
118
|
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
|
119
|
+
offset: { type: "integer", minimum: 0, maximum: 1000000, default: 0 },
|
|
120
|
+
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
|
+
includeUnknownExperience: { type: "boolean", default: false, description: "With experienceYears, also keep roles without a stated range, clearly unknown rather than matched." },
|
|
113
122
|
},
|
|
114
123
|
additionalProperties: false,
|
|
115
124
|
},
|
|
@@ -119,7 +128,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
119
128
|
description: "Get the full description and application URL for a job returned by recommend_jobs or search_jobs.",
|
|
120
129
|
inputSchema: {
|
|
121
130
|
type: "object",
|
|
122
|
-
properties: { id: { type: "string", description: "Stable job id returned by search_jobs" } },
|
|
131
|
+
properties: { id: { type: "string", description: "Stable job id returned by search_jobs or recommend_jobs (jobId is accepted too)" } },
|
|
123
132
|
required: ["id"],
|
|
124
133
|
additionalProperties: false,
|
|
125
134
|
},
|
|
@@ -127,7 +136,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
127
136
|
];
|
|
128
137
|
|
|
129
138
|
return {
|
|
130
|
-
list: () => definitions,
|
|
139
|
+
list: () => definitions.map((definition) => ({ ...definition, annotations: annotationsFor(definition.name) })),
|
|
131
140
|
async call(name: string, input: Record<string, unknown>) {
|
|
132
141
|
const result = await dispatch(name, input);
|
|
133
142
|
try { options.onCall?.(name, input, result); } catch { /* usage reporting never affects a tool result */ }
|
|
@@ -142,7 +151,10 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
142
151
|
if (name === "analyze_job_fit") return workflows.analyzeJobFit(input);
|
|
143
152
|
if (name === "optimize_resume") return workflows.optimizeResume(input);
|
|
144
153
|
if (name === "search_jobs") {
|
|
145
|
-
assertToolKeys(input, ["query", "location", "country", "remote", "maxAgeDays", "limit"], "search_jobs");
|
|
154
|
+
assertToolKeys(input, ["query", "location", "country", "remote", "maxAgeDays", "limit", "offset", "experienceYears", "includeUnknownExperience"], "search_jobs");
|
|
155
|
+
if (input.offset !== undefined && (!Number.isInteger(input.offset) || (input.offset as number) < 0 || (input.offset as number) > 1000000)) throw new Error("offset must be an integer between 0 and 1000000");
|
|
156
|
+
if (input.experienceYears !== undefined && (typeof input.experienceYears !== "number" || !Number.isFinite(input.experienceYears) || input.experienceYears < 0 || input.experienceYears > 60)) throw new Error("experienceYears must be a number between 0 and 60");
|
|
157
|
+
if (input.includeUnknownExperience !== undefined && typeof input.includeUnknownExperience !== "boolean") throw new Error("includeUnknownExperience must be a boolean");
|
|
146
158
|
if (input.query !== undefined && typeof input.query !== "string") throw new Error("query must be a string");
|
|
147
159
|
if (input.location !== undefined && typeof input.location !== "string") throw new Error("location must be a string");
|
|
148
160
|
if (input.remote !== undefined && typeof input.remote !== "boolean") throw new Error("remote must be a boolean");
|
|
@@ -156,16 +168,21 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
156
168
|
if (typeof input.remote === "boolean") query.remote = input.remote;
|
|
157
169
|
if (typeof input.maxAgeDays === "number") query.maxAgeDays = input.maxAgeDays;
|
|
158
170
|
if (typeof input.limit === "number") query.limit = input.limit;
|
|
171
|
+
if (typeof input.offset === "number") query.offset = input.offset;
|
|
172
|
+
if (typeof input.experienceYears === "number") query.experienceYears = input.experienceYears;
|
|
173
|
+
if (typeof input.includeUnknownExperience === "boolean") query.includeUnknownExperience = input.includeUnknownExperience;
|
|
159
174
|
const jobs = await catalog.search(query) as SearchResult;
|
|
160
175
|
return {
|
|
161
|
-
jobs, window: jobs.window,
|
|
162
|
-
guidance: "Unranked keyword matches.
|
|
176
|
+
jobs, window: jobs.window, pagination: jobs.pagination,
|
|
177
|
+
guidance: "Unranked keyword matches. Experience filters compare stated ranges only; unknown experience is not a match or a gap. Present older/stale roles as possibly still open, not current. Reuse window.daysUsed as maxAgeDays and pagination.nextOffset with unchanged filters for the next page. Pagination is over the current index, not a frozen snapshot; restart if the index refreshes. Resume-based matching is optional.",
|
|
163
178
|
};
|
|
164
179
|
}
|
|
165
180
|
if (name === "get_job") {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
181
|
+
// analyze_job_fit and optimize_resume call it jobId, so accept either spelling rather than fail on a near miss.
|
|
182
|
+
assertToolKeys(input, ["id", "jobId"], "get_job");
|
|
183
|
+
const id = typeof input.id === "string" && input.id ? input.id : input.jobId;
|
|
184
|
+
if (typeof id !== "string" || !id) throw new Error("get_job requires a non-empty id");
|
|
185
|
+
return { job: await catalog.get(id) };
|
|
169
186
|
}
|
|
170
187
|
throw new Error(`Unknown tool: ${name}`);
|
|
171
188
|
}
|
package/src/types.ts
CHANGED
|
@@ -116,6 +116,11 @@ export interface SearchQuery {
|
|
|
116
116
|
/** Only jobs posted within this many days. Unset walks 7, 14, 30, then everything until 5 results appear; an explicit value is a single window that drops undated jobs; 0 includes everything. */
|
|
117
117
|
maxAgeDays?: number;
|
|
118
118
|
limit?: number;
|
|
119
|
+
/** Zero-based offset within the chosen date window; restart when the index changes. */
|
|
120
|
+
offset?: number;
|
|
121
|
+
/** Match stated experience ranges, not inferred seniority or a hiring guarantee. */
|
|
122
|
+
experienceYears?: number;
|
|
123
|
+
includeUnknownExperience?: boolean;
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
export interface CrawlFailure {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.36";
|