openings 0.1.34 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.34",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
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" },
@@ -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/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
- return { ...base, result: { content: [{ type: "text", text: JSON.stringify(withUpdate(result), null, 2) }], isError: false } };
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 payload = { error: { message: error instanceof Error ? error.message : String(error), ...(details ?? {}) } };
55
- return { ...base, result: { content: [{ type: "text", text: JSON.stringify(withUpdate(payload), null, 2) }], isError: true } };
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
- const getSelectedJob = createSelectedJobLookup({ getSnapshotJob: async (id) => (await local.get(id, { offline: true, staleDays: 3650 })).job, getDetailedJob: async () => null });
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
- assertToolKeys(input, ["id"], "get_job");
167
- if (typeof input.id !== "string" || !input.id) throw new Error("get_job requires a non-empty id");
168
- return { job: await catalog.get(input.id) };
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.34";
1
+ export const VERSION = "0.1.35";