openings 0.1.33 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +17 -2
- package/package.json +1 -1
- package/src/candidate-profile.ts +11 -0
- package/src/crawler.ts +3 -2
- package/src/job-recommendations.ts +1 -1
- package/src/job-search-preparation.ts +1 -1
- package/src/local-jobs.ts +4 -1
- package/src/mcp.ts +5 -3
- package/src/runtime.ts +15 -3
- package/src/tools.ts +14 -6
- package/src/version.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
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
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
A free, candidate-safe job search for AI agents.
|
|
4
4
|
|
|
5
|
-
Openings indexes public company job boards into a private index on your machine and exposes it to any MCP client. Your agent can find roles that fit a resume, explain the fit with evidence, and propose truthful resume improvements.
|
|
5
|
+
Openings indexes public company job boards into a private index on your machine and exposes it to any MCP client. Your agent can find roles that fit a resume, explain the fit with evidence, and propose truthful resume improvements. Installed this way there are no accounts and no API keys; there are never model calls and never a way to submit an application.
|
|
6
|
+
|
|
7
|
+
Two ways to run it, with different data rules:
|
|
8
|
+
|
|
9
|
+
| | On your machine (this package) | Hosted connector at `openings.avagama.co/mcp` |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Sign-in | None | Email code, so an account exists |
|
|
12
|
+
| Job index | Built and stored on your machine | Ours, shared |
|
|
13
|
+
| Resume | Parsed in memory for one request, never written to disk | Sent to our server, processed in memory for that request, then discarded |
|
|
14
|
+
| What we receive | Crawl reports, plus anonymous usage events unless you turn them off | The same usage record, keyed to your account |
|
|
15
|
+
|
|
16
|
+
Both are covered in [Sharing crawls and usage](#sharing-crawls-and-usage) and on the [privacy page](https://avagama.co/privacy/).
|
|
6
17
|
|
|
7
18
|
- **Eleven providers plus company sites.** Greenhouse, Lever, Ashby, Workday, Recruitee, SmartRecruiters, Workable, Breezy, Freshteam, Keka, and Zoho Recruit, crawled from their public structured endpoints, plus employer career sites read only through the schema.org JobPosting markup they publish for search engines. No free-form HTML scraping.
|
|
8
19
|
- **Verified sources only.** Every company in the catalog passed an identity check against its own board.
|
|
@@ -99,7 +110,11 @@ On startup the server makes one request to the npm registry to learn the latest
|
|
|
99
110
|
|
|
100
111
|
## Privacy
|
|
101
112
|
|
|
102
|
-
Job data comes straight from public ATS endpoints and is stored only on your machine. Your MCP client reads the resume file and passes its content to a tool; Openings never sees the path and never
|
|
113
|
+
Job data comes straight from public ATS endpoints and is stored only on your machine. Your MCP client reads the resume file and passes its content to a tool; Openings never sees the path, and it never writes the resume to disk, logs it, or sends it anywhere.
|
|
114
|
+
|
|
115
|
+
One thing derived from a resume does leave your machine while usage reporting is on, which is the default: the skill and title words the parser extracted, in the anonymous event described in [Sharing crawls and usage](#sharing-crawls-and-usage). Never the resume text, the quoted evidence, your name, or your contact details. `OPENINGS_USAGE=off` stops it, and an empty `OPENINGS_AGGREGATOR_URL` keeps everything local.
|
|
116
|
+
|
|
117
|
+
Every proposed change stays subject to your review.
|
|
103
118
|
|
|
104
119
|
## Develop
|
|
105
120
|
|
package/package.json
CHANGED
package/src/candidate-profile.ts
CHANGED
|
@@ -36,6 +36,14 @@ export class ResumeInputError extends Error {
|
|
|
36
36
|
) { super(message); }
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** A PDF or DOCX pasted as text: a file signature, or control characters no resume contains. */
|
|
40
|
+
function looksBinary(content: string): boolean {
|
|
41
|
+
const head = content.slice(0, 2048);
|
|
42
|
+
if (/^%PDF-|^PK\u0003\u0004|^\{\\rtf/.test(content.trimStart())) return true;
|
|
43
|
+
const control = head.match(/[\u0000-\u0008\u000e-\u001f]/g)?.length ?? 0;
|
|
44
|
+
return control > 0 && control / Math.max(head.length, 1) > 0.01;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
export function parseCandidateProfile(input: unknown): CandidateProfile {
|
|
40
48
|
if (!isRecord(input) || typeof input.format !== "string" || typeof input.content !== "string") {
|
|
41
49
|
throw new ResumeInputError("invalid_resume_input", "Resume must contain text or Markdown content");
|
|
@@ -43,6 +51,9 @@ export function parseCandidateProfile(input: unknown): CandidateProfile {
|
|
|
43
51
|
if (input.format === "pdf_base64" || input.format === "docx_base64") {
|
|
44
52
|
throw new ResumeInputError("unsupported_resume_format", `Resume format ${input.format} is not supported yet`, input.format, ["text", "markdown"]);
|
|
45
53
|
}
|
|
54
|
+
if (looksBinary(input.content)) {
|
|
55
|
+
throw new ResumeInputError("invalid_resume_input", "That looks like a file's raw bytes, not resume text. Export the resume as text, or paste the text itself.");
|
|
56
|
+
}
|
|
46
57
|
if (!(["text", "markdown"] as string[]).includes(input.format)) {
|
|
47
58
|
throw new ResumeInputError("invalid_resume_input", "Resume must contain text or Markdown content");
|
|
48
59
|
}
|
package/src/crawler.ts
CHANGED
|
@@ -30,7 +30,7 @@ interface CrawlerOptions {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export interface Crawler {
|
|
33
|
-
crawl(sources: Company[], settings?: { prune?: boolean }): Promise<CrawlReport>;
|
|
33
|
+
crawl(sources: Company[], settings?: { prune?: boolean; workdayCountries?: string[] }): Promise<CrawlReport>;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function createCrawler(options: CrawlerOptions): Crawler {
|
|
@@ -124,7 +124,8 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
124
124
|
const listed = await options.fetchJobs(source, controller.signal, {
|
|
125
125
|
onBackoff: ({ status, delayMs }) => { metric.backoffMs += delayMs; if (status === 429) metric.throttles += 1; },
|
|
126
126
|
workdayPageDelayMs: options.workdayPageDelayMs,
|
|
127
|
-
|
|
127
|
+
// The countries this crawl asked for drive Workday's supplementary passes, not just the configured default.
|
|
128
|
+
workdayCountries: settings?.workdayCountries ?? options.workdayCountries,
|
|
128
129
|
});
|
|
129
130
|
const jobs = await withExperience(source, listed, partitionFor(partitions, source.slug)?.jobs);
|
|
130
131
|
partitions[source.slug] = { fetchedAt: now().toISOString(), jobs };
|
|
@@ -211,7 +211,7 @@ function refreshReason(policy: RefreshPolicy, snapshot: JobSnapshot | null, matc
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
function refreshScope(intent: CandidateIntent, snapshot: JobSnapshot | null, sources: Company[]): CrawlScope {
|
|
214
|
-
return intent.countries?.length ? { slugs: relevantSourceSlugs(snapshot, intent, sources) } : {};
|
|
214
|
+
return intent.countries?.length ? { slugs: relevantSourceSlugs(snapshot, intent, sources), countries: intent.countries } : {};
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
function relevantSnapshot(snapshot: JobSnapshot, intent: CandidateIntent, sources: Company[]): JobSnapshot {
|
|
@@ -38,7 +38,7 @@ export function createJobSearchPreparer(options: {
|
|
|
38
38
|
}
|
|
39
39
|
const pendingBefore = pendingSources(options.sources, snapshot, now(), freshnessMs);
|
|
40
40
|
const selected = pendingBefore.filter((source) => !input.attempted.has(source.slug)).slice(0, batchSize).map((source) => source.slug);
|
|
41
|
-
const crawl = selected.length ? await options.crawl({ slugs: selected }) : undefined;
|
|
41
|
+
const crawl = selected.length ? await options.crawl({ slugs: selected, countries }) : undefined;
|
|
42
42
|
if (crawl) snapshot = await options.store.read();
|
|
43
43
|
if (!snapshot) throw new Error("Job search preparation did not produce a local snapshot");
|
|
44
44
|
|
package/src/local-jobs.ts
CHANGED
|
@@ -44,7 +44,10 @@ export function createLocalJobs(options: LocalJobsOptions) {
|
|
|
44
44
|
|
|
45
45
|
async function crawl(scope: CrawlScope = {}): Promise<CrawlReport> {
|
|
46
46
|
const selected = selectSources(options.sources, scope);
|
|
47
|
-
|
|
47
|
+
// Workday's country passes cover the countries this crawl is for, on top of the configured ones.
|
|
48
|
+
const asked = [...(scope.countries ?? []), ...(scope.country ? [scope.country] : [])].map((code) => code.toUpperCase());
|
|
49
|
+
const workdayCountries = asked.length ? [...new Set([...(options.workdayCountries ?? []), ...asked])] : undefined;
|
|
50
|
+
return crawler.crawl(selected, { prune: !scope.country && !scope.countries && !scope.slugs, ...(workdayCountries ? { workdayCountries } : {}) });
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
async function ensureFresh(offline: boolean, staleDays: number): Promise<{ snapshot: JobSnapshot; refreshed: boolean }> {
|
package/src/mcp.ts
CHANGED
|
@@ -48,11 +48,13 @@ export function createMcpHandler(tools: ToolHandler, options: { update?: () => U
|
|
|
48
48
|
if (args !== undefined && !isRecord(args)) throw new Error("tools/call arguments must be an object");
|
|
49
49
|
try {
|
|
50
50
|
const result = await tools.call(name, args ?? {});
|
|
51
|
-
|
|
51
|
+
const payload = withUpdate(result);
|
|
52
|
+
// Both shapes: text for models that read it, structuredContent for hosts that render it.
|
|
53
|
+
return { ...base, result: { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], structuredContent: payload, isError: false } };
|
|
52
54
|
} catch (error) {
|
|
53
55
|
const details = errorDetails(error);
|
|
54
|
-
const
|
|
55
|
-
return { ...base, result: { content: [{ type: "text", text: JSON.stringify(
|
|
56
|
+
const failure = withUpdate({ error: { message: error instanceof Error ? error.message : String(error), ...(details ?? {}) } });
|
|
57
|
+
return { ...base, result: { content: [{ type: "text", text: JSON.stringify(failure, null, 2) }], structuredContent: failure, isError: true } };
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
if (request.method.startsWith("notifications/")) return null;
|
package/src/runtime.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { SnapshotStore } from "./crawler.ts";
|
|
3
|
-
import type { Company, SearchQuery } from "./types.ts";
|
|
3
|
+
import type { Company, Job, SearchQuery } from "./types.ts";
|
|
4
4
|
import { fetchJobDescription, fetchSourceJobs } from "./catalog.ts";
|
|
5
5
|
import { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
|
|
6
6
|
import { createUsageReporter, type UsageReporter } from "./usage.ts";
|
|
@@ -74,12 +74,24 @@ export function createHostedRuntime(options: { sources: Company[]; store: Snapsh
|
|
|
74
74
|
const recommender = createJobRecommender({ sources: options.sources, store: options.store, crawl: noCrawl, now: options.now });
|
|
75
75
|
const coverage = createJobCoverageReader({ sources: options.sources, store: options.store });
|
|
76
76
|
const preparation = createJobSearchPreparer({ sources: options.sources, store: options.store, crawl: noCrawl, now: options.now, freshnessDays: 3650 });
|
|
77
|
-
|
|
77
|
+
// Workday and a few other boards keep descriptions off their listings, so the shared index has none: read one on demand.
|
|
78
|
+
async function withDescription(job: Job | null): Promise<Job | null> {
|
|
79
|
+
if (!job || job.description.trim()) return job;
|
|
80
|
+
const [ats, slug] = job.id.split(":", 3);
|
|
81
|
+
const company = options.sources.find((source) => source.ats === ats && source.slug === slug);
|
|
82
|
+
if (!company) return job;
|
|
83
|
+
try {
|
|
84
|
+
const description = await fetchJobDescription(company, job, globalThis.fetch);
|
|
85
|
+
return description.trim() ? { ...job, description } : job;
|
|
86
|
+
} catch { return job; } // the employer's board is unreachable; the summary is still useful
|
|
87
|
+
}
|
|
88
|
+
const snapshotJob = async (id: string) => (await local.get(id, { offline: true, staleDays: 3650 })).job;
|
|
89
|
+
const getSelectedJob = createSelectedJobLookup({ getSnapshotJob: snapshotJob, getDetailedJob: async (id) => withDescription(await snapshotJob(id)) });
|
|
78
90
|
const analyzer = createJobFitAnalyzer({ getJob: getSelectedJob });
|
|
79
91
|
const optimizer = createResumeOptimizer({ analyzeJobFit: analyzer.analyze });
|
|
80
92
|
return {
|
|
81
93
|
search: (query: SearchQuery) => local.search(query, { offline: true, staleDays: 3650 }),
|
|
82
|
-
get: (id: string) => local.get(id, { offline: true, staleDays: 3650 }),
|
|
94
|
+
get: async (id: string) => { const found = await local.get(id, { offline: true, staleDays: 3650 }); return { ...found, job: await withDescription(found.job) }; },
|
|
83
95
|
prepareJobSearch: preparation.prepare, getJobCoverage: coverage.getCoverage, recommend: recommender.recommend, analyzeJobFit: analyzer.analyze, optimizeResume: optimizer.optimize,
|
|
84
96
|
};
|
|
85
97
|
}
|
package/src/tools.ts
CHANGED
|
@@ -11,8 +11,14 @@ export interface ToolDefinition {
|
|
|
11
11
|
name: "prepare_job_search" | "get_job_coverage" | "recommend_jobs" | "analyze_job_fit" | "optimize_resume" | "search_jobs" | "get_job";
|
|
12
12
|
description: string;
|
|
13
13
|
inputSchema: Record<string, unknown>;
|
|
14
|
+
/** Every tool only reads: nothing here applies, submits, or writes on the candidate's behalf. */
|
|
15
|
+
annotations: { readOnlyHint: true; destructiveHint: false; idempotentHint: true; openWorldHint: boolean };
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/** Tools that reach employer boards over the network are open-world; the rest read the local index. */
|
|
19
|
+
const annotationsFor = (name: ToolDefinition["name"]): ToolDefinition["annotations"] =>
|
|
20
|
+
({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: name === "prepare_job_search" || name === "recommend_jobs" || name === "get_job" });
|
|
21
|
+
|
|
16
22
|
interface JobWorkflows {
|
|
17
23
|
prepareJobSearch(input: unknown): Promise<PrepareJobSearchResult>;
|
|
18
24
|
getJobCoverage(input: unknown): Promise<JobCoverageSummary>;
|
|
@@ -22,7 +28,7 @@ interface JobWorkflows {
|
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, options: { onCall?(name: string, input: Record<string, unknown>, result: unknown): void } = {}) {
|
|
25
|
-
const definitions: ToolDefinition
|
|
31
|
+
const definitions: Array<Omit<ToolDefinition, "annotations">> = [
|
|
26
32
|
{
|
|
27
33
|
name: "prepare_job_search",
|
|
28
34
|
description: "Initialize the local job index: the first call downloads the shared index of every verified source (thousands of employers) and returns ready; only missing or stale sources are crawled, in batches of at most 25. Report coverage from the result rather than calling again once nextAction is ready. This may use the network and write only job data under the local Openings data directory; it never processes a resume.",
|
|
@@ -119,7 +125,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
119
125
|
description: "Get the full description and application URL for a job returned by recommend_jobs or search_jobs.",
|
|
120
126
|
inputSchema: {
|
|
121
127
|
type: "object",
|
|
122
|
-
properties: { id: { type: "string", description: "Stable job id returned by search_jobs" } },
|
|
128
|
+
properties: { id: { type: "string", description: "Stable job id returned by search_jobs or recommend_jobs (jobId is accepted too)" } },
|
|
123
129
|
required: ["id"],
|
|
124
130
|
additionalProperties: false,
|
|
125
131
|
},
|
|
@@ -127,7 +133,7 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
127
133
|
];
|
|
128
134
|
|
|
129
135
|
return {
|
|
130
|
-
list: () => definitions,
|
|
136
|
+
list: () => definitions.map((definition) => ({ ...definition, annotations: annotationsFor(definition.name) })),
|
|
131
137
|
async call(name: string, input: Record<string, unknown>) {
|
|
132
138
|
const result = await dispatch(name, input);
|
|
133
139
|
try { options.onCall?.(name, input, result); } catch { /* usage reporting never affects a tool result */ }
|
|
@@ -163,9 +169,11 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
163
169
|
};
|
|
164
170
|
}
|
|
165
171
|
if (name === "get_job") {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
// analyze_job_fit and optimize_resume call it jobId, so accept either spelling rather than fail on a near miss.
|
|
173
|
+
assertToolKeys(input, ["id", "jobId"], "get_job");
|
|
174
|
+
const id = typeof input.id === "string" && input.id ? input.id : input.jobId;
|
|
175
|
+
if (typeof id !== "string" || !id) throw new Error("get_job requires a non-empty id");
|
|
176
|
+
return { job: await catalog.get(id) };
|
|
169
177
|
}
|
|
170
178
|
throw new Error(`Unknown tool: ${name}`);
|
|
171
179
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.35";
|