openings 0.1.23 → 0.1.25
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/package.json +1 -1
- package/src/catalog.ts +34 -12
- package/src/job-matching.ts +7 -3
- package/src/job-recommendations.ts +56 -13
- package/src/mcp.ts +8 -1
- package/src/tools.ts +7 -2
- package/src/types.ts +11 -1
- package/src/version.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
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
package/src/catalog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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/job-matching.ts
CHANGED
|
@@ -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 && (
|
|
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";
|
|
@@ -22,7 +24,8 @@ export interface RecommendJobsResult {
|
|
|
22
24
|
profile: CandidateProfile;
|
|
23
25
|
matches: JobMatch[];
|
|
24
26
|
exploration: ReturnType<typeof matchJobs>["exploration"];
|
|
25
|
-
|
|
27
|
+
/** Counts by reason plus a small sample: the full list once ran to every job in the index and blew past MCP payload limits. */
|
|
28
|
+
filteredOut: { total: number; byReason: Record<string, number>; sample: FilteredJob[] };
|
|
26
29
|
assumptions: string[];
|
|
27
30
|
ranking: { mode: "evidence" | "keyword"; minimumPercent: number };
|
|
28
31
|
snapshot: SnapshotStatus;
|
|
@@ -30,6 +33,11 @@ export interface RecommendJobsResult {
|
|
|
30
33
|
refresh: RecommendationRefreshResult;
|
|
31
34
|
shortfall?: { minimumMatches: number; actualMatches: number; message: string };
|
|
32
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[];
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface JobRecommenderOptions {
|
|
@@ -98,6 +106,7 @@ export function createJobRecommender(options: JobRecommenderOptions) {
|
|
|
98
106
|
refresh: { policy, attempted, occurred, reason, failures: report?.failed ?? [], ...(refreshError ? { error: refreshError } : {}) },
|
|
99
107
|
...(shortfall ? { shortfall } : {}),
|
|
100
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),
|
|
101
110
|
};
|
|
102
111
|
},
|
|
103
112
|
};
|
|
@@ -138,20 +147,54 @@ function invalidInput(field: string, message: string): RecommendationError {
|
|
|
138
147
|
return new RecommendationError("invalid_recommendation_input", message, field);
|
|
139
148
|
}
|
|
140
149
|
|
|
150
|
+
/** Matches inside the cascade: 7 days, then 14, 30, everything, until CASCADE_MINIMUM matches appear. An explicit maxAgeDays is a single window. */
|
|
141
151
|
function matchSnapshot(profile: CandidateProfile, intent: CandidateIntent, snapshot: JobSnapshot, ranking?: MatchingOptions) {
|
|
142
152
|
const jobs = Object.values(snapshot.partitions).flatMap((partition) => partition.jobs);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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: [] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const EXCERPT_CHARS = 280;
|
|
185
|
+
/** Transport shape: descriptions become excerpts (get_job returns the full text), exploration lists carry the same compact matches, filtered jobs are summarised. */
|
|
186
|
+
function limitMatching(matching: ReturnType<typeof matchSnapshot>, limit = 20): Omit<ReturnType<typeof matchSnapshot>, "filteredOut"> & { filteredOut: RecommendJobsResult["filteredOut"] } {
|
|
187
|
+
const compact = (match: JobMatch): JobMatch => ({ ...match, job: { ...match.job, description: match.job.description.length > EXCERPT_CHARS ? `${match.job.description.slice(0, EXCERPT_CHARS).trimEnd()}…` : match.job.description } });
|
|
188
|
+
const matches = matching.matches.slice(0, Math.max(0, limit)).map(compact);
|
|
189
|
+
const byId = new Map(matches.map((match) => [match.job.id, match]));
|
|
190
|
+
const pick = (list: JobMatch[]) => list.filter((match) => byId.has(match.job.id)).map((match) => byId.get(match.job.id)!);
|
|
191
|
+
const byReason: Record<string, number> = {};
|
|
192
|
+
for (const entry of matching.filteredOut) for (const reason of entry.reasons) { const key = reason.split(":")[0]!; byReason[key] = (byReason[key] ?? 0) + 1; }
|
|
193
|
+
return {
|
|
194
|
+
...matching, matches,
|
|
195
|
+
exploration: { ...matching.exploration, directMatches: pick(matching.exploration.directMatches), hiddenMatches: pick(matching.exploration.hiddenMatches), stretchMatches: pick(matching.exploration.stretchMatches) },
|
|
196
|
+
filteredOut: { total: matching.filteredOut.length, byReason, sample: matching.filteredOut.slice(0, 20) },
|
|
197
|
+
};
|
|
155
198
|
}
|
|
156
199
|
|
|
157
200
|
function preCutoffMatchCount(matching: ReturnType<typeof matchSnapshot>): number {
|
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 },
|
|
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/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: "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1
|
+
export const VERSION = "0.1.25";
|