openings 0.1.24 → 0.1.26

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.24",
3
+ "version": "0.1.26",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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
@@ -1,4 +1,4 @@
1
- import type { Company, Job, JobSummary, SearchQuery } from "./types.ts";
1
+ import { CASCADE_MINIMUM, CASCADE_WINDOWS, type Company, type Job, type JobAge, type JobSummary, type SearchQuery, type SearchWindow } from "./types.ts";
2
2
  import { providerSpec, type JsonGet } from "./providers.ts";
3
3
  import { crawlSite, sitePostingsToJobs } from "./jobposting-site.ts";
4
4
  import { classifyJob, isEligibleForCountry, normalizeLocation } from "./locations.ts";
@@ -112,15 +112,37 @@ export function createCatalog(options: CatalogOptions): Catalog {
112
112
  }
113
113
 
114
114
  export const DEFAULT_MAX_AGE_DAYS = 30;
115
- export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.now()): JobSummary[] {
116
- const explicit = query.maxAgeDays !== undefined;
117
- const days = explicit ? query.maxAgeDays! : DEFAULT_MAX_AGE_DAYS;
118
- const since = days > 0 ? now - days * 86_400_000 : undefined;
119
- const fresh = (job: Job) => since === undefined || postedTime(job) >= since || (!explicit && postedTime(job) === 0);
120
- return jobs
121
- .filter((job) => matches(job, query) && fresh(job))
122
- .sort((left, right) => postedTime(right) - postedTime(left))
123
- .slice(0, query.limit ?? 50).map(toSummary);
115
+ export type SearchResult = JobSummary[] & { window?: SearchWindow };
116
+
117
+ /** One window: dated jobs inside it; 0 means everything including undated. */
118
+ export function withinWindow(job: Pick<Job, "updatedAt">, days: number, now: number): boolean {
119
+ if (days <= 0) return true;
120
+ const time = postedTime(job);
121
+ return time > 0 && time >= now - days * 86_400_000;
122
+ }
123
+
124
+ /** 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. */
125
+ export function searchJobs(jobs: Job[], query: SearchQuery, now: number = Date.now()): SearchResult {
126
+ const matched = jobs.filter((job) => matches(job, query));
127
+ const run = (days: number) => matched.filter((job) => withinWindow(job, days, now)).sort((left, right) => postedTime(right) - postedTime(left));
128
+ const steps: SearchWindow["steps"] = [];
129
+ let found: Job[] = [];
130
+ for (const days of query.maxAgeDays !== undefined ? [query.maxAgeDays] : CASCADE_WINDOWS) {
131
+ found = run(days);
132
+ steps.push({ days, results: found.length });
133
+ if (found.length >= CASCADE_MINIMUM) break;
134
+ }
135
+ const result = found.slice(0, query.limit ?? 50).map((job) => toSummary(job, now)) as SearchResult;
136
+ Object.defineProperty(result, "window", { value: { daysUsed: steps[steps.length - 1]!.days, widened: steps.length > 1, steps } satisfies SearchWindow, enumerable: false });
137
+ return result;
138
+ }
139
+
140
+ /** Age label from the posting date: new (≤7d), older (≤30d), stale, or undated. */
141
+ export function jobAge(job: Pick<Job, "updatedAt">, now: number = Date.now()): { postedDaysAgo?: number; age: JobAge } {
142
+ const time = postedTime(job);
143
+ if (time <= 0) return { age: "undated" };
144
+ const postedDaysAgo = Math.max(0, Math.floor((now - time) / 86_400_000));
145
+ return { postedDaysAgo, age: postedDaysAgo <= 7 ? "new" : postedDaysAgo <= 30 ? "older" : "stale" };
124
146
  }
125
147
 
126
148
  /** Posting time in ms for sorting; undated jobs sort last. */
@@ -439,9 +461,9 @@ function matches(job: Job, query: SearchQuery): boolean {
439
461
  && (query.remote === undefined || job.remote === query.remote);
440
462
  }
441
463
 
442
- function toSummary(job: Job): JobSummary {
464
+ function toSummary(job: Job, now: number = Date.now()): JobSummary {
443
465
  const { description: _description, ...summary } = job;
444
- return summary;
466
+ return { ...summary, ...jobAge(job, now) };
445
467
  }
446
468
 
447
469
  function stripHtml(value: string): string {
package/src/index.ts CHANGED
@@ -35,3 +35,9 @@ export { createSelectedJobLookup } from "./selected-job-lookup.ts";
35
35
  export type { SelectedJobLookupOptions } from "./selected-job-lookup.ts";
36
36
  export { createJobCoverageReader, projectJobCoverage } from "./job-coverage.ts";
37
37
  export type { CountryJobCoverage, JobCoverageSummary } from "./job-coverage.ts";
38
+ export { createHostedRuntime } from "./runtime.ts";
39
+ export type { SnapshotStore } from "./crawler.ts";
40
+ export { createToolHandler } from "./tools.ts";
41
+ export { createMcpHandler, FLOW_INSTRUCTIONS } from "./mcp.ts";
42
+ export type { UpdateNotice } from "./update-check.ts";
43
+ export { VERSION } from "./version.ts";
@@ -1,8 +1,8 @@
1
1
  import { validateCandidateProfileEvidence, type CandidateProfile } from "./candidate-profile.ts";
2
2
  import { isEligibleForCountry, normalizeLocation } from "./locations.ts";
3
3
  import { detectRequirementTerms, findTransferability, matchesExactSkillEvidence, requiresExactSkillEvidence, type TransferabilityKind } from "./requirement-vocabulary.ts";
4
- import type { Job } from "./types.ts";
5
- import { DEFAULT_MAX_AGE_DAYS, postedTime } from "./catalog.ts";
4
+ import type { Job, JobAge } from "./types.ts";
5
+ import { DEFAULT_MAX_AGE_DAYS, jobAge, postedTime, withinWindow } from "./catalog.ts";
6
6
  import { evaluateScreeningRequirements, type ScreeningRequirement } from "./screening-requirements.ts";
7
7
 
8
8
  export interface CandidateIntent {
@@ -51,6 +51,8 @@ export interface JobMatch {
51
51
  discovery: { category: "direct" | "hidden" | "stretch"; titleExpansions: TitleExpansion[] };
52
52
  /** The employer's own posting, repeated at the top level so it is never dropped from a summary. */
53
53
  applyUrl: string;
54
+ postedDaysAgo?: number;
55
+ age: JobAge;
54
56
  }
55
57
 
56
58
  export interface FilteredJob { jobId: string; reasons: string[] }
@@ -118,6 +120,7 @@ export function matchJobs(profile: CandidateProfile, intent: CandidateIntent, jo
118
120
  gaps,
119
121
  discovery: { category, titleExpansions },
120
122
  applyUrl: job.url,
123
+ ...jobAge(job),
121
124
  score,
122
125
  index,
123
126
  });
@@ -232,9 +235,10 @@ function hardFilterReasons(job: Job, intent: CandidateIntent): string[] {
232
235
  if (intent.locations?.length && !intent.locations.some((location) => normalizeLocation(job.location).includes(normalizeLocation(location)))) reasons.push("location_mismatch");
233
236
  for (const location of intent.excludedLocations ?? []) if (normalizeLocation(job.location).includes(normalizeLocation(location))) reasons.push(`location_excluded:${location}`);
234
237
  for (const role of intent.excludedRoles ?? []) if (includesPhrase(job.title, role) || tokenOverlap(role, job.title) === 1) reasons.push(`role_excluded:${role}`);
238
+ // The recommender walks the windows itself and always passes an explicit value; a bare call keeps the 30-day default with undated roles allowed.
235
239
  const explicitAge = intent.maxAgeDays !== undefined;
236
240
  const maxAgeDays = explicitAge ? intent.maxAgeDays! : DEFAULT_MAX_AGE_DAYS;
237
- if (maxAgeDays > 0 && (postedTime(job) > 0 || explicitAge) && postedTime(job) < Date.now() - maxAgeDays * 86_400_000) reasons.push(`posted_too_old:${maxAgeDays}d`);
241
+ if (maxAgeDays > 0 && !withinWindow(job, maxAgeDays, Date.now()) && (explicitAge || postedTime(job) > 0)) reasons.push(`posted_too_old:${maxAgeDays}d`);
238
242
  if (intent.remote === true && job.workMode !== "remote") reasons.push("remote_required");
239
243
  if (intent.remote === false && (job.workMode === "remote" || job.workMode === "unknown")) reasons.push("non_remote_required");
240
244
  const searchable = `${job.title}\n${job.company}\n${job.location}\n${job.description}`;
@@ -1,6 +1,8 @@
1
1
  import { parseCandidateProfile, type CandidateProfile, type ResumeInput } from "./candidate-profile.ts";
2
2
  import type { SnapshotStore } from "./crawler.ts";
3
3
  import { matchJobs, type CandidateIntent, type FilteredJob, type JobMatch, type MatchingOptions } from "./job-matching.ts";
4
+ import { withinWindow } from "./catalog.ts";
5
+ import { CASCADE_MINIMUM, CASCADE_WINDOWS, type SearchWindow } from "./types.ts";
4
6
  import { isEligibleForCountry } from "./locations.ts";
5
7
  import type { Company, CrawlReport, JobSnapshot } from "./types.ts";
6
8
  import { snapshotStatus, type CrawlScope, type SnapshotStatus } from "./local-jobs.ts";
@@ -31,6 +33,11 @@ export interface RecommendJobsResult {
31
33
  refresh: RecommendationRefreshResult;
32
34
  shortfall?: { minimumMatches: number; actualMatches: number; message: string };
33
35
  nextActions: string[];
36
+ /** Which age windows were walked and where the results came from. */
37
+ window: SearchWindow;
38
+ outcome: "matches" | "widened" | "no_matches";
39
+ explanation: string;
40
+ nextMoves: string[];
34
41
  }
35
42
 
36
43
  export interface JobRecommenderOptions {
@@ -99,6 +106,7 @@ export function createJobRecommender(options: JobRecommenderOptions) {
99
106
  refresh: { policy, attempted, occurred, reason, failures: report?.failed ?? [], ...(refreshError ? { error: refreshError } : {}) },
100
107
  ...(shortfall ? { shortfall } : {}),
101
108
  nextActions: limited.matches.length ? [`Analyze fit for job ${limited.matches[0]!.job.id}`] : ["Clarify or broaden explicit job intent"],
109
+ ...describeOutcome(profile, input.intent, limited.window, limited.matches.length),
102
110
  };
103
111
  },
104
112
  };
@@ -139,9 +147,38 @@ function invalidInput(field: string, message: string): RecommendationError {
139
147
  return new RecommendationError("invalid_recommendation_input", message, field);
140
148
  }
141
149
 
150
+ /** Matches inside the cascade: 7 days, then 14, 30, everything, until CASCADE_MINIMUM matches appear. An explicit maxAgeDays is a single window. */
142
151
  function matchSnapshot(profile: CandidateProfile, intent: CandidateIntent, snapshot: JobSnapshot, ranking?: MatchingOptions) {
143
152
  const jobs = Object.values(snapshot.partitions).flatMap((partition) => partition.jobs);
144
- return matchJobs(profile, intent, jobs, jobs.length, ranking);
153
+ const now = Date.now();
154
+ const steps: SearchWindow["steps"] = [];
155
+ let matching = matchJobs(profile, { ...intent, maxAgeDays: 0 }, [], 0, ranking);
156
+ for (const days of intent.maxAgeDays !== undefined ? [intent.maxAgeDays] : CASCADE_WINDOWS) {
157
+ const candidates = days > 0 ? jobs.filter((job) => withinWindow(job, days, now)) : jobs;
158
+ matching = matchJobs(profile, { ...intent, maxAgeDays: days }, candidates, candidates.length, ranking);
159
+ steps.push({ days, results: matching.matches.length });
160
+ if (matching.matches.length >= CASCADE_MINIMUM) break;
161
+ }
162
+ return { ...matching, window: { daysUsed: steps[steps.length - 1]!.days, widened: steps.length > 1, steps } satisfies SearchWindow };
163
+ }
164
+
165
+ /** Says in data terms why the result is what it is, so the agent never presents silence as an answer. */
166
+ function describeOutcome(profile: CandidateProfile, intent: CandidateIntent, window: SearchWindow, matches: number): { outcome: RecommendJobsResult["outcome"]; explanation: string; nextMoves: string[] } {
167
+ const skills = profile.facts.filter((fact) => fact.kind === "skill").map((fact) => fact.value).slice(0, 8);
168
+ const roles = profile.facts.filter((fact) => fact.kind === "role").map((fact) => fact.value).slice(0, 4);
169
+ const windows = window.steps.map((step) => `${step.days === 0 ? "all time" : `${step.days} days`}: ${step.results}`).join(", ");
170
+ const scope = [intent.roles?.length ? `roles asked: ${intent.roles.join(", ")}` : "", intent.countries?.length ? `countries: ${intent.countries.join(", ")}` : "", skills.length ? `resume skills: ${skills.join(", ")}` : "", roles.length ? `resume roles: ${roles.join(", ")}` : ""].filter(Boolean).join("; ");
171
+ if (matches === 0) return {
172
+ outcome: "no_matches",
173
+ explanation: `No role matched (${windows}). ${scope}.`,
174
+ nextMoves: ["Broaden or remove the roles in intent, or drop excludedRoles", "Add countries or allow remote roles", "Try search_jobs with a plain keyword to see what the index holds", "Check get_job_coverage for the countries asked"],
175
+ };
176
+ if (window.widened) return {
177
+ outcome: "widened",
178
+ explanation: `Fewer than ${CASCADE_MINIMUM} matches in the last ${window.steps[0]!.days} days, so the window widened to ${window.daysUsed === 0 ? "all time" : `${window.daysUsed} days`} (${windows}). ${scope}. Present roles labelled older or stale as possibly still open, not as current.`,
179
+ nextMoves: ["Tell the person which window the results came from", "Offer to broaden roles or countries for fresher matches"],
180
+ };
181
+ return { outcome: "matches", explanation: `${matches} matches in the last ${window.daysUsed} days (${windows}). ${scope}.`, nextMoves: [] };
145
182
  }
146
183
 
147
184
  const EXCERPT_CHARS = 280;
package/src/mcp.ts CHANGED
@@ -17,6 +17,13 @@ interface ToolHandler {
17
17
  call(name: string, input: Record<string, unknown>): Promise<unknown>;
18
18
  }
19
19
 
20
+ /** Read by the AI app before its first call: the order of operations that keeps results honest. */
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.",
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
+ "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.",
25
+ ].join("\n");
26
+
20
27
  export function createMcpHandler(tools: ToolHandler, options: { update?: () => UpdateNotice | null } = {}) {
21
28
  /** A pending update rides on every tool result so the AI app can prompt the person; nothing is changed on their machine. */
22
29
  const withUpdate = (payload: unknown) => { const update = options.update?.(); return update && isRecord(payload) ? { ...payload, updateAvailable: update } : payload; };
@@ -28,7 +35,7 @@ export function createMcpHandler(tools: ToolHandler, options: { update?: () => U
28
35
  try {
29
36
  if (request.method === "initialize") {
30
37
  const update = options.update?.();
31
- return { ...base, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "openings", version: VERSION }, ...(update ? { instructions: update.message } : {}) } };
38
+ return { ...base, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "openings", version: VERSION }, instructions: [FLOW_INSTRUCTIONS, update?.message].filter(Boolean).join("\n\n") } };
32
39
  }
33
40
  if (request.method === "ping") return { ...base, result: {} };
34
41
  if (request.method === "tools/list") return { ...base, result: { tools: tools.list() } };
package/src/runtime.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { join } from "node:path";
2
+ import type { SnapshotStore } from "./crawler.ts";
3
+ import type { Company, SearchQuery } from "./types.ts";
2
4
  import { fetchSourceJobs } from "./catalog.ts";
3
5
  import { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
4
6
  import { createUsageReporter, type UsageReporter } from "./usage.ts";
@@ -56,3 +58,25 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
56
58
  const optimizer = createResumeOptimizer({ analyzeJobFit: analyzer.analyze });
57
59
  return { ...local, prepareJobSearch: preparation.prepare, getJobCoverage: coverage.getCoverage, recommend: recommender.recommend, analyzeJobFit: analyzer.analyze, optimizeResume: optimizer.optimize, usage };
58
60
  }
61
+
62
+ /**
63
+ * The same workflows over a read-only shared index, for the hosted connector. The store is supplied by the host
64
+ * (the aggregator's live index), nothing is crawled, and the sources are exactly those the store holds so
65
+ * preparation reports ready at once. No usage reporter: the host meters by account instead.
66
+ */
67
+ export function createHostedRuntime(options: { sources: Company[]; store: SnapshotStore; now?: () => Date }) {
68
+ const readOnly = async () => { throw new Error("The hosted index is read-only; it refreshes from the nightly crawl"); };
69
+ const local = createLocalJobs({ sources: options.sources, store: options.store, fetchJobs: readOnly, now: options.now, sourceFreshnessMs: 0 });
70
+ const noCrawl = async () => ({ startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), selected: 0, succeeded: 0, failed: [] });
71
+ const recommender = createJobRecommender({ sources: options.sources, store: options.store, crawl: noCrawl, now: options.now });
72
+ const coverage = createJobCoverageReader({ sources: options.sources, store: options.store });
73
+ const preparation = createJobSearchPreparer({ sources: options.sources, store: options.store, crawl: noCrawl, now: options.now, freshnessDays: 3650 });
74
+ const getSelectedJob = createSelectedJobLookup({ getSnapshotJob: async (id) => (await local.get(id, { offline: true, staleDays: 3650 })).job, getDetailedJob: async () => null });
75
+ const analyzer = createJobFitAnalyzer({ getJob: getSelectedJob });
76
+ const optimizer = createResumeOptimizer({ analyzeJobFit: analyzer.analyze });
77
+ return {
78
+ search: (query: SearchQuery) => local.search(query, { offline: true, staleDays: 3650 }),
79
+ get: (id: string) => local.get(id, { offline: true, staleDays: 3650 }),
80
+ prepareJobSearch: preparation.prepare, getJobCoverage: coverage.getCoverage, recommend: recommender.recommend, analyzeJobFit: analyzer.analyze, optimizeResume: optimizer.optimize,
81
+ };
82
+ }
package/src/tools.ts CHANGED
@@ -3,6 +3,7 @@ import type { RecommendJobsResult } from "./job-recommendations.ts";
3
3
  import type { AnalyzeJobFitResult } from "./job-fit-analysis.ts";
4
4
  import type { OptimizeResumeResult } from "./resume-optimization.ts";
5
5
  import type { SearchQuery } from "./types.ts";
6
+ import type { SearchResult } from "./catalog.ts";
6
7
  import type { JobCoverageSummary } from "./job-coverage.ts";
7
8
  import type { PrepareJobSearchResult } from "./job-search-preparation.ts";
8
9
 
@@ -99,7 +100,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
99
100
  },
100
101
  {
101
102
  name: "search_jobs",
102
- description: "Search the local job snapshot by role, location, country eligibility, and work mode.",
103
+ 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.",
103
104
  inputSchema: {
104
105
  type: "object",
105
106
  properties: {
@@ -155,7 +156,11 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
155
156
  if (typeof input.remote === "boolean") query.remote = input.remote;
156
157
  if (typeof input.maxAgeDays === "number") query.maxAgeDays = input.maxAgeDays;
157
158
  if (typeof input.limit === "number") query.limit = input.limit;
158
- return { jobs: await catalog.search(query) };
159
+ const jobs = await catalog.search(query) as SearchResult;
160
+ return {
161
+ jobs, window: jobs.window,
162
+ 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.",
163
+ };
159
164
  }
160
165
  if (name === "get_job") {
161
166
  assertToolKeys(input, ["id"], "get_job");
package/src/types.ts CHANGED
@@ -83,8 +83,18 @@ export interface JobSummary {
83
83
  url: string;
84
84
  /** Posting date when the board exposes one (Workday's relative label is approximate), otherwise the board's last-update time. */
85
85
  updatedAt?: string;
86
+ /** Days since the posting date at the time of the search; absent when the board gave no date. */
87
+ postedDaysAgo?: number;
88
+ /** 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. */
89
+ age?: JobAge;
86
90
  }
87
91
 
92
+ export type JobAge = "new" | "older" | "stale" | "undated";
93
+ /** The age windows a search or recommendation walked: 7 days, then 14, then 30, then everything, until at least CASCADE_MINIMUM results appeared. */
94
+ export interface SearchWindow { daysUsed: number; widened: boolean; steps: Array<{ days: number; results: number }> }
95
+ export const CASCADE_WINDOWS = [7, 14, 30, 0] as const;
96
+ export const CASCADE_MINIMUM = 5;
97
+
88
98
  export interface Job extends JobSummary {
89
99
  description: string;
90
100
  }
@@ -99,7 +109,7 @@ export interface SearchQuery {
99
109
  location?: string;
100
110
  country?: string;
101
111
  remote?: boolean;
102
- /** Only jobs posted within this many days. Default 30 keeps undated jobs (sorted last); an explicit value also drops undated jobs; 0 includes everything. */
112
+ /** 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. */
103
113
  maxAgeDays?: number;
104
114
  limit?: number;
105
115
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.24";
1
+ export const VERSION = "0.1.26";