openings 0.1.54 → 0.1.56
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/package.json +1 -1
- package/src/crawler.ts +23 -1
- package/src/index.ts +1 -1
- package/src/local-jobs.ts +1 -0
- package/src/runtime.ts +2 -0
- package/src/tools.ts +36 -2
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/crawler.ts
CHANGED
|
@@ -31,6 +31,14 @@ interface CrawlerOptions {
|
|
|
31
31
|
* including one whose board states no date at all. The nightly crawl keeps the 30-day default so it spends
|
|
32
32
|
* its small budget on what is new; the catch-up pass lifts it to reach roles still listed but long posted. */
|
|
33
33
|
describeMaxAgeDays?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Whether the snapshot written to disk keeps the description text. A local install needs it: the snapshot is
|
|
36
|
+
* its only copy. A server crawl reports every source to an aggregator that stores them, so keeping a second
|
|
37
|
+
* copy only costs memory — on one box the file reached 1.3 GB and parsing it needed more RAM than the machine
|
|
38
|
+
* had, which killed the crawl for seven nights. The experience read out of each description is always kept,
|
|
39
|
+
* so a role already read is never fetched twice.
|
|
40
|
+
*/
|
|
41
|
+
retainDescriptions?: boolean;
|
|
34
42
|
pacingNow?: () => number;
|
|
35
43
|
pacingSleep?: (delayMs: number) => Promise<void>;
|
|
36
44
|
now?: () => Date;
|
|
@@ -190,7 +198,9 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
190
198
|
cached: considered - eligibleSources.length, deferred: eligibleSources.length - selectedSources.length,
|
|
191
199
|
succeeded, failed: finalFailures, sources: selectedSources.map((source) => metrics.get(source.slug)!),
|
|
192
200
|
};
|
|
193
|
-
|
|
201
|
+
// Reporting below still sends the full partitions; only the copy kept on disk is lightened.
|
|
202
|
+
const stored = options.retainDescriptions === false ? withoutDescriptions(partitions) : partitions;
|
|
203
|
+
await options.store.write({ version: 1, updatedAt: finishedAt, partitions: stored, lastCrawl: report });
|
|
194
204
|
if (options.onCrawled) {
|
|
195
205
|
const crawled = selectedSources.filter((source) => metrics.get(source.slug)!.status === "succeeded");
|
|
196
206
|
// Four at a time: sending a thousand reports at once made the largest ones time out while the aggregator queued them.
|
|
@@ -204,6 +214,18 @@ export function createCrawler(options: CrawlerOptions): Crawler {
|
|
|
204
214
|
}
|
|
205
215
|
|
|
206
216
|
// ponytail: per-source caps keep one crawl bounded; a big backlog (first run) drains over a few nights.
|
|
217
|
+
/**
|
|
218
|
+
* The same partitions with the description text left out. Everything the next crawl needs to avoid re-reading a
|
|
219
|
+
* description — the experience and its version — is kept, so this costs nothing but the bytes.
|
|
220
|
+
*/
|
|
221
|
+
function withoutDescriptions(partitions: JobSnapshot["partitions"]): JobSnapshot["partitions"] {
|
|
222
|
+
const out: JobSnapshot["partitions"] = {};
|
|
223
|
+
for (const [slug, partition] of Object.entries(partitions)) {
|
|
224
|
+
out[slug] = { ...partition, jobs: partition.jobs.map((job) => (job.description ? { ...job, description: "" } : job)) };
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
207
229
|
/** Defaults for the nightly crawl, where the point is to keep every source moving rather than finish any one. */
|
|
208
230
|
const DESCRIBE_LIMIT = 150;
|
|
209
231
|
const DESCRIBE_MAX_AGE_DAYS = 30;
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,7 @@ export { createJobCoverageReader, projectJobCoverage } from "./job-coverage.ts";
|
|
|
40
40
|
export type { CountryJobCoverage, JobCoverageSummary } from "./job-coverage.ts";
|
|
41
41
|
export { createHostedRuntime } from "./runtime.ts";
|
|
42
42
|
export type { SnapshotStore } from "./crawler.ts";
|
|
43
|
-
export { createToolHandler } from "./tools.ts";
|
|
43
|
+
export { createToolHandler, SCOPES, scopeForTool, type Scope } from "./tools.ts";
|
|
44
44
|
export { createMcpHandler, FLOW_INSTRUCTIONS } from "./mcp.ts";
|
|
45
45
|
export type { UpdateNotice } from "./update-check.ts";
|
|
46
46
|
export { VERSION } from "./version.ts";
|
package/src/local-jobs.ts
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -41,6 +41,8 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
|
|
|
41
41
|
describeLimit: positiveEnv("OPENINGS_DESCRIBE_LIMIT"),
|
|
42
42
|
describeBudgetMs: positiveEnv("OPENINGS_DESCRIBE_BUDGET_MS"),
|
|
43
43
|
describeMaxAgeDays: wholeEnv("OPENINGS_DESCRIBE_MAX_AGE_DAYS"),
|
|
44
|
+
// Server crawls set this off: the aggregator they report to is the system of record for descriptions.
|
|
45
|
+
retainDescriptions: process.env.OPENINGS_RETAIN_DESCRIPTIONS !== "off",
|
|
44
46
|
onCrawled,
|
|
45
47
|
});
|
|
46
48
|
const recommender = createJobRecommender({ sources: companies, store, crawl: local.crawl });
|
package/src/tools.ts
CHANGED
|
@@ -33,7 +33,35 @@ interface JobWorkflows {
|
|
|
33
33
|
optimizeResume(input: unknown): Promise<OptimizeResumeResult>;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* What a token is allowed to reach. The split is on what the tool touches, not on what it changes: four tools read
|
|
38
|
+
* job data and nothing else, three parse a candidate's resume. A client that only wants job search should be able
|
|
39
|
+
* to say so and be held to it, which is the promise we already make in prose and could not enforce.
|
|
40
|
+
*/
|
|
41
|
+
export const SCOPES = { jobs: "jobs.read", resume: "resume.analyze" } as const;
|
|
42
|
+
export type Scope = (typeof SCOPES)[keyof typeof SCOPES];
|
|
43
|
+
|
|
44
|
+
const TOOL_SCOPE: Record<ToolDefinition["name"], Scope> = {
|
|
45
|
+
prepare_job_search: SCOPES.jobs, get_job_coverage: SCOPES.jobs, search_jobs: SCOPES.jobs, get_job: SCOPES.jobs,
|
|
46
|
+
recommend_jobs: SCOPES.resume, analyze_job_fit: SCOPES.resume, optimize_resume: SCOPES.resume,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The scope a tool needs, for a caller that wants to explain the boundary rather than discover it by refusal. */
|
|
50
|
+
export function scopeForTool(name: string): Scope | undefined {
|
|
51
|
+
return TOOL_SCOPE[name as ToolDefinition["name"]];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, options: {
|
|
55
|
+
onCall?(name: string, input: Record<string, unknown>, result: unknown): void;
|
|
56
|
+
/**
|
|
57
|
+
* Scopes this token was granted. Undefined means every tool, which is deliberate: tokens issued before the
|
|
58
|
+
* split carry no scope claim, and refusing them would break every client that already works. Only a token
|
|
59
|
+
* that states its scopes is held to them.
|
|
60
|
+
*/
|
|
61
|
+
scopes?: readonly string[];
|
|
62
|
+
} = {}) {
|
|
63
|
+
const granted = options.scopes === undefined ? undefined : new Set(options.scopes);
|
|
64
|
+
const allowed = (name: ToolDefinition["name"]) => granted === undefined || granted.has(TOOL_SCOPE[name]);
|
|
37
65
|
const definitions: Array<Omit<ToolDefinition, "annotations">> = [
|
|
38
66
|
{
|
|
39
67
|
name: "prepare_job_search",
|
|
@@ -209,8 +237,14 @@ export function createToolHandler(catalog: Catalog, workflows: JobWorkflows, opt
|
|
|
209
237
|
];
|
|
210
238
|
|
|
211
239
|
return {
|
|
212
|
-
|
|
240
|
+
// A client sees only what it may call. Listing a tool it would be refused teaches it to try and fail.
|
|
241
|
+
list: () => definitions.filter((definition) => allowed(definition.name))
|
|
242
|
+
.map((definition) => ({ ...definition, annotations: annotationsFor(definition.name) })),
|
|
213
243
|
async call(name: string, input: Record<string, unknown>) {
|
|
244
|
+
const needed = TOOL_SCOPE[name as ToolDefinition["name"]];
|
|
245
|
+
if (needed && !allowed(name as ToolDefinition["name"])) {
|
|
246
|
+
throw new Error(`${name} needs the ${needed} scope, which this connection was not granted. Reconnect and allow it.`);
|
|
247
|
+
}
|
|
214
248
|
const result = await dispatch(name, input);
|
|
215
249
|
try { options.onCall?.(name, input, result); } catch { /* usage reporting never affects a tool result */ }
|
|
216
250
|
return result;
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.56";
|