openings 0.1.13 → 0.1.15

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.13",
3
+ "version": "0.1.15",
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
@@ -50,7 +50,7 @@ The [job-seeker quickstart](docs/job-seeker-quickstart.md) has sample prompts, a
50
50
  | `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. |
51
51
  | `analyze_job_fit` | Explains one job against verbatim resume evidence: supported, transferable, unsupported, and screening risks. |
52
52
  | `optimize_resume` | Proposes grounded suggestions, an additive diff, or revised Markdown. Never invents experience. |
53
- | `search_jobs` | Plain keyword, location, country, and remote search over the index. `country` takes a two-letter code such as `IN`. |
53
+ | `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. |
54
54
  | `get_job` | Returns one job with its full description. |
55
55
 
56
56
  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.13",
3
+ "version": "0.1.15",
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/catalog.ts CHANGED
@@ -20,6 +20,7 @@ interface GreenhouseJob {
20
20
  title: string;
21
21
  location: { name: string };
22
22
  absolute_url: string;
23
+ first_published?: string;
23
24
  updated_at?: string;
24
25
  content?: string;
25
26
  }
@@ -109,8 +110,18 @@ export function createCatalog(options: CatalogOptions): Catalog {
109
110
  };
110
111
  }
111
112
 
112
- export function searchJobs(jobs: Job[], query: SearchQuery): JobSummary[] {
113
- return jobs.filter((job) => matches(job, query)).slice(0, query.limit ?? 50).map(toSummary);
113
+ export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.now()): JobSummary[] {
114
+ const since = query.maxAgeDays ? now - query.maxAgeDays * 86_400_000 : undefined;
115
+ return jobs
116
+ .filter((job) => matches(job, query) && (since === undefined || postedTime(job) >= since))
117
+ .sort((left, right) => postedTime(right) - postedTime(left))
118
+ .slice(0, query.limit ?? 50).map(toSummary);
119
+ }
120
+
121
+ /** Posting time in ms for sorting; undated jobs sort last. */
122
+ export function postedTime(job: Pick<Job, "updatedAt">): number {
123
+ const time = job.updatedAt ? Date.parse(job.updatedAt) : Number.NaN;
124
+ return Number.isFinite(time) ? time : 0;
114
125
  }
115
126
 
116
127
  export interface FetchJobsObserver {
@@ -293,10 +304,22 @@ function normalizeWorkday(company: Company, source: ReturnType<typeof parseWorkd
293
304
  workMode: /remote/i.test(location) ? "remote" : "unknown",
294
305
  eligibleCountries: [], excludedCountries: [], eligibleRegions: [], eligibilityConfidence: "unknown",
295
306
  url: `https://${source.host}/en-US/${source.site}${job.externalPath}`,
307
+ ...(workdayPostedAt(job.postedOn) ? { updatedAt: workdayPostedAt(job.postedOn) } : {}),
296
308
  description: "",
297
309
  });
298
310
  }
299
311
 
312
+ /** Workday lists only a relative label ("Posted Today", "Posted 3 Days Ago", "Posted 30+ Days Ago"); turn it into an approximate posting date. */
313
+ export function workdayPostedAt(label: string | undefined, now: number = Date.now()): string | undefined {
314
+ const text = label?.trim().toLowerCase() ?? "";
315
+ let days: number | undefined;
316
+ if (/\btoday\b/.test(text)) days = 0;
317
+ else if (/\byesterday\b/.test(text)) days = 1;
318
+ else { const match = /(\d+)\+?\s*days?\s*ago/.exec(text); if (match) days = Number(match[1]) + (text.includes("+") ? 1 : 0); }
319
+ if (days === undefined) return undefined;
320
+ return new Date(now - days * 86_400_000).toISOString().slice(0, 10);
321
+ }
322
+
300
323
  async function fetchWorkdayDescription(company: Company, jobUrl: string, fetcher: Fetch): Promise<string> {
301
324
  const source = parseWorkdayToken(company.token);
302
325
  const path = new URL(jobUrl).pathname.replace(new RegExp(`^/en-US/${escapeRegExp(source.site)}`), "");
@@ -367,7 +390,7 @@ function normalizeGreenhouse(company: Company, job: GreenhouseJob): Job {
367
390
  eligibleRegions: [],
368
391
  eligibilityConfidence: "unknown",
369
392
  url: job.absolute_url,
370
- updatedAt: job.updated_at,
393
+ updatedAt: job.first_published ?? job.updated_at,
371
394
  description: stripHtml(job.content ?? ""),
372
395
  });
373
396
  }
@@ -376,7 +399,7 @@ function normalizeRecruitee(company: Company, job: RecruiteeJob): Job {
376
399
  const location = [job.city, job.state_name, job.country_code?.toUpperCase()].filter(Boolean).join(", ") || "Unspecified";
377
400
  const translation = job.translations?.en ?? Object.values(job.translations ?? {})[0];
378
401
  const description = [translation?.description ?? job.description, translation?.requirements ?? job.requirements].filter(Boolean).map((value) => stripHtml(value!)).join("\n\n");
379
- const updatedAt = job.updated_at ?? job.published_at;
402
+ const updatedAt = job.published_at ?? job.updated_at;
380
403
  return classifyJob({
381
404
  id: `recruitee:${company.slug}:${job.guid}`,
382
405
  company: company.name,
package/src/cli.ts CHANGED
@@ -22,7 +22,7 @@ import { mergeAttemptedRoundLeads, prepareRecruiteeRoundArtifacts } from "./recr
22
22
  const HELP = `Openings — search public company job boards
23
23
 
24
24
  Usage:
25
- openings crawl [--workday-countries IN,US] [--country CODE | --companies FILE] [--concurrency N] [--source-cache-hours N] [--source-limit N] [--delay-ms N] [--workday-page-delay-ms N] [--data-dir PATH]
25
+ openings crawl [--workday-countries IN,US] [--country CODE | --companies FILE] [--concurrency N] [--source-cache-hours N] [--source-limit N] [--delay-ms N] [--workday-page-delay-ms N] [--timeout-ms N] [--data-dir PATH]
26
26
  openings snapshot export [--input FILE] [--output-dir PATH]
27
27
  openings coverage report --country CODE [--snapshot FILE] [--catalog FILE] [--candidates FILE] [--registry FILE] [--output FILE] [--as-of ISO]
28
28
  openings sources discover-subdomains SEEDS.json [--output FILE] [--limit N] [--delay-ms N] [--report FILE]
@@ -69,7 +69,7 @@ export async function run(args: string[]): Promise<number> {
69
69
  if (typeof parsed === "string") return fail(parsed);
70
70
  const runtime = createRuntime({
71
71
  dataDir: parsed.dataDir, concurrency: parsed.concurrency, sourceCacheHours: parsed.sourceCacheHours,
72
- sourceLimit: parsed.sourceLimit, crawlDelayMs: parsed.delayMs, workdayPageDelayMs: parsed.workdayPageDelayMs, workdayCountries: parsed.workdayCountries,
72
+ sourceLimit: parsed.sourceLimit, crawlDelayMs: parsed.delayMs, workdayPageDelayMs: parsed.workdayPageDelayMs, workdayCountries: parsed.workdayCountries, timeoutMs: parsed.timeoutMs,
73
73
  });
74
74
  const slugs = parsed.companiesFile ? await readCompanyFile(parsed.companiesFile) : undefined;
75
75
  console.log(JSON.stringify(await runtime.crawl({ country: parsed.country, slugs }), null, 2));
@@ -514,6 +514,7 @@ function parseCrawl(args: string[]) {
514
514
  let concurrency = 10;
515
515
  let delayMs = 0;
516
516
  let workdayPageDelayMs = 0;
517
+ let timeoutMs: number | undefined;
517
518
  let workdayCountries: string[] | undefined;
518
519
  let sourceCacheHours = 24;
519
520
  let sourceLimit = 0;
@@ -537,6 +538,9 @@ function parseCrawl(args: string[]) {
537
538
  } else if (arg === "--workday-page-delay-ms") {
538
539
  workdayPageDelayMs = Number(args[++index]);
539
540
  if (!Number.isInteger(workdayPageDelayMs) || workdayPageDelayMs < 0 || workdayPageDelayMs > 60_000) return "--workday-page-delay-ms must be an integer from 0 to 60000";
541
+ } else if (arg === "--timeout-ms") {
542
+ timeoutMs = Number(args[++index]);
543
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 1_800_000) return "--timeout-ms must be an integer from 1000 to 1800000";
540
544
  } else if (arg === "--source-cache-hours") {
541
545
  sourceCacheHours = Number(args[++index]);
542
546
  if (!Number.isFinite(sourceCacheHours) || sourceCacheHours < 0 || sourceCacheHours > 8760) return "--source-cache-hours must be a number from 0 to 8760";
@@ -548,7 +552,7 @@ function parseCrawl(args: string[]) {
548
552
  if (country && companiesFile) return "Use either --country or --companies, not both";
549
553
  if (args.includes("--companies") && !companiesFile) return "--companies requires a file";
550
554
  if (args.includes("--data-dir") && !dataDir) return "--data-dir requires a value";
551
- return { country, companiesFile, dataDir, concurrency, delayMs, workdayPageDelayMs, sourceCacheHours, sourceLimit , workdayCountries };
555
+ return { country, companiesFile, dataDir, concurrency, delayMs, workdayPageDelayMs, sourceCacheHours, sourceLimit, workdayCountries, timeoutMs };
552
556
  }
553
557
 
554
558
  function parseCountry(value: string | undefined): string | undefined {
@@ -6,7 +6,7 @@ const arrayFields = ["roles", "countries", "locations", "seniority", "requiredSk
6
6
  export function validateCandidateIntent(value: unknown, invalid: InvalidInput, options: { optional?: boolean } = {}): CandidateIntent {
7
7
  if (value === undefined && options.optional) return {};
8
8
  if (!isRecord(value)) throw invalid("intent", "Candidate intent must be an object");
9
- assertKnownKeys(value, [...arrayFields, "remote"], "intent", invalid);
9
+ assertKnownKeys(value, [...arrayFields, "remote", "maxAgeDays"], "intent", invalid);
10
10
  for (const field of arrayFields) {
11
11
  const candidate = value[field];
12
12
  if (candidate !== undefined && (!Array.isArray(candidate) || !candidate.every((item) => typeof item === "string" && item.trim().length > 0))) throw invalid(`intent.${field}`, `${field} must be an array of non-empty strings`);
@@ -17,6 +17,7 @@ export function validateCandidateIntent(value: unknown, invalid: InvalidInput, o
17
17
  if (Array.isArray(countries) && !countries.every((country) => typeof country === "string" && /^[A-Za-z]{2}$/.test(country))) throw invalid(`intent.${field}`, `${field} must contain two-letter country codes`);
18
18
  }
19
19
  if (value.remote !== undefined && typeof value.remote !== "boolean") throw invalid("intent.remote", "remote must be a boolean");
20
+ if (value.maxAgeDays !== undefined && (!Number.isInteger(value.maxAgeDays) || (value.maxAgeDays as number) < 1 || (value.maxAgeDays as number) > 365)) throw invalid("intent.maxAgeDays", "maxAgeDays must be an integer between 1 and 365");
20
21
  return value as unknown as CandidateIntent;
21
22
  }
22
23
 
@@ -2,6 +2,7 @@ import { validateCandidateProfileEvidence, type CandidateProfile } from "./candi
2
2
  import { isEligibleForCountry, normalizeLocation } from "./locations.ts";
3
3
  import { detectRequirementTerms, findTransferability, matchesExactSkillEvidence, requiresExactSkillEvidence, type TransferabilityKind } from "./requirement-vocabulary.ts";
4
4
  import type { Job } from "./types.ts";
5
+ import { postedTime } from "./catalog.ts";
5
6
  import { evaluateScreeningRequirements, type ScreeningRequirement } from "./screening-requirements.ts";
6
7
 
7
8
  export interface CandidateIntent {
@@ -15,6 +16,8 @@ export interface CandidateIntent {
15
16
  excludedCountries?: string[];
16
17
  excludedLocations?: string[];
17
18
  excludedRoles?: string[];
19
+ /** Only roles posted within this many days; undated roles are dropped when set. */
20
+ maxAgeDays?: number;
18
21
  }
19
22
 
20
23
  export interface SupportedRequirement {
@@ -226,6 +229,7 @@ function hardFilterReasons(job: Job, intent: CandidateIntent): string[] {
226
229
  if (intent.locations?.length && !intent.locations.some((location) => normalizeLocation(job.location).includes(normalizeLocation(location)))) reasons.push("location_mismatch");
227
230
  for (const location of intent.excludedLocations ?? []) if (normalizeLocation(job.location).includes(normalizeLocation(location))) reasons.push(`location_excluded:${location}`);
228
231
  for (const role of intent.excludedRoles ?? []) if (includesPhrase(job.title, role) || tokenOverlap(role, job.title) === 1) reasons.push(`role_excluded:${role}`);
232
+ if (intent.maxAgeDays && postedTime(job) < Date.now() - intent.maxAgeDays * 86_400_000) reasons.push(`posted_too_old:${intent.maxAgeDays}d`);
229
233
  if (intent.remote === true && job.workMode !== "remote") reasons.push("remote_required");
230
234
  if (intent.remote === false && (job.workMode === "remote" || job.workMode === "unknown")) reasons.push("non_remote_required");
231
235
  const searchable = `${job.title}\n${job.company}\n${job.location}\n${job.description}`;
@@ -439,7 +443,4 @@ function tokenOverlap(left: string, right: string): number {
439
443
  return [...leftTokens].filter((token) => rightTokens.has(token)).length / leftTokens.size;
440
444
  }
441
445
 
442
- function freshness(job: Job): number {
443
- const timestamp = job.updatedAt ? Date.parse(job.updatedAt) : Number.NaN;
444
- return Number.isFinite(timestamp) ? timestamp : 0;
445
- }
446
+ const freshness = postedTime;
package/src/runtime.ts CHANGED
@@ -13,7 +13,7 @@ import { createFileSnapshotStore } from "./snapshot-store.ts";
13
13
  import { createJobCoverageReader } from "./job-coverage.ts";
14
14
  import { createJobSearchPreparer } from "./job-search-preparation.ts";
15
15
 
16
- export function createRuntime(options: { dataDir?: string; concurrency?: number; crawlDelayMs?: number; workdayPageDelayMs?: number; workdayCountries?: string[]; sourceCacheHours?: number; sourceLimit?: number } = {}) {
16
+ export function createRuntime(options: { dataDir?: string; concurrency?: number; crawlDelayMs?: number; workdayPageDelayMs?: number; workdayCountries?: string[]; sourceCacheHours?: number; sourceLimit?: number; timeoutMs?: number } = {}) {
17
17
  const workdayCountries = options.workdayCountries ?? (process.env.OPENINGS_WORKDAY_COUNTRIES ?? "IN,US").split(",").map((code) => code.trim().toUpperCase()).filter((code) => /^[A-Z]{2}$/.test(code));
18
18
  const dataDir = options.dataDir ?? process.env.OPENINGS_DATA_DIR ?? join(process.cwd(), ".openings");
19
19
  const store = createFileSnapshotStore(join(dataDir, "snapshot.json"));
@@ -25,6 +25,7 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
25
25
  store,
26
26
  fetchJobs: (source, signal, observer) => fetchSourceJobs(source, globalThis.fetch, signal, observer),
27
27
  concurrency: options.concurrency,
28
+ timeoutMs: options.timeoutMs,
28
29
  sourceStartDelayMs: options.crawlDelayMs,
29
30
  sourceFreshnessMs: (options.sourceCacheHours ?? 0) * 60 * 60 * 1000,
30
31
  sourceLimit: options.sourceLimit,
package/src/tools.ts CHANGED
@@ -107,6 +107,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
107
107
  location: { type: "string", description: "Case-insensitive location substring" },
108
108
  country: { type: "string", pattern: "^[A-Za-z]{2}$", description: "Two-letter country code for job eligibility, such as IN or DE" },
109
109
  remote: { type: "boolean", description: "True for remote-only; false for non-remote-only" },
110
+ maxAgeDays: { type: "integer", minimum: 1, maximum: 365, description: "Only roles posted within this many days; results are newest first" },
110
111
  limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
111
112
  },
112
113
  additionalProperties: false,
@@ -140,10 +141,11 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
140
141
  if (name === "analyze_job_fit") return workflows.analyzeJobFit(input);
141
142
  if (name === "optimize_resume") return workflows.optimizeResume(input);
142
143
  if (name === "search_jobs") {
143
- assertToolKeys(input, ["query", "location", "country", "remote", "limit"], "search_jobs");
144
+ assertToolKeys(input, ["query", "location", "country", "remote", "maxAgeDays", "limit"], "search_jobs");
144
145
  if (input.query !== undefined && typeof input.query !== "string") throw new Error("query must be a string");
145
146
  if (input.location !== undefined && typeof input.location !== "string") throw new Error("location must be a string");
146
147
  if (input.remote !== undefined && typeof input.remote !== "boolean") throw new Error("remote must be a boolean");
148
+ if (input.maxAgeDays !== undefined && (!Number.isInteger(input.maxAgeDays) || (input.maxAgeDays as number) < 1 || (input.maxAgeDays as number) > 365)) throw new Error("maxAgeDays must be an integer between 1 and 365");
147
149
  if (input.limit !== undefined && (!Number.isInteger(input.limit) || (input.limit as number) < 1 || (input.limit as number) > 100)) throw new Error("limit must be an integer between 1 and 100");
148
150
  const query: SearchQuery = {};
149
151
  if (typeof input.query === "string") query.query = input.query;
@@ -151,6 +153,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
151
153
  if (typeof input.country === "string" && /^[a-z]{2}$/i.test(input.country)) query.country = input.country.toUpperCase();
152
154
  else if (input.country !== undefined) throw new Error("country must be a two-letter code");
153
155
  if (typeof input.remote === "boolean") query.remote = input.remote;
156
+ if (typeof input.maxAgeDays === "number") query.maxAgeDays = input.maxAgeDays;
154
157
  if (typeof input.limit === "number") query.limit = input.limit;
155
158
  return { jobs: await catalog.search(query) };
156
159
  }
@@ -181,6 +184,7 @@ function intentSchema(): Record<string, unknown> {
181
184
  roles: stringArray(), countries: countryArray(), locations: stringArray(), remote: { type: "boolean" }, seniority: stringArray(),
182
185
  requiredSkills: stringArray(), excludedTerms: stringArray(),
183
186
  excludedCountries: countryArray(), excludedLocations: stringArray(), excludedRoles: stringArray(),
187
+ maxAgeDays: { type: "integer", minimum: 1, maximum: 365, description: "Only roles posted within this many days" },
184
188
  },
185
189
  additionalProperties: false,
186
190
  };
package/src/types.ts CHANGED
@@ -80,6 +80,7 @@ export interface JobSummary {
80
80
  eligibleRegions: string[];
81
81
  eligibilityConfidence: EligibilityConfidence;
82
82
  url: string;
83
+ /** Posting date when the board exposes one (Workday's relative label is approximate), otherwise the board's last-update time. */
83
84
  updatedAt?: string;
84
85
  }
85
86
 
@@ -92,6 +93,8 @@ export interface SearchQuery {
92
93
  location?: string;
93
94
  country?: string;
94
95
  remote?: boolean;
96
+ /** Only jobs posted within this many days; undated jobs are excluded when set. */
97
+ maxAgeDays?: number;
95
98
  limit?: number;
96
99
  }
97
100
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.13";
1
+ export const VERSION = "0.1.15";