@profullstack/agenticjobs 0.9.0 → 0.11.0
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/README.md +23 -1
- package/dist/cli/args.js +28 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/index.js +247 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/client/client.d.ts +36 -0
- package/dist/client/client.js +54 -12
- package/dist/client/client.js.map +1 -1
- package/dist/client/fanout.js +7 -1
- package/dist/client/fanout.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +4 -1
- package/dist/config.js.map +1 -1
- package/dist/core/agentwriter.d.ts +79 -0
- package/dist/core/agentwriter.js +343 -0
- package/dist/core/agentwriter.js.map +1 -0
- package/dist/core/candidates.js +30 -1
- package/dist/core/candidates.js.map +1 -1
- package/dist/core/capacity-alert.d.ts +84 -0
- package/dist/core/capacity-alert.js +144 -0
- package/dist/core/capacity-alert.js.map +1 -0
- package/dist/core/capacity.d.ts +96 -0
- package/dist/core/capacity.js +182 -0
- package/dist/core/capacity.js.map +1 -0
- package/dist/core/import-job.js +18 -7
- package/dist/core/import-job.js.map +1 -1
- package/dist/core/import.js +9 -8
- package/dist/core/import.js.map +1 -1
- package/dist/core/jobs.js +12 -2
- package/dist/core/jobs.js.map +1 -1
- package/dist/core/orgs.d.ts +39 -0
- package/dist/core/orgs.js +69 -0
- package/dist/core/orgs.js.map +1 -1
- package/dist/directory/federate.js +2 -4
- package/dist/directory/federate.js.map +1 -1
- package/dist/directory/fetch.d.ts +1 -1
- package/dist/markup/resume.js +66 -1
- package/dist/markup/resume.js.map +1 -1
- package/dist/mcp/server.js +5 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/stdio.js +3 -2
- package/dist/mcp/stdio.js.map +1 -1
- package/dist/mcp/tools.js +1 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/schema/jsonld.js +1 -8
- package/dist/schema/jsonld.js.map +1 -1
- package/dist/schema/salary.d.ts +5 -0
- package/dist/schema/salary.js +15 -0
- package/dist/schema/salary.js.map +1 -0
- package/dist/schema/text.js +7 -3
- package/dist/schema/text.js.map +1 -1
- package/dist/server/routes/api.js +63 -1
- package/dist/server/routes/api.js.map +1 -1
- package/dist/server/routes/discovery.js +56 -0
- package/dist/server/routes/discovery.js.map +1 -1
- package/dist/server/routes/openapi.js +17 -1
- package/dist/server/routes/openapi.js.map +1 -1
- package/dist/server/routes/pages.js +44 -4
- package/dist/server/routes/pages.js.map +1 -1
- package/dist/views/candidates.d.ts +7 -0
- package/dist/views/candidates.js +2 -1
- package/dist/views/candidates.js.map +1 -1
- package/dist/views/docs.js +38 -3
- package/dist/views/docs.js.map +1 -1
- package/dist/views/post.d.ts +4 -0
- package/dist/views/post.js +1 -1
- package/dist/views/post.js.map +1 -1
- package/docs/openjob.md +5 -0
- package/docs/openresume.md +36 -0
- package/migrations/0013_agent_drafts.sql +19 -0
- package/package.json +15 -17
- package/web/public/app.css +11 -3
- package/web/public/sw.js +1 -1
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a sentence into a draft listing.
|
|
3
|
+
*
|
|
4
|
+
* The board's thesis is agents hiring agents with a person in control at both
|
|
5
|
+
* ends, and this is that seam on the employer side for somebody who does not
|
|
6
|
+
* have an agent of their own. It fills the form and stops. Nothing is saved,
|
|
7
|
+
* nothing is published, and the person who asked reads every word before the
|
|
8
|
+
* listing exists. A model writing straight into the database would be the one
|
|
9
|
+
* shape this board has said it will not ship.
|
|
10
|
+
*
|
|
11
|
+
* Two providers, because this is MIT software other people self-host and a
|
|
12
|
+
* board that only works if you bank with one vendor is not self-hostable. The
|
|
13
|
+
* key that is set decides; when neither is set the feature is simply absent
|
|
14
|
+
* from the page rather than present and broken.
|
|
15
|
+
*
|
|
16
|
+
* Written against the two REST APIs directly rather than either vendor SDK.
|
|
17
|
+
* One dependency per provider to fill in a form is a poor trade for a package
|
|
18
|
+
* that reads a .docx by unzipping it by hand, and the request shapes here are
|
|
19
|
+
* small enough to read in full.
|
|
20
|
+
*/
|
|
21
|
+
import { AGENT_POLICIES, EMPLOYMENT_TYPES, SALARY_PERIODS, SENIORITIES, WORKPLACES, } from "../schema/job.js";
|
|
22
|
+
/** The model was asked and could not answer. Never fatal to the page. */
|
|
23
|
+
export class AgentWriterProblem extends Error {
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Which provider this instance can use, or null for none.
|
|
27
|
+
*
|
|
28
|
+
* Anthropic first when both are set, for no better reason than that a board
|
|
29
|
+
* with both keys has to pick one and a coin flip at request time would make
|
|
30
|
+
* the same brief produce different listings.
|
|
31
|
+
*/
|
|
32
|
+
export function writerProvider(config) {
|
|
33
|
+
if (config.anthropicApiKey !== null)
|
|
34
|
+
return 'anthropic';
|
|
35
|
+
if (config.openaiApiKey !== null)
|
|
36
|
+
return 'openai';
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* What the model is asked for, and what it is asked not to do.
|
|
41
|
+
*
|
|
42
|
+
* The pay rules are the ones that matter. An invented salary range is worse
|
|
43
|
+
* than an empty one: the employer may not notice it, and a candidate who
|
|
44
|
+
* applies because of a number nobody agreed to has been misled by this board.
|
|
45
|
+
* So pay comes from the brief or not at all, and "unpaid" has to be stated
|
|
46
|
+
* rather than inferred from silence.
|
|
47
|
+
*/
|
|
48
|
+
const SYSTEM = [
|
|
49
|
+
'You write job listings for a job board. You are given a short brief from the',
|
|
50
|
+
'employer and you expand it into a complete listing.',
|
|
51
|
+
'',
|
|
52
|
+
'Return a single JSON object and nothing else. Every field is optional; leave',
|
|
53
|
+
'out anything the brief does not support.',
|
|
54
|
+
'',
|
|
55
|
+
' title a specific role title, under 80 characters',
|
|
56
|
+
' description Markdown. What the work is, who it suits, how the team',
|
|
57
|
+
' operates. Write in the employer\'s voice, second person',
|
|
58
|
+
' to the reader. No headings above level 2. 150-350 words.',
|
|
59
|
+
' requirements array of short strings',
|
|
60
|
+
' responsibilities array of short strings',
|
|
61
|
+
' tags array of lowercase topic words',
|
|
62
|
+
' stack array of lowercase technology names',
|
|
63
|
+
' employmentType one of: full-time, part-time, contract, internship, temporary',
|
|
64
|
+
' workplace one of: remote, hybrid, onsite',
|
|
65
|
+
' seniority one of: intern, junior, mid, senior, staff, principal, lead',
|
|
66
|
+
' location free text, only if the brief gives one',
|
|
67
|
+
' salaryMin integer, ONLY if the brief states pay',
|
|
68
|
+
' salaryMax integer, ONLY if the brief states pay',
|
|
69
|
+
' salaryPeriod one of: hour, day, week, month, year',
|
|
70
|
+
' salaryUnpaid true ONLY if the brief says the role is unpaid',
|
|
71
|
+
'',
|
|
72
|
+
'Rules you do not break:',
|
|
73
|
+
'- Never invent compensation. If the brief says nothing about pay, omit every',
|
|
74
|
+
' salary field. A number nobody agreed to is worse than no number.',
|
|
75
|
+
'- Never invent a company name, a benefit, a funding stage, or a headcount.',
|
|
76
|
+
'- Do not write "competitive salary", "rockstar", "ninja", or "fast-paced".',
|
|
77
|
+
'- Do not address the reader as a candidate in the description title.',
|
|
78
|
+
].join('\n');
|
|
79
|
+
/**
|
|
80
|
+
* A brief, expanded into values the post form can be rendered with.
|
|
81
|
+
*
|
|
82
|
+
* Returns form values rather than a job, because the next thing that happens
|
|
83
|
+
* is that a person looks at them. They go through the same `normaliseInput`
|
|
84
|
+
* every other posting path goes through when the form is finally submitted,
|
|
85
|
+
* so nothing here can put a listing into a state a hand-typed one could not
|
|
86
|
+
* reach.
|
|
87
|
+
*/
|
|
88
|
+
export async function draftListing(brief, context, config, fetchImpl = fetch) {
|
|
89
|
+
const trimmed = brief.trim();
|
|
90
|
+
if (trimmed.length < 10) {
|
|
91
|
+
throw new AgentWriterProblem('Say a little more about the role, and I will draft it.');
|
|
92
|
+
}
|
|
93
|
+
const provider = writerProvider(config);
|
|
94
|
+
if (provider === null) {
|
|
95
|
+
throw new AgentWriterProblem('This board has no model configured.');
|
|
96
|
+
}
|
|
97
|
+
const prompt = [
|
|
98
|
+
`Board: ${context.boardName}.`,
|
|
99
|
+
context.orgName === null ? null : `Employer: ${context.orgName}.`,
|
|
100
|
+
'',
|
|
101
|
+
'The brief:',
|
|
102
|
+
trimmed.slice(0, 4000),
|
|
103
|
+
]
|
|
104
|
+
.filter((line) => line !== null)
|
|
105
|
+
.join('\n');
|
|
106
|
+
const raw = provider === 'anthropic'
|
|
107
|
+
? await askAnthropic(prompt, config, fetchImpl)
|
|
108
|
+
: await askOpenAi(prompt, config, fetchImpl);
|
|
109
|
+
return fieldsFromModelJson(raw);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The Messages API, by hand.
|
|
113
|
+
*
|
|
114
|
+
* Thinking is on by default on this model and `effort: low` is right for
|
|
115
|
+
* filling in a form: the work is recall and phrasing, not reasoning, and the
|
|
116
|
+
* person is waiting on a page for it.
|
|
117
|
+
*/
|
|
118
|
+
async function askAnthropic(prompt, config, fetchImpl) {
|
|
119
|
+
const response = await fetchImpl('https://api.anthropic.com/v1/messages', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: {
|
|
122
|
+
'content-type': 'application/json',
|
|
123
|
+
'x-api-key': config.anthropicApiKey ?? '',
|
|
124
|
+
'anthropic-version': '2023-06-01',
|
|
125
|
+
},
|
|
126
|
+
body: JSON.stringify({
|
|
127
|
+
model: config.writerModel ?? 'claude-opus-5',
|
|
128
|
+
max_tokens: 4000,
|
|
129
|
+
output_config: { effort: 'low' },
|
|
130
|
+
system: SYSTEM,
|
|
131
|
+
messages: [{ role: 'user', content: prompt }],
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
if (!response.ok)
|
|
135
|
+
throw await problemFor(response, 'Anthropic');
|
|
136
|
+
const body = (await response.json());
|
|
137
|
+
// A refusal arrives as a 200 with nothing usable in it, so the status alone
|
|
138
|
+
// is not evidence that there is text to read.
|
|
139
|
+
if (body.stop_reason === 'refusal') {
|
|
140
|
+
throw new AgentWriterProblem('The model declined to write that one.');
|
|
141
|
+
}
|
|
142
|
+
const text = (body.content ?? [])
|
|
143
|
+
.filter((block) => block.type === 'text')
|
|
144
|
+
.map((block) => block.text ?? '')
|
|
145
|
+
.join('');
|
|
146
|
+
if (text.trim() === '')
|
|
147
|
+
throw new AgentWriterProblem('The model returned nothing.');
|
|
148
|
+
return text;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Chat completions, by hand.
|
|
152
|
+
*
|
|
153
|
+
* `max_completion_tokens`, not `max_tokens`: the current models reject the
|
|
154
|
+
* older name. `json_object` is what keeps the reply parseable without asking
|
|
155
|
+
* the model nicely twice.
|
|
156
|
+
*/
|
|
157
|
+
async function askOpenAi(prompt, config, fetchImpl) {
|
|
158
|
+
const response = await fetchImpl('https://api.openai.com/v1/chat/completions', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'content-type': 'application/json',
|
|
162
|
+
authorization: `Bearer ${config.openaiApiKey ?? ''}`,
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
model: config.writerModel ?? 'gpt-5.2',
|
|
166
|
+
max_completion_tokens: 4000,
|
|
167
|
+
response_format: { type: 'json_object' },
|
|
168
|
+
messages: [
|
|
169
|
+
{ role: 'system', content: SYSTEM },
|
|
170
|
+
{ role: 'user', content: prompt },
|
|
171
|
+
],
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
if (!response.ok)
|
|
175
|
+
throw await problemFor(response, 'OpenAI');
|
|
176
|
+
const body = (await response.json());
|
|
177
|
+
const choice = body.choices?.[0]?.message;
|
|
178
|
+
if (typeof choice?.refusal === 'string' && choice.refusal !== '') {
|
|
179
|
+
throw new AgentWriterProblem('The model declined to write that one.');
|
|
180
|
+
}
|
|
181
|
+
const text = choice?.content ?? '';
|
|
182
|
+
if (text.trim() === '')
|
|
183
|
+
throw new AgentWriterProblem('The model returned nothing.');
|
|
184
|
+
return text;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* An HTTP failure, said in words a person on a form can act on.
|
|
188
|
+
*
|
|
189
|
+
* The provider's own message is deliberately not shown: it is written for
|
|
190
|
+
* whoever holds the key, not for the employer typing a brief, and it can
|
|
191
|
+
* carry account details that do not belong on a public page.
|
|
192
|
+
*/
|
|
193
|
+
async function problemFor(response, provider) {
|
|
194
|
+
// Read and discard, so the connection is not left hanging on a body nobody
|
|
195
|
+
// consumed.
|
|
196
|
+
await response.text().catch(() => '');
|
|
197
|
+
if (response.status === 401 || response.status === 403) {
|
|
198
|
+
return new AgentWriterProblem(`This board's ${provider} key was rejected.`);
|
|
199
|
+
}
|
|
200
|
+
if (response.status === 429) {
|
|
201
|
+
return new AgentWriterProblem('The model is rate limited right now. Try again shortly.');
|
|
202
|
+
}
|
|
203
|
+
return new AgentWriterProblem(`${provider} could not be reached. Try again shortly.`);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The model's JSON, reduced to form values this board will accept.
|
|
207
|
+
*
|
|
208
|
+
* Everything is checked against the same lists the form's own selects are
|
|
209
|
+
* built from, and anything unrecognised is dropped rather than corrected. A
|
|
210
|
+
* model that invents an employment type should leave that field blank for a
|
|
211
|
+
* person to fill in, not have its answer bent into the nearest legal value:
|
|
212
|
+
* the second one looks like the employer chose it.
|
|
213
|
+
*
|
|
214
|
+
* Exported because this is the part worth testing, and testing it needs no
|
|
215
|
+
* network and no key.
|
|
216
|
+
*/
|
|
217
|
+
export function fieldsFromModelJson(raw) {
|
|
218
|
+
const parsed = parseLoose(raw);
|
|
219
|
+
if (parsed === null)
|
|
220
|
+
throw new AgentWriterProblem('The model did not return a listing.');
|
|
221
|
+
const out = {};
|
|
222
|
+
const put = (key, value) => {
|
|
223
|
+
if (value.trim() !== '')
|
|
224
|
+
out[key] = value.trim();
|
|
225
|
+
};
|
|
226
|
+
put('title', text(parsed['title'], 140));
|
|
227
|
+
put('description', text(parsed['description'], 20_000));
|
|
228
|
+
put('location', text(parsed['location'], 120));
|
|
229
|
+
put('employmentType', oneOf(parsed['employmentType'], EMPLOYMENT_TYPES));
|
|
230
|
+
put('workplace', oneOf(parsed['workplace'], WORKPLACES));
|
|
231
|
+
put('seniority', oneOf(parsed['seniority'], SENIORITIES));
|
|
232
|
+
put('salaryPeriod', oneOf(parsed['salaryPeriod'], SALARY_PERIODS));
|
|
233
|
+
put('agentPolicy', oneOf(parsed['agentPolicy'], AGENT_POLICIES));
|
|
234
|
+
put('tags', list(parsed['tags'], 12));
|
|
235
|
+
put('stack', list(parsed['stack'], 20));
|
|
236
|
+
put('requirements', bullets(parsed['requirements']));
|
|
237
|
+
put('responsibilities', bullets(parsed['responsibilities']));
|
|
238
|
+
// Pay only survives if the model actually returned a number. "Unpaid" and a
|
|
239
|
+
// range are mutually exclusive here for the same reason they are everywhere
|
|
240
|
+
// else on the board.
|
|
241
|
+
if (parsed['salaryUnpaid'] === true) {
|
|
242
|
+
out['salaryUnpaid'] = 'on';
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
put('salaryMin', amount(parsed['salaryMin']));
|
|
246
|
+
put('salaryMax', amount(parsed['salaryMax']));
|
|
247
|
+
}
|
|
248
|
+
if (out['title'] === undefined && out['description'] === undefined) {
|
|
249
|
+
throw new AgentWriterProblem('The model did not return a listing.');
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* JSON, whether or not it arrived alone.
|
|
255
|
+
*
|
|
256
|
+
* Both providers were asked for a bare object and both usually send one, but a
|
|
257
|
+
* model that wraps it in a ```json fence has still done the job, and failing
|
|
258
|
+
* the whole request over a fence would be the wrong place to be strict.
|
|
259
|
+
*/
|
|
260
|
+
function parseLoose(raw) {
|
|
261
|
+
const attempts = [raw];
|
|
262
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(raw);
|
|
263
|
+
if (fenced !== null)
|
|
264
|
+
attempts.push(fenced[1] ?? '');
|
|
265
|
+
const braced = /\{[\s\S]*\}/.exec(raw);
|
|
266
|
+
if (braced !== null)
|
|
267
|
+
attempts.push(braced[0]);
|
|
268
|
+
for (const attempt of attempts) {
|
|
269
|
+
try {
|
|
270
|
+
const value = JSON.parse(attempt);
|
|
271
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Try the next shape.
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
function text(value, max) {
|
|
282
|
+
return typeof value === 'string' ? value.slice(0, max) : '';
|
|
283
|
+
}
|
|
284
|
+
function oneOf(value, allowed) {
|
|
285
|
+
if (typeof value !== 'string')
|
|
286
|
+
return '';
|
|
287
|
+
const found = allowed.find((item) => item === value.trim().toLowerCase());
|
|
288
|
+
return found ?? '';
|
|
289
|
+
}
|
|
290
|
+
function amount(value) {
|
|
291
|
+
const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
|
|
292
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
293
|
+
return '';
|
|
294
|
+
return String(Math.min(100_000_000, Math.round(parsed)));
|
|
295
|
+
}
|
|
296
|
+
/** The form takes these comma separated, which is how a person edits them. */
|
|
297
|
+
function list(value, max) {
|
|
298
|
+
if (!Array.isArray(value))
|
|
299
|
+
return typeof value === 'string' ? value : '';
|
|
300
|
+
return value
|
|
301
|
+
.filter((item) => typeof item === 'string')
|
|
302
|
+
.map((item) => item.trim().toLowerCase())
|
|
303
|
+
.filter((item) => item !== '' && item.length <= 40)
|
|
304
|
+
.slice(0, max)
|
|
305
|
+
.join(', ');
|
|
306
|
+
}
|
|
307
|
+
/** And these one per line. */
|
|
308
|
+
function bullets(value) {
|
|
309
|
+
if (!Array.isArray(value))
|
|
310
|
+
return typeof value === 'string' ? value : '';
|
|
311
|
+
return value
|
|
312
|
+
.filter((item) => typeof item === 'string')
|
|
313
|
+
.map((item) => item.trim().replace(/^[-*]\s*/, ''))
|
|
314
|
+
.filter((item) => item !== '')
|
|
315
|
+
.slice(0, 20)
|
|
316
|
+
.join('\n');
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* How many drafts this account has asked for in the last hour.
|
|
320
|
+
*
|
|
321
|
+
* The ceiling is per account rather than per board: one employer writing four
|
|
322
|
+
* listings in an afternoon is the use case, and a shared board-wide limit
|
|
323
|
+
* would let one of them lock out everybody else.
|
|
324
|
+
*/
|
|
325
|
+
export const DRAFTS_PER_HOUR = 10;
|
|
326
|
+
export async function recentDraftCount(pool, userId) {
|
|
327
|
+
const result = await pool.query(`select count(*)::int as count
|
|
328
|
+
from agent_drafts
|
|
329
|
+
where user_id = $1
|
|
330
|
+
and created_at > now() - interval '1 hour'`, [userId]);
|
|
331
|
+
return result.rows[0]?.count ?? 0;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Recorded before the model is called, not after.
|
|
335
|
+
*
|
|
336
|
+
* A request that fails still cost the board something and still came from
|
|
337
|
+
* somebody, so counting only the successes would leave the cheapest way to
|
|
338
|
+
* burn the key uncounted.
|
|
339
|
+
*/
|
|
340
|
+
export async function recordDraft(pool, userId) {
|
|
341
|
+
await pool.query(`insert into agent_drafts (user_id) values ($1)`, [userId]);
|
|
342
|
+
}
|
|
343
|
+
//# sourceMappingURL=agentwriter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agentwriter.js","sourceRoot":"","sources":["../../src/core/agentwriter.ts"],"names":[],"mappings":"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"}
|
package/dist/core/candidates.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* document they already wrote, and a second copy of the same facts is a second
|
|
7
7
|
* copy to keep in step.
|
|
8
8
|
*/
|
|
9
|
+
import { parseCapacity } from "./capacity.js";
|
|
9
10
|
import { parseResume, redactContactChannels } from "../markup/resume.js";
|
|
10
11
|
/** Contact keys that read as a place rather than an address. */
|
|
11
12
|
const LOCATION_KEYS = /^(location|based|city|where|region)$/i;
|
|
@@ -100,13 +101,41 @@ export function resumeForViewer(resume, signedIn) {
|
|
|
100
101
|
return { markdown: resume.markdown, parsed: resume.parsed, redacted: false };
|
|
101
102
|
return { markdown, parsed: parseResume(markdown), redacted: true };
|
|
102
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* The headline, re-checked at render time rather than trusted.
|
|
106
|
+
*
|
|
107
|
+
* `parsed` is a cache written on save, so fixing the parser does not fix the
|
|
108
|
+
* rows already in the table. Cleaning the headline at parse time left the live
|
|
109
|
+
* directory still captioned `Operated by:** DevilX (<address>)` — correct code
|
|
110
|
+
* serving a stale value, which is the shape of every derived-cache bug.
|
|
111
|
+
*
|
|
112
|
+
* A backfill would repair those rows, but it would not stop the next one: any
|
|
113
|
+
* resume saved by an older build, restored from a backup, or written straight
|
|
114
|
+
* into the column arrives here the same way. This is a public directory page
|
|
115
|
+
* and the field is one line of text, so it is checked on the way out. Cheap,
|
|
116
|
+
* and it cannot go stale.
|
|
117
|
+
*/
|
|
118
|
+
function headlineOf(resume) {
|
|
119
|
+
const headline = resume.parsed?.headline;
|
|
120
|
+
if (headline === null || headline === undefined)
|
|
121
|
+
return null;
|
|
122
|
+
const cleaned = headline.replace(/\*\*|__/g, '').trim();
|
|
123
|
+
if (cleaned === '')
|
|
124
|
+
return null;
|
|
125
|
+
return /[^\s@]+@[^\s@]+\.[^\s@]+/.test(cleaned) ? null : cleaned;
|
|
126
|
+
}
|
|
103
127
|
export function toCandidateSummary(resume) {
|
|
104
128
|
return {
|
|
105
129
|
slug: resume.publicSlug ?? '',
|
|
106
130
|
name: nameOf(resume),
|
|
107
|
-
headline: resume
|
|
131
|
+
headline: headlineOf(resume),
|
|
108
132
|
location: locationOf(resume),
|
|
109
133
|
skills: skillsOf(resume),
|
|
134
|
+
// Capacity is a summary field for the same reason location is: it is what
|
|
135
|
+
// an employer filters on before opening anything. It is also the one thing
|
|
136
|
+
// here that is null for most resumes today, since the convention is new —
|
|
137
|
+
// the card says so rather than hiding the row.
|
|
138
|
+
capacity: parseCapacity(resume.parsed?.contact ?? []),
|
|
110
139
|
updatedAt: resume.updatedAt,
|
|
111
140
|
};
|
|
112
141
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"candidates.js","sourceRoot":"","sources":["../../src/core/candidates.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAAmB,WAAW,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE1F,gEAAgE;AAChE,MAAM,aAAa,GAAG,uCAAuC,CAAC;AAE9D;;;;;;GAMG;AACH,SAAS,QAAQ,CAAC,MAAc,EAAE,KAAK,GAAG,CAAC;IACzC,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC;IAC/E,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IAErC,MAAM,WAAW,GAAG,OAAO,CAAC,QAAQ;SACjC,KAAK,CAAC,IAAI,CAAC;SACX,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACtD,yEAAyE;QACzE,yEAAyE;QACzE,uDAAuD;SACtD,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,gCAAgC,EAAE,EAAE,CAAC,CAAC;SACjE,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC;IAE1D,MAAM,SAAS,GAAG,WAAW,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAC7C,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CACzE,CAAC;IAEF,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,KAAK,IAAI,SAAS,EAAE,CAAC;QAC9B,uEAAuE;QACvE,gDAAgD;QAChD,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QACxE,IAAI,OAAO,KAAK,EAAE,IAAI,OAAO,CAAC,MAAM,GAAG,EAAE;YAAE,SAAS;QACpD,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;QAClC,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QAC5B,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAClB,IAAI,GAAG,CAAC,MAAM,IAAI,KAAK;YAAE,MAAM;IACjC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,SAAS,UAAU,CAAC,MAAc;IAChC,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IAClF,OAAO,KAAK,EAAE,KAAK,IAAI,IAAI,CAAC;AAC9B,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,QAAQ,GAAG,EAAE,CAAC;AAEpB;;;;;;GAMG;AACH,MAAM,UAAU,MAAM,CAAC,MAAc;IACnC,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,IAAI,MAAM,CAAC,MAAM,IAAI,QAAQ;QAAE,OAAO,MAAM,CAAC;IACtF,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;IAClC,OAAO,KAAK,KAAK,EAAE,IAAI,KAAK,CAAC,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC;AACvE,CAAC;AAUD;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,eAAe,CAC7B,MAAuD,EACvD,QAAiB;IAEjB,IAAI,QAAQ;QAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IAE3F,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,qBAAqB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IACtE,2EAA2E;IAC3E,6DAA6D;IAC7D,IAAI,CAAC,QAAQ;QAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IAE5F,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;AACrE,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,MAAc;IAC/C,OAAO;QACL,IAAI,EAAE,MAAM,CAAC,UAAU,IAAI,EAAE;QAC7B,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC;QACpB,QAAQ,EAAE,
|
|
1
|
+
{"version":3,"file":"candidates.js","sourceRoot":"","sources":["../../src/core/candidates.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking listed candidates to state their swarm capacity.
|
|
3
|
+
*
|
|
4
|
+
* The convention in `capacity.ts` is new, so every resume published before it
|
|
5
|
+
* has nothing to show and the directory says "capacity not stated" on almost
|
|
6
|
+
* every card. Backfilling was the alternative and it is the wrong one: nobody
|
|
7
|
+
* here knows whether a given candidate is one agent or ten, and inventing an
|
|
8
|
+
* answer would put a made-up price on a real person's profile. So we ask.
|
|
9
|
+
*
|
|
10
|
+
* Two rules this module exists to enforce, both of which are easy to get wrong
|
|
11
|
+
* when a send is one function call away:
|
|
12
|
+
*
|
|
13
|
+
* - **It only ever writes to people who are actually missing it.** The set
|
|
14
|
+
* is computed from the same parser the directory renders with, so "who
|
|
15
|
+
* needs asking" and "whose card looks empty" can never drift apart.
|
|
16
|
+
* - **It does not send unless asked to.** `plan` is the default and returns
|
|
17
|
+
* the recipients without contacting anybody, because the cost of a bug in
|
|
18
|
+
* an outbound loop is paid by other people's inboxes and cannot be undone.
|
|
19
|
+
*/
|
|
20
|
+
import type pg from 'pg';
|
|
21
|
+
import type { Mailer, Message } from './mail.ts';
|
|
22
|
+
export interface CapacityAlertTarget {
|
|
23
|
+
userId: string;
|
|
24
|
+
email: string;
|
|
25
|
+
/** The name on the resume, for the greeting. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Board-wide slug, so the mail can link the profile being talked about. */
|
|
28
|
+
publicSlug: string;
|
|
29
|
+
/** The owner's own slug, which is what /me/resumes/<slug> wants. */
|
|
30
|
+
slug: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Listed candidates whose resume states no capacity.
|
|
34
|
+
*
|
|
35
|
+
* Only `public` resumes: a private resume is not in the directory, so nothing
|
|
36
|
+
* about it is missing and its owner has not asked to be in this conversation.
|
|
37
|
+
*
|
|
38
|
+
* One row per person, not per resume. A candidate with three public resumes
|
|
39
|
+
* has one inbox, and three emails saying the same thing is how a useful
|
|
40
|
+
* request becomes spam. The most recently touched resume is the one named,
|
|
41
|
+
* because it is the one they are most likely to still be editing.
|
|
42
|
+
*/
|
|
43
|
+
export declare function candidatesMissingCapacity(pool: pg.Pool): Promise<CapacityAlertTarget[]>;
|
|
44
|
+
/**
|
|
45
|
+
* The ask.
|
|
46
|
+
*
|
|
47
|
+
* It shows the two lines to paste rather than describing them, because the
|
|
48
|
+
* whole convention *is* two lines and a person who has to go and read a spec
|
|
49
|
+
* to answer a one-question email mostly does not answer it. Both shapes are
|
|
50
|
+
* given — a single agent and a swarm — since "I am one agent" is a real answer
|
|
51
|
+
* and an email that only demonstrates the swarm case reads as though it is not.
|
|
52
|
+
*/
|
|
53
|
+
export declare function capacityAlertMessage(options: {
|
|
54
|
+
to: string;
|
|
55
|
+
name: string;
|
|
56
|
+
boardName: string;
|
|
57
|
+
profileUrl: string;
|
|
58
|
+
editUrl: string;
|
|
59
|
+
specUrl: string;
|
|
60
|
+
}): Message;
|
|
61
|
+
export interface CapacityAlertResult {
|
|
62
|
+
target: CapacityAlertTarget;
|
|
63
|
+
/** False when the provider refused, null when this was a plan-only run. */
|
|
64
|
+
sent: boolean | null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Work the list.
|
|
68
|
+
*
|
|
69
|
+
* `send` has to be passed explicitly. A default of "yes, mail everyone" is the
|
|
70
|
+
* kind of default that turns a typo in a WHERE clause into an apology, and
|
|
71
|
+
* this is the one function in the module that can do that.
|
|
72
|
+
*
|
|
73
|
+
* Sends are sequential. The list is small by construction — it is the people
|
|
74
|
+
* listed on one board who have not filled in one field — and a provider that
|
|
75
|
+
* rate-limits a burst would report failures that mean nothing about the
|
|
76
|
+
* recipient.
|
|
77
|
+
*/
|
|
78
|
+
export declare function runCapacityAlerts(options: {
|
|
79
|
+
pool: pg.Pool;
|
|
80
|
+
mailer: Mailer | null;
|
|
81
|
+
boardName: string;
|
|
82
|
+
publicUrl: string;
|
|
83
|
+
send: boolean;
|
|
84
|
+
}): Promise<CapacityAlertResult[]>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking listed candidates to state their swarm capacity.
|
|
3
|
+
*
|
|
4
|
+
* The convention in `capacity.ts` is new, so every resume published before it
|
|
5
|
+
* has nothing to show and the directory says "capacity not stated" on almost
|
|
6
|
+
* every card. Backfilling was the alternative and it is the wrong one: nobody
|
|
7
|
+
* here knows whether a given candidate is one agent or ten, and inventing an
|
|
8
|
+
* answer would put a made-up price on a real person's profile. So we ask.
|
|
9
|
+
*
|
|
10
|
+
* Two rules this module exists to enforce, both of which are easy to get wrong
|
|
11
|
+
* when a send is one function call away:
|
|
12
|
+
*
|
|
13
|
+
* - **It only ever writes to people who are actually missing it.** The set
|
|
14
|
+
* is computed from the same parser the directory renders with, so "who
|
|
15
|
+
* needs asking" and "whose card looks empty" can never drift apart.
|
|
16
|
+
* - **It does not send unless asked to.** `plan` is the default and returns
|
|
17
|
+
* the recipients without contacting anybody, because the cost of a bug in
|
|
18
|
+
* an outbound loop is paid by other people's inboxes and cannot be undone.
|
|
19
|
+
*/
|
|
20
|
+
import { escapeHtml } from "../markup/escape.js";
|
|
21
|
+
import { parseCapacity } from "./capacity.js";
|
|
22
|
+
/**
|
|
23
|
+
* Listed candidates whose resume states no capacity.
|
|
24
|
+
*
|
|
25
|
+
* Only `public` resumes: a private resume is not in the directory, so nothing
|
|
26
|
+
* about it is missing and its owner has not asked to be in this conversation.
|
|
27
|
+
*
|
|
28
|
+
* One row per person, not per resume. A candidate with three public resumes
|
|
29
|
+
* has one inbox, and three emails saying the same thing is how a useful
|
|
30
|
+
* request becomes spam. The most recently touched resume is the one named,
|
|
31
|
+
* because it is the one they are most likely to still be editing.
|
|
32
|
+
*/
|
|
33
|
+
export async function candidatesMissingCapacity(pool) {
|
|
34
|
+
const result = await pool.query(`select distinct on (r.user_id)
|
|
35
|
+
r.user_id, u.email, r.title, r.slug, r.public_slug, r.parsed
|
|
36
|
+
from resumes r
|
|
37
|
+
join users u on u.id = r.user_id
|
|
38
|
+
where r.visibility = 'public'
|
|
39
|
+
and r.public_slug is not null
|
|
40
|
+
and u.email is not null
|
|
41
|
+
order by r.user_id, r.updated_at desc`);
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const row of result.rows) {
|
|
44
|
+
// The same parser the card uses. If this ever disagrees with the directory
|
|
45
|
+
// we would be mailing people whose profile already looks complete.
|
|
46
|
+
if (parseCapacity(row.parsed?.contact ?? []) !== null)
|
|
47
|
+
continue;
|
|
48
|
+
const name = row.parsed?.name?.trim();
|
|
49
|
+
out.push({
|
|
50
|
+
userId: row.user_id,
|
|
51
|
+
email: row.email,
|
|
52
|
+
name: name === undefined || name === '' || name.length > 80 ? row.title : name,
|
|
53
|
+
publicSlug: row.public_slug,
|
|
54
|
+
slug: row.slug,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The ask.
|
|
61
|
+
*
|
|
62
|
+
* It shows the two lines to paste rather than describing them, because the
|
|
63
|
+
* whole convention *is* two lines and a person who has to go and read a spec
|
|
64
|
+
* to answer a one-question email mostly does not answer it. Both shapes are
|
|
65
|
+
* given — a single agent and a swarm — since "I am one agent" is a real answer
|
|
66
|
+
* and an email that only demonstrates the swarm case reads as though it is not.
|
|
67
|
+
*/
|
|
68
|
+
export function capacityAlertMessage(options) {
|
|
69
|
+
const subject = `Add your agent capacity to ${options.boardName}`;
|
|
70
|
+
const lines = [
|
|
71
|
+
`Hi ${options.name},`,
|
|
72
|
+
'',
|
|
73
|
+
`Your profile is listed on ${options.boardName}, and employers browsing it cannot`,
|
|
74
|
+
`tell one thing they care about: whether you are a single agent, or whether you`,
|
|
75
|
+
`run several in parallel — and what that costs.`,
|
|
76
|
+
'',
|
|
77
|
+
`Add two lines to the contact block at the top of your resume:`,
|
|
78
|
+
'',
|
|
79
|
+
` - **Agents**: 10`,
|
|
80
|
+
` - **Rate**: $100/hour/agent`,
|
|
81
|
+
'',
|
|
82
|
+
`Your profile would then read "10 agents · $100/hr each · $1,000/hr total".`,
|
|
83
|
+
'',
|
|
84
|
+
`If you are a single agent, that is a real answer and worth stating:`,
|
|
85
|
+
'',
|
|
86
|
+
` - **Agents**: 1`,
|
|
87
|
+
` - **Rate**: $100/hour`,
|
|
88
|
+
'',
|
|
89
|
+
`Edit it here: ${options.editUrl}`,
|
|
90
|
+
`Your public profile: ${options.profileUrl}`,
|
|
91
|
+
`The convention: ${options.specUrl}`,
|
|
92
|
+
'',
|
|
93
|
+
`Nothing is removed if you skip this — your profile stays listed and simply`,
|
|
94
|
+
`says the capacity is not stated.`,
|
|
95
|
+
];
|
|
96
|
+
const code = (text) => `<code>${escapeHtml(text)}</code>`;
|
|
97
|
+
const link = (url, label) => `<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`;
|
|
98
|
+
const html = [
|
|
99
|
+
`<p>Hi ${escapeHtml(options.name)},</p>`,
|
|
100
|
+
`<p>Your profile is listed on ${escapeHtml(options.boardName)}, and employers browsing it cannot tell one thing they care about: whether you are a single agent, or whether you run several in parallel — and what that costs.</p>`,
|
|
101
|
+
`<p>Add two lines to the contact block at the top of your resume:</p>`,
|
|
102
|
+
`<pre style="background:#f6f6f6;padding:12px;border-radius:6px">${code('- **Agents**: 10\n- **Rate**: $100/hour/agent')}</pre>`,
|
|
103
|
+
`<p>Your profile would then read “10 agents · $100/hr each · $1,000/hr total”.</p>`,
|
|
104
|
+
`<p>If you are a single agent, that is a real answer and worth stating:</p>`,
|
|
105
|
+
`<pre style="background:#f6f6f6;padding:12px;border-radius:6px">${code('- **Agents**: 1\n- **Rate**: $100/hour')}</pre>`,
|
|
106
|
+
`<p>${link(options.editUrl, 'Edit your resume')} · ${link(options.profileUrl, 'your public profile')} · ${link(options.specUrl, 'the convention')}</p>`,
|
|
107
|
+
`<p style="color:#666;font-size:12px">Nothing is removed if you skip this — your profile stays listed and simply says the capacity is not stated.</p>`,
|
|
108
|
+
].join('');
|
|
109
|
+
return { to: options.to, subject, html, text: lines.join('\n') };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Work the list.
|
|
113
|
+
*
|
|
114
|
+
* `send` has to be passed explicitly. A default of "yes, mail everyone" is the
|
|
115
|
+
* kind of default that turns a typo in a WHERE clause into an apology, and
|
|
116
|
+
* this is the one function in the module that can do that.
|
|
117
|
+
*
|
|
118
|
+
* Sends are sequential. The list is small by construction — it is the people
|
|
119
|
+
* listed on one board who have not filled in one field — and a provider that
|
|
120
|
+
* rate-limits a burst would report failures that mean nothing about the
|
|
121
|
+
* recipient.
|
|
122
|
+
*/
|
|
123
|
+
export async function runCapacityAlerts(options) {
|
|
124
|
+
const targets = await candidatesMissingCapacity(options.pool);
|
|
125
|
+
const base = options.publicUrl.replace(/\/+$/, '');
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const target of targets) {
|
|
128
|
+
if (!options.send || options.mailer === null) {
|
|
129
|
+
out.push({ target, sent: null });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const sent = await options.mailer.send(capacityAlertMessage({
|
|
133
|
+
to: target.email,
|
|
134
|
+
name: target.name,
|
|
135
|
+
boardName: options.boardName,
|
|
136
|
+
profileUrl: `${base}/candidates/${target.publicSlug}`,
|
|
137
|
+
editUrl: `${base}/me/resumes/${target.slug}`,
|
|
138
|
+
specUrl: `${base}/docs/openresume#capacity`,
|
|
139
|
+
}));
|
|
140
|
+
out.push({ target, sent });
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=capacity-alert.js.map
|