openings 0.1.35 → 0.1.37

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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` | Plain keyword, location, country, and remote search over the index, newest first. `country` takes a two-letter code such as `IN`; `maxAgeDays` keeps only roles posted within that window. |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
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" },
@@ -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 recommendation.
16
- 3. Supply the user's 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.
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/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 { experienceMatches, normalizeJobExperience } from "./experience.ts";
5
6
 
6
7
  type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
7
8
 
@@ -101,13 +102,13 @@ export function createCatalog(options: CatalogOptions): Catalog {
101
102
  let job = (await fetchJobs(company)).find((candidate) => candidate.id === id) ?? null;
102
103
  if (job && !job.description.trim()) job = { ...job, description: await fetchJobDescription(company, job, fetcher) };
103
104
  if (job && !job.description.trim()) throw new Error(`Full description unavailable for job: ${id}`);
104
- return job;
105
+ return job ? normalizeJobExperience(job) : null;
105
106
  },
106
107
  };
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
- const run = (days: number) => matched.filter((job) => withinWindow(job, days, now)).sort((left, right) => postedTime(right) - postedTime(left));
122
+ const matched = jobs.filter((job) => matches(job, query)).map(normalizeJobExperience).filter((job) => {
123
+ if (query.experienceYears === undefined) return true;
124
+ const experience = job.experience;
125
+ return experience ? experienceMatches(experience, query.experienceYears) : 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 result = found.slice(0, query.limit ?? 50).map((job) => toSummary(job, now)) as SearchResult;
135
+ const offset = query.offset ?? 0, limit = query.limit ?? 50;
136
+ const result = found.slice(offset, offset + limit).map((job) => toSummary(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/crawler.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { FetchJobsObserver } from "./catalog.ts";
2
- import { statedExperience } from "./experience.ts";
2
+ import { EXPERIENCE_VERSION, normalizeJobExperience } from "./experience.ts";
3
3
  import { partitionFor, type Company, type CrawlFailure, type CrawlReport, type CrawlSourceResult, type Job, type JobPartition, type JobSnapshot } from "./types.ts";
4
4
 
5
5
  export interface SnapshotStore {
@@ -49,23 +49,22 @@ export function createCrawler(options: CrawlerOptions): Crawler {
49
49
 
50
50
  /** Required experience for every job: from its description, the last crawl's reading, or a paced detail fetch for recent roles in describeCountries. */
51
51
  async function withExperience(source: Company, jobs: Job[], previous: Job[] | undefined): Promise<Job[]> {
52
- const known = new Map((previous ?? []).filter((job) => job.experience !== undefined).map((job) => [job.id, job.experience]));
52
+ const known = new Map((previous ?? []).map(normalizeJobExperience).filter((job) => job.experienceVersion === EXPERIENCE_VERSION && job.experience !== undefined).map((job) => [job.id, job]));
53
53
  const since = now().getTime() - 30 * 86_400_000;
54
54
  const deadline = Date.now() + DESCRIBE_BUDGET_MS;
55
55
  let budget = DESCRIBE_LIMIT;
56
56
  const out: Job[] = [];
57
57
  for (const job of jobs) {
58
- if (job.experience !== undefined) out.push(job);
59
- else if (job.description.trim()) out.push({ ...job, experience: statedExperience(job.description) });
60
- else if (known.has(job.id)) out.push({ ...job, experience: known.get(job.id) });
58
+ if (job.description.trim() || job.experienceVersion === EXPERIENCE_VERSION) out.push(normalizeJobExperience(job));
59
+ else if (known.has(job.id)) out.push({ ...job, description: known.get(job.id)!.description, experience: known.get(job.id)!.experience, experienceVersion: EXPERIENCE_VERSION });
61
60
  else if (options.describe && budget > 0 && Date.now() < deadline && job.eligibleCountries.some((code) => describeCountries.has(code)) && Date.parse(job.updatedAt ?? "") >= since) {
62
61
  budget -= 1;
63
62
  try {
64
63
  if (options.workdayPageDelayMs) await pacingSleep(options.workdayPageDelayMs);
65
64
  const description = await options.describe(source, job);
66
- out.push(description.trim() ? { ...job, experience: statedExperience(description) } : job);
67
- } catch { out.push(job); } // unread; the next crawl tries again
68
- } else out.push(job);
65
+ out.push(normalizeJobExperience({ ...job, description }));
66
+ } catch { out.push(normalizeJobExperience(job)); } // unread; the next crawl tries again
67
+ } else out.push(normalizeJobExperience(job));
69
68
  }
70
69
  return out;
71
70
  }
package/src/experience.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { decodeEntities } from "./providers.ts";
1
+ import { plainText } from "./providers.ts";
2
2
 
3
3
  /** Years of experience a posting asks for, read from its own text. */
4
- export interface Experience { min: number; max?: number }
4
+ export interface Experience { min: number; max?: number; maxOpen?: boolean }
5
+ export const EXPERIENCE_VERSION = 2;
5
6
 
6
7
  const WORDS: Record<string, number> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, twelve: 12, fifteen: 15 };
7
8
  const NUM = String.raw`(\d{1,2}(?:\.\d)?|${Object.keys(WORDS).join("|")})`;
@@ -11,6 +12,7 @@ const YEARS_THEN_EXPERIENCE = new RegExp(String.raw`${RANGE}(?:\s+of)?(?:\s+[\w/
11
12
  const EXPERIENCE_THEN_YEARS = new RegExp(String.raw`\b(?:experiences?|exp)\b[^.\n\d]{0,30}?${RANGE}`, "gi");
12
13
  // "minimum 15+ years" with no word "experience" after it.
13
14
  const MINIMUM_YEARS = new RegExp(String.raw`\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?${RANGE}`, "gi");
15
+ const STANDALONE_YEARS = new RegExp(String.raw`^[ \t]*(?:[•·-][ \t]*)?${RANGE}[ \t]*[.!]?[ \t]*$`, "gmi");
14
16
  // Company history reads the same way ("our 135 years of experience"); skip matches that talk about the employer.
15
17
  const ABOUT_EMPLOYER = /\b(?:our (?:company|firm|group|organi[sz]ation|brands?|history|legacy)|(?:company|firm|group) (?:has|with)|founded|established|history|legacy|heritage)\b[^.\n?!:;•·-]*$/i;
16
18
  const MIN_PREFIX = /\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?$/i;
@@ -20,11 +22,15 @@ function value(text: string | undefined): number | undefined {
20
22
  return WORDS[text.toLowerCase()] ?? Number(text);
21
23
  }
22
24
 
23
- /** The first experience requirement the text states, or null when it states none. */
25
+ /** Prefer overall/total experience and standalone role ranges to skill-specific requirements. */
24
26
  export function statedExperience(description: string): Experience | null {
25
- const text = decodeEntities(description);
26
- const found: Array<{ index: number; experience: Experience }> = [];
27
- for (const pattern of [YEARS_THEN_EXPERIENCE, EXPERIENCE_THEN_YEARS, MINIMUM_YEARS]) {
27
+ return readExperience(description)?.experience ?? null;
28
+ }
29
+
30
+ export function readExperience(description: string): { index: number; quote: string; priority: number; experience: Experience } | null {
31
+ const text = plainText(description);
32
+ const found: Array<{ index: number; quote: string; priority: number; experience: Experience }> = [];
33
+ for (const pattern of [YEARS_THEN_EXPERIENCE, EXPERIENCE_THEN_YEARS, MINIMUM_YEARS, STANDALONE_YEARS]) {
28
34
  for (const match of text.matchAll(pattern)) {
29
35
  const [low, high] = [value(match[1]), value(match[2])];
30
36
  if (low === undefined || !Number.isFinite(low)) continue;
@@ -32,11 +38,15 @@ export function statedExperience(description: string): Experience | null {
32
38
  if (low > 20 || (high !== undefined && (high < low || high > 40))) continue;
33
39
  const before = text.slice(Math.max(0, match.index - 60), match.index);
34
40
  if (pattern !== MINIMUM_YEARS && ABOUT_EMPLOYER.test(before) && !MIN_PREFIX.test(before)) continue;
35
- found.push({ index: match.index, experience: high !== undefined && high !== low ? { min: low, max: high } : { min: low } });
41
+ const lineStart = Math.max(text.lastIndexOf("\n", match.index - 1), text.lastIndexOf(".", match.index - 1)) + 1;
42
+ const context = text.slice(lineStart, match.index) + match[0];
43
+ const priority = /\b(?:overall|total)\b/i.test(context) ? 3 : pattern === STANDALONE_YEARS ? 2 : 1;
44
+ const maxOpen = high !== undefined && /(?:\+\s*(?:years?|yrs?)|(?:years?|yrs?)\s*\+)/i.test(match[0]);
45
+ found.push({ index: match.index, quote: match[0], priority, experience: high !== undefined && high !== low ? { min: low, max: high, ...(maxOpen ? { maxOpen: true } : {}) } : { min: low } });
36
46
  }
37
47
  }
38
- found.sort((left, right) => left.index - right.index);
39
- return found[0]?.experience ?? null;
48
+ found.sort((left, right) => right.priority - left.priority || right.experience.min - left.experience.min || left.index - right.index);
49
+ return found[0] ?? null;
40
50
  }
41
51
 
42
52
  /** Entry-level titles (intern, trainee, fresher, graduate) as a rough 0–1 year estimate when the posting states nothing. */
@@ -46,5 +56,17 @@ export function titleExperience(title: string): Experience | undefined {
46
56
 
47
57
  /** "2–5 yrs", "5+ yrs". */
48
58
  export function experienceLabel(experience: Experience): string {
49
- return experience.max === undefined ? `${experience.min}+ yrs` : `${experience.min}–${experience.max} yrs`;
59
+ return experience.max === undefined ? `${experience.min}+ yrs` : `${experience.min}–${experience.max}${experience.maxOpen ? "+" : ""} yrs`;
60
+ }
61
+
62
+ export function experienceMatches(experience: Experience, years: number): boolean {
63
+ return years >= experience.min && (experience.max === undefined || experience.maxOpen === true || years <= experience.max);
64
+ }
65
+
66
+ /** Old cached readings without their source text cannot be safely corrected: make them unknown. */
67
+ export function normalizeJobExperience<T extends { description: string; experience?: Experience | null; experienceVersion?: number }>(job: T): Omit<T, "experience" | "experienceVersion"> & { experience?: Experience | null; experienceVersion?: number } {
68
+ if (job.description.trim()) return { ...job, experience: statedExperience(job.description), experienceVersion: EXPERIENCE_VERSION };
69
+ if (job.experienceVersion === EXPERIENCE_VERSION) return job;
70
+ const { experience: _experience, experienceVersion: _version, ...rest } = job;
71
+ return rest;
50
72
  }
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export const companies: Company[] = Object.entries(companyData).map(([slug, valu
17
17
 
18
18
  export const catalog = createCatalog({ companies });
19
19
  export { createCatalog } from "./catalog.ts";
20
- export { experienceLabel, statedExperience, titleExperience, type Experience } from "./experience.ts";
20
+ export { EXPERIENCE_VERSION, experienceLabel, experienceMatches, normalizeJobExperience, statedExperience, titleExperience, type Experience } from "./experience.ts";
21
21
  export type { Catalog } from "./catalog.ts";
22
22
  export type { Ats, Company, CrawlReport, Job, JobPartition, JobSnapshot, JobSummary, SearchQuery } from "./types.ts";
23
23
  export { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
@@ -1,4 +1,4 @@
1
- import { experienceLabel } from "./experience.ts";
1
+ import { experienceLabel, normalizeJobExperience } from "./experience.ts";
2
2
  import { validateCandidateProfileEvidence, type CandidateProfile } from "./candidate-profile.ts";
3
3
  import { isEligibleForCountry, normalizeLocation } from "./locations.ts";
4
4
  import { detectRequirementTerms, findTransferability, matchesExactSkillEvidence, requiresExactSkillEvidence, type TransferabilityKind } from "./requirement-vocabulary.ts";
@@ -71,7 +71,8 @@ export function matchJobs(profile: CandidateProfile, intent: CandidateIntent, jo
71
71
  const roleFamilies = deriveRoleFamilies(profile, intent);
72
72
  const filteredOut: FilteredJob[] = [];
73
73
  const ranked: Array<JobMatch & { score: number; index: number }> = [];
74
- for (const [index, job] of jobs.entries()) {
74
+ for (const [index, rawJob] of jobs.entries()) {
75
+ const job = normalizeJobExperience(rawJob);
75
76
  const rejectionReasons = hardFilterReasons(job, intent);
76
77
  if (rejectionReasons.length) {
77
78
  filteredOut.push({ jobId: job.id, reasons: rejectionReasons });
@@ -263,7 +264,7 @@ function seniorityAlignment(profile: CandidateProfile, intent: CandidateIntent,
263
264
  if (job.experience && typeof resumeYears === "number" && !intent.seniority?.length) {
264
265
  // The posting's own range is better evidence than a seniority word in the title. A year short or a few over still fits.
265
266
  const asked = experienceLabel(job.experience);
266
- const fits = resumeYears >= job.experience.min - 1 && (job.experience.max === undefined || resumeYears <= job.experience.max + 3);
267
+ const fits = resumeYears >= job.experience.min - 1 && (job.experience.max === undefined || job.experience.maxOpen === true || resumeYears <= job.experience.max + 3);
267
268
  return { score: fits ? 3 : -3, reason: `posting asks for ${asked}; resume shows about ${resumeYears} years` };
268
269
  }
269
270
  const jobSeniority = seniorityTerms.find((term) => includesPhrase(job.title, term));
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: 1) call prepare_job_search for the person's countries; it is normally ready in one call. 2) Ask for the resume before exploring roles, because recommend_jobs ranks by evidence from it. 3) If the person declines a resume, use search_jobs with their keywords. 4) If they decline that too, use your judgement.",
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.",
package/src/runtime.ts CHANGED
@@ -5,6 +5,7 @@ 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";
7
7
  import { VERSION } from "./version.ts";
8
+ import { normalizeJobExperience } from "./experience.ts";
8
9
  import { catalog as liveCatalog, companies } from "./index.ts";
9
10
  import { createLocalJobs } from "./local-jobs.ts";
10
11
  import { createJobRecommender } from "./job-recommendations.ts";
@@ -82,7 +83,7 @@ export function createHostedRuntime(options: { sources: Company[]; store: Snapsh
82
83
  if (!company) return job;
83
84
  try {
84
85
  const description = await fetchJobDescription(company, job, globalThis.fetch);
85
- return description.trim() ? { ...job, description } : job;
86
+ return description.trim() ? normalizeJobExperience({ ...job, description }) : job;
86
87
  } catch { return job; } // the employer's board is unreachable; the summary is still useful
87
88
  }
88
89
  const snapshotJob = async (id: string) => (await local.get(id, { offline: true, staleDays: 3650 })).job;
@@ -1,5 +1,7 @@
1
1
  import type { CandidateProfile } from "./candidate-profile.ts";
2
2
  import type { Job } from "./types.ts";
3
+ import { readExperience } from "./experience.ts";
4
+ import { plainText } from "./providers.ts";
3
5
 
4
6
  export interface ScreeningRequirement {
5
7
  kind: "experience" | "education";
@@ -14,6 +16,12 @@ export function evaluateScreeningRequirements(profile: CandidateProfile, job: Jo
14
16
  ...experienceRequirements(profile, description),
15
17
  ...educationRequirements(profile, description),
16
18
  ];
19
+ const text = plainText(job.description);
20
+ const reading = readExperience(text);
21
+ if (reading && (isMandatory(text, reading.index, reading.quote) || (reading.priority === 2 && lastSectionKind(text.slice(0, reading.index)) !== "optional")) && !requirements.some((item) => item.kind === "experience" && Number(item.requirement.match(/\d+/)?.[0]) === reading.experience.min)) {
22
+ const inference = profile.inferences.find((value) => value.kind === "approximate_experience_years");
23
+ requirements.push({ kind: "experience", requirement: clean(reading.quote), status: !inference ? "unsupported" : inference.value >= reading.experience.min ? "supported" : "partial", factIds: inference?.derivedFromFactIds ?? [] });
24
+ }
17
25
  return [...new Map(requirements.map((item) => [`${item.kind}:${item.requirement.toLocaleLowerCase()}`, item])).values()];
18
26
  }
19
27
 
@@ -1,4 +1,5 @@
1
1
  import type { Job } from "./types.ts";
2
+ import { normalizeJobExperience } from "./experience.ts";
2
3
 
3
4
  export interface SelectedJobLookupOptions {
4
5
  getSnapshotJob(id: string): Promise<Job | null>;
@@ -8,7 +9,8 @@ export interface SelectedJobLookupOptions {
8
9
  export function createSelectedJobLookup(options: SelectedJobLookupOptions) {
9
10
  return async (id: string): Promise<Job | null> => {
10
11
  const snapshot = await options.getSnapshotJob(id);
11
- if (snapshot?.description.trim()) return snapshot;
12
- return options.getDetailedJob(id);
12
+ if (snapshot?.description.trim()) return normalizeJobExperience(snapshot);
13
+ const detailed = await options.getDetailedJob(id);
14
+ return detailed ? normalizeJobExperience(detailed) : null;
13
15
  };
14
16
  }
@@ -1,5 +1,6 @@
1
1
  import { searchJobs, type Catalog } from "./catalog.ts";
2
2
  import type { SnapshotStore } from "./crawler.ts";
3
+ import { normalizeJobExperience } from "./experience.ts";
3
4
 
4
5
  export function createSnapshotCatalog(store: SnapshotStore): Catalog {
5
6
  return {
@@ -13,7 +14,7 @@ export function createSnapshotCatalog(store: SnapshotStore): Catalog {
13
14
  if (!snapshot) return null;
14
15
  for (const partition of Object.values(snapshot.partitions)) {
15
16
  const job = partition.jobs.find((candidate) => candidate.id === id);
16
- if (job) return job;
17
+ if (job) return normalizeJobExperience(job);
17
18
  }
18
19
  return null;
19
20
  },
package/src/tools.ts CHANGED
@@ -106,7 +106,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
106
106
  },
107
107
  {
108
108
  name: "search_jobs",
109
- description: "Plain keyword search over the local job index, newest first, walking 7, 14, 30 days then everything until 5 results appear; the window used is returned. Use only when the person declines to share a resume or asks for a plain search; recommend_jobs ranks against a resume.",
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.",
110
110
  inputSchema: {
111
111
  type: "object",
112
112
  properties: {
@@ -114,8 +114,11 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
114
114
  location: { type: "string", description: "Case-insensitive location substring" },
115
115
  country: { type: "string", pattern: "^[A-Za-z]{2}$", description: "Two-letter country code for job eligibility, such as IN or DE" },
116
116
  remote: { type: "boolean", description: "True for remote-only; false for non-remote-only" },
117
- maxAgeDays: { type: "integer", minimum: 0, maximum: 365, description: "Only roles posted within this many days, newest first. Default 30 (undated roles kept, listed last); an explicit value also drops undated roles; 0 includes older roles" },
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." },
118
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." },
119
122
  },
120
123
  additionalProperties: false,
121
124
  },
@@ -148,7 +151,10 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
148
151
  if (name === "analyze_job_fit") return workflows.analyzeJobFit(input);
149
152
  if (name === "optimize_resume") return workflows.optimizeResume(input);
150
153
  if (name === "search_jobs") {
151
- 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");
152
158
  if (input.query !== undefined && typeof input.query !== "string") throw new Error("query must be a string");
153
159
  if (input.location !== undefined && typeof input.location !== "string") throw new Error("location must be a string");
154
160
  if (input.remote !== undefined && typeof input.remote !== "boolean") throw new Error("remote must be a boolean");
@@ -162,10 +168,13 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
162
168
  if (typeof input.remote === "boolean") query.remote = input.remote;
163
169
  if (typeof input.maxAgeDays === "number") query.maxAgeDays = input.maxAgeDays;
164
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;
165
174
  const jobs = await catalog.search(query) as SearchResult;
166
175
  return {
167
- jobs, window: jobs.window,
168
- guidance: "Unranked keyword matches. Present roles labelled older or stale as possibly still open, not as current. With a resume, recommend_jobs ranks roles by evidence and explains fit.",
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.",
169
178
  };
170
179
  }
171
180
  if (name === "get_job") {
package/src/types.ts CHANGED
@@ -87,6 +87,7 @@ export interface JobSummary {
87
87
  updatedAt?: string;
88
88
  /** Years of experience the posting states, read from its description. null: the description states none. Absent: not read yet. */
89
89
  experience?: Experience | null;
90
+ experienceVersion?: number;
90
91
  /** Days since the posting date at the time of the search; absent when the board gave no date. */
91
92
  postedDaysAgo?: number;
92
93
  /** new = 7 days or less, older = 8 to 30, stale = beyond 30, undated = no posting date. Present it as such; a stale listing may still be open. */
@@ -116,6 +117,11 @@ export interface SearchQuery {
116
117
  /** 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
118
  maxAgeDays?: number;
118
119
  limit?: number;
120
+ /** Zero-based offset within the chosen date window; restart when the index changes. */
121
+ offset?: number;
122
+ /** Match stated experience ranges, not inferred seniority or a hiring guarantee. */
123
+ experienceYears?: number;
124
+ includeUnknownExperience?: boolean;
119
125
  }
120
126
 
121
127
  export interface CrawlFailure {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.35";
1
+ export const VERSION = "0.1.37";