qiksy 1.1.0 → 1.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Qiksy \u2014 one local engine for every Qiksy tool (Work Finder, Travel Assistant, Client Finder). Runs on your own Claude; nothing leaves your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,12 @@ export function createJob(input) {
23
23
  had. Parsing log lines instead would leave the header one rewording away from
24
24
  lying again. */
25
25
  phase: 'starting', // starting | searching | verifying | done
26
- input, // { productId, channels, brief, count, model }
26
+ /* { productId, kind, channels, brief, count, model, seeds? }
27
+ `kind` is which phase this is — 'scout' (wide, cheap, WebSearch only),
28
+ 'dossier' (deep, on `seeds`) or 'full' (the old one-phase run). It decides
29
+ the prompt AND the tool list, so it is part of the run's identity rather
30
+ than a display flag. */
31
+ input,
27
32
  log: [],
28
33
  prospects: [],
29
34
  error: null,
@@ -53,6 +58,7 @@ export const listJobs = () =>
53
58
  id: j.id,
54
59
  status: j.status,
55
60
  brief: j.input.brief,
61
+ kind: j.input.kind || 'full',
56
62
  productId: j.input.productId,
57
63
  found: j.prospects.length,
58
64
  startedAt: j.startedAt,
@@ -43,7 +43,118 @@ export function briefToIcp({ product, brief, channels = [], language }) {
43
43
  };
44
44
  }
45
45
 
46
+ /**
47
+ * SCOUTING — the cheap half of a two-phase search.
48
+ *
49
+ * The expensive part of qualifying a prospect is not the thinking, it is the
50
+ * READING: a full dossier opens the homepage, the contact page and a product page,
51
+ * which is where the 50–150K input tokens per lead go. Paying that for candidates
52
+ * the seller would have crossed off at a glance is the single biggest waste in the
53
+ * old one-phase run — and it also meant five minutes of blank screen before the
54
+ * first name appeared.
55
+ *
56
+ * So scouting is capped by TOOL, not by instruction: the runner hands this pass
57
+ * `WebSearch` and nothing else. Without WebFetch the agent physically cannot walk
58
+ * sites, so "just check one page quickly" is not a temptation it can act on. What
59
+ * comes back is a name, a site and one honest line of why it might fit — enough to
60
+ * cross off the obvious misses, and nothing that pretends to be proof.
61
+ *
62
+ * `verified` still gets filled in for these rows, by lib/verify.mjs — plain HTTP,
63
+ * no model, no tokens. So a dead site is visible before anyone spends a cent on it.
64
+ */
65
+ const SCOUT_SCHEMA = `{
66
+ "company": "name as written",
67
+ "url": "their own site, exactly as it appears in the result — never reconstructed",
68
+ "country": "ISO-2 if visible, else null",
69
+ "what_they_do": "one short line",
70
+ "why_maybe": "why this MIGHT fit the brief — a guess from the search result, stated as a guess",
71
+ "found_at": "the search result or listing page this came from"
72
+ }`;
73
+
74
+ export function scoutPrompt(/** @type {Icp} */ icp, count) {
75
+ return `${SCOPE_LOCK}
76
+
77
+ You are SCOUTING — a fast, wide first pass. You produce a shortlist for a human to triage, not proof.
78
+
79
+ # The product you are finding buyers for
80
+ ${icp.product.name} — ${icp.product.pitch}
81
+
82
+ # Who counts as a candidate
83
+ ${icp.who}
84
+
85
+ # How to work
86
+ 1. Search the public web: directories, marketplaces, "best X in Y" lists, industry catalogues, local business media, maps listings.
87
+ 2. Vary the query. Repeating one phrasing returns one slice of the market — search in the LOCAL LANGUAGE of the geography named in the brief as well as in English.
88
+ 3. Judge from the search results alone. You have no page-fetching tool in this pass, and that is deliberate: this is the cheap pass.
89
+ 4. Take the URL exactly as it appears. Never reconstruct or guess a URL — a guessed link is a fabricated source, and the next phase will fetch it for real.
90
+ 5. Skip aggregators, directories and marketplaces themselves. You want the businesses listed ON them.
91
+
92
+ # Honesty
93
+ "why_maybe" is a GUESS and must read like one. You have not opened their site, so you cannot claim their site lacks a chat, or that they use Shopify, unless the search result itself says so. Do not dress a guess as a finding — the seller decides what to pay to verify, and a confident-sounding guess corrupts that decision.
94
+
95
+ # Output
96
+ For each candidate, emit ONE line, nothing else on it:
97
+ PROSPECT: {json}
98
+ using exactly this shape:
99
+ ${SCOUT_SCHEMA}
100
+
101
+ Find up to ${count}. Stop early rather than padding the list with candidates that plainly do not fit the brief.
102
+ When done, output a final JSON array of everything you emitted in a \`\`\`json fence, then one sentence on where you searched and what you would look at next.`;
103
+ }
104
+
105
+ /**
106
+ * The DOSSIER pass — the expensive half, aimed only at what the seller ticked.
107
+ *
108
+ * Same evidence contract the one-phase run always had, with one addition that
109
+ * matters more than it looks: a candidate may come back REJECTED. Scouting guesses;
110
+ * this pass is where a guess is allowed to die, with a reason. A tool that silently
111
+ * dropped the ones that failed would look like it lost them, and the seller would
112
+ * never learn which of their own picks were wrong.
113
+ */
114
+ export function dossierPrompt(/** @type {Icp} */ icp, prospects) {
115
+ const list = prospects
116
+ .map((p, i) => `${i + 1}. ${p.company} — ${p.url}${p.what_they_do ? ` (${p.what_they_do})` : ''}`)
117
+ .join('\n');
118
+ return `${SCOPE_LOCK}
119
+
120
+ You are building DOSSIERS on candidates a human already picked. You do not search for new ones — this exact list, nothing added.
121
+
122
+ # The product you are finding buyers for
123
+ ${icp.product.name} — ${icp.product.pitch}${icp.product.url ? `\nProduct page: ${icp.product.url}` : ''}
124
+
125
+ # The brief they were picked against
126
+ ${icp.who}
127
+
128
+ Must be true (every one of these):
129
+ ${icp.must.map((m) => `- ${m}`).join('\n')}
130
+ ${icp.mustNot?.length ? `\nMust NOT be true (check each and say you checked):\n${icp.mustNot.map((m) => `- ${m}`).join('\n')}` : ''}
131
+
132
+ # The candidates
133
+ ${list}
134
+
135
+ # How to work
136
+ 1. OPEN each one's own site with WebFetch. A search snippet is not evidence.
137
+ 2. Check every "must" against something you actually saw on a page. Cite the page each claim lives on, not the homepage for all of them.
138
+ 3. For "must not", say explicitly what you checked and did not find.
139
+ 4. Find a contact route and a decision-maker only if the site shows them. Never guess an email address; an inferred pattern is not a contact.
140
+ 5. Use URLs character for character as you fetched them. Do not reconstruct a page URL from a title — a guessed URL is a fabricated source, and it will be checked.
141
+
142
+ # A candidate is allowed to fail
143
+ If the site shows they do NOT match, return them with "verdict":"rejected" and say why in "why_not". That is a useful answer, not a failure — the human picked them from a guess and needs to know the guess was wrong. Never quietly drop a candidate from the list.
144
+ If the site will not load at all, return "verdict":"unreachable" rather than judging it from search results.
145
+
146
+ # Output
147
+ As soon as one is finished, emit ONE line, nothing else on it:
148
+ PROSPECT: {json}
149
+ using exactly this shape:
150
+ ${SCHEMA}
151
+
152
+ Do all ${prospects.length}. When done, output a final JSON array of all of them in a \`\`\`json fence.`;
153
+ }
154
+
46
155
  const SCHEMA = `{
156
+ "verdict": "qualified | rejected | unreachable",
157
+ "why_not": "only when rejected or unreachable — one line, what the page showed",
47
158
  "company": "legal or trading name",
48
159
  "url": "the company's own site (homepage)",
49
160
  "country": "ISO-2, e.g. PL",
@@ -18,7 +18,7 @@ import { writeFileSync, mkdirSync } from 'node:fs';
18
18
  import { resolve, dirname } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { runClaude, extractJson } from './lib/agent.mjs';
21
- import { researchPrompt, briefToIcp, draftPrompt } from './lib/prompts.mjs';
21
+ import { researchPrompt, scoutPrompt, dossierPrompt, briefToIcp, draftPrompt } from './lib/prompts.mjs';
22
22
  import { checkBrief } from './lib/scope.mjs';
23
23
  import { PRODUCTS, byId } from './lib/catalog.mjs';
24
24
  import { CHANNELS, needsBrowser } from './lib/channels.mjs';
@@ -68,15 +68,38 @@ const readBody = (req) =>
68
68
  });
69
69
  });
70
70
 
71
- /* ── the run ──────────────────────────────────────────────────────────────── */
71
+ /* ── the run ──────────────────────────────────────────────────────────────────
72
+ * TWO PHASES, and the split is about money and about the first minute.
73
+ *
74
+ * The expensive part of qualifying a prospect is READING pages — that is where the
75
+ * 50–150K input tokens per lead go. A one-phase run paid that for every candidate,
76
+ * including the ones the seller would have crossed off at a glance, and showed
77
+ * nothing at all for the first several minutes.
78
+ *
79
+ * SCOUT wide and cheap. WebSearch ONLY — the tool list, not the wording, is
80
+ * what keeps it cheap: without WebFetch the agent physically cannot walk
81
+ * sites. Names and sites land in seconds.
82
+ * DOSSIER narrow and expensive, aimed only at what the seller ticked. WebFetch
83
+ * is back, and so is the full evidence contract.
84
+ *
85
+ * Link verification runs in BOTH: it is plain HTTP, no model, no tokens — so a dead
86
+ * site is visible on a scout row before anyone pays to look into it.
87
+ */
72
88
 
73
89
  async function execute(job) {
74
90
  const product = byId(job.input.productId);
75
91
  const icp = briefToIcp({ product, brief: job.input.brief, channels: job.input.channels });
92
+ const seeds = job.input.seeds;
93
+ const isDossier = job.input.kind === 'dossier';
94
+ const isScout = job.input.kind === 'scout';
76
95
 
77
96
  job.phase = 'searching';
78
- log(job, `Ищу покупателей для «${product.name}»`);
79
- if (needsBrowser(job.input.channels)) {
97
+ if (isDossier) {
98
+ log(job, `Собираю досье: ${seeds.length} кандидат(ов), открываю их сайты`);
99
+ } else {
100
+ log(job, isScout ? `Разведка: ищу кандидатов для «${product.name}»` : `Ищу покупателей для «${product.name}»`);
101
+ }
102
+ if (!isDossier && needsBrowser(job.input.channels)) {
80
103
  // Browser channels are declared but not wired yet: say so out loud rather than
81
104
  // silently searching the open web and letting the picked channel imply more
82
105
  // than happened.
@@ -84,7 +107,15 @@ async function execute(job) {
84
107
  }
85
108
 
86
109
  const res = await runClaude({
87
- prompt: researchPrompt(icp, job.input.count),
110
+ prompt: isDossier
111
+ ? dossierPrompt(icp, seeds)
112
+ : isScout
113
+ ? scoutPrompt(icp, job.input.count)
114
+ : researchPrompt(icp, job.input.count),
115
+ /* The cap that makes scouting cheap is the TOOL LIST, not the prompt. Take
116
+ WebFetch away and "let me just open one page to be sure" stops being a thing
117
+ the agent can do — which is the whole economic point of the phase. */
118
+ allowedTools: isScout ? ['WebSearch'] : ['WebSearch', 'WebFetch'],
88
119
  model: job.input.model,
89
120
  apiKey: job.input.apiKey,
90
121
  signal: job.controller.signal,
@@ -96,8 +127,12 @@ async function execute(job) {
96
127
  about it. On a product whose landing sells "проверенные ссылки" that is the
97
128
  worst possible silence. Pure HTTP: no model, no tokens. */
98
129
  onPartial: (p) => {
130
+ // How deeply this row was checked, recorded by the phase that produced it —
131
+ // never inferred from which fields happen to be filled. The panel gates
132
+ // "these are proven" on this and nothing else.
133
+ p.depth = isScout ? 'scout' : 'full';
99
134
  job.prospects.push(p);
100
- log(job, `✅ ${job.prospects.length}. ${p.company}`);
135
+ log(job, `${isScout ? '👁' : '✅'} ${job.prospects.length}. ${p.company}`);
101
136
  void verifyProspect(p)
102
137
  .then((v) => Object.assign(p, { verified: v.verified }))
103
138
  .catch(() => {
@@ -147,6 +182,19 @@ async function execute(job) {
147
182
  const alreadyKnown = new Map(job.prospects.filter((p) => p.verified).map((p) => [p.url, p.verified]));
148
183
  for (const p of prospects) {
149
184
  if (!p.verified && alreadyKnown.has(p.url)) p.verified = alreadyKnown.get(p.url);
185
+ if (!p.depth) p.depth = isScout ? 'scout' : 'full';
186
+ }
187
+
188
+ /* A dossier answers about a FIXED list, so anything the agent silently left out is
189
+ a hole in the seller's board — they ticked that row and would be left staring at
190
+ a scout entry that never changed. Missing ones come back as unreachable, which
191
+ is the honest word for "we cannot say". */
192
+ if (isDossier) {
193
+ const answered = new Set(prospects.map((p) => p.url));
194
+ for (const seed of seeds) {
195
+ if (answered.has(seed.url)) continue;
196
+ prospects.push({ ...seed, depth: 'full', verdict: 'unreachable', why_not: 'Агент не вернул досье по этому кандидату' });
197
+ }
150
198
  }
151
199
 
152
200
  job.phase = 'verifying';
@@ -273,6 +321,10 @@ const server = createServer(async (req, res) => {
273
321
 
274
322
  const job = createJob({
275
323
  productId: product.id,
324
+ /* Scouting is the default entry point: cheap, wide, and on screen in
325
+ seconds. `full` stays reachable for someone who knows their brief is
326
+ tight enough to pay for depth straight away. */
327
+ kind: body.mode === 'full' ? 'full' : 'scout',
276
328
  channels: Array.isArray(body.channels) && body.channels.length ? body.channels : ['web'],
277
329
  brief: String(body.brief).trim(),
278
330
  /* Only a REAL key travels on. Anything else — a placeholder from a stale
@@ -294,6 +346,56 @@ const server = createServer(async (req, res) => {
294
346
  }
295
347
 
296
348
 
349
+ /* ── dossier: phase two ───────────────────────────────────────────────────
350
+ * The seller ticked some scout rows; this is where those — and ONLY those —
351
+ * get their pages opened and their evidence collected.
352
+ *
353
+ * It takes the candidates in the REQUEST rather than reading them out of a
354
+ * job id, and that is deliberate: runs live in memory here and in the browser's
355
+ * storage over there, so a backend restart would otherwise turn every board
356
+ * older than the process into a dead "collect dossier" button. The panel owns
357
+ * the list, this endpoint owns the work.
358
+ */
359
+ if (req.method === 'POST' && path === '/api/dossier') {
360
+ const body = await readBody(req);
361
+ const access = await checkPartner(body.partner, body.account);
362
+ if (!access.ok) return json(res, 403, { reason: 'Доступ только для партнёров', hint: access.reason });
363
+ const product = byId(body.productId);
364
+ if (!product) return json(res, 400, { error: 'Выберите продукт из списка' });
365
+
366
+ const seeds = (Array.isArray(body.prospects) ? body.prospects : [])
367
+ .map((p) => ({
368
+ company: String(p?.company || '').slice(0, 200),
369
+ url: String(p?.url || '').slice(0, 500),
370
+ what_they_do: String(p?.what_they_do || '').slice(0, 300),
371
+ }))
372
+ .filter((p) => p.company && /^https?:\/\//i.test(p.url))
373
+ // One page-walking agent over twenty companies is already a long run; past
374
+ // that the seller is better served by a second batch they can watch.
375
+ .slice(0, 20);
376
+ if (!seeds.length) return json(res, 400, { error: 'Некого проверять — отметьте кандидатов' });
377
+
378
+ const guard = checkBrief(body.brief);
379
+ if (!guard.ok) return json(res, 422, guard);
380
+
381
+ const job = createJob({
382
+ productId: product.id,
383
+ kind: 'dossier',
384
+ seeds,
385
+ channels: Array.isArray(body.channels) && body.channels.length ? body.channels : ['web'],
386
+ brief: String(body.brief).trim(),
387
+ apiKey: typeof body.apiKey === 'string' && isRealKey(body.apiKey) ? body.apiKey : '',
388
+ count: seeds.length,
389
+ model: body.model === 'opus' ? 'opus' : 'sonnet',
390
+ });
391
+ execute(job).catch((e) => {
392
+ job.status = 'failed';
393
+ job.error = e.message;
394
+ job.finishedAt = Date.now();
395
+ });
396
+ return json(res, 200, view(job));
397
+ }
398
+
297
399
  /* ── drafts ───────────────────────────────────────────────────────────────
298
400
  * A separate, cheap pass over prospects we already verified: no web tools, no
299
401
  * second search. That also makes re-drafting the same list for another
@@ -303,13 +405,22 @@ const server = createServer(async (req, res) => {
303
405
  const body = await readBody(req);
304
406
  const access = await checkPartner(body.partner, body.account);
305
407
  if (!access.ok) return json(res, 403, { error: access.reason });
408
+ /* The panel may send the candidates outright, and when it does they WIN over
409
+ anything held here. Two reasons, both of which used to bite: runs live in
410
+ memory on this side, so a restart made every older board unwritable; and a
411
+ row enriched by a dossier lives in the panel's storage under its original
412
+ run, so reading it back from that run's job would draft from the thin scout
413
+ version of a prospect the seller already paid to prove. */
306
414
  const job = getJob(body.jobId);
307
- if (!job) return json(res, 404, { error: 'нет такого прогона' });
308
- const product = byId(job.input.productId);
415
+ const sent = Array.isArray(body.prospects) ? body.prospects.filter((p) => p?.company && p?.url) : [];
416
+ if (!job && !sent.length) return json(res, 404, { error: 'нет такого прогона' });
417
+ const product = byId(body.productId ?? job?.input.productId);
418
+ if (!product) return json(res, 400, { error: 'Выберите продукт из списка' });
309
419
  const format = formatById(body.format);
310
- const picked = Array.isArray(body.indexes) && body.indexes.length
311
- ? body.indexes.map((i) => job.prospects[i]).filter(Boolean)
312
- : job.prospects;
420
+ const pool = sent.length ? sent : job.prospects;
421
+ const picked = Array.isArray(body.indexes) && body.indexes.length && !sent.length
422
+ ? body.indexes.map((i) => pool[i]).filter(Boolean)
423
+ : pool;
313
424
  if (!picked.length) return json(res, 400, { error: 'нечего писать — список пуст' });
314
425
 
315
426
  const out = await runClaude({
@@ -321,13 +432,13 @@ const server = createServer(async (req, res) => {
321
432
  language: body.language,
322
433
  platform: body.platform,
323
434
  }),
324
- model: job.input.model,
435
+ model: body.model === 'opus' ? 'opus' : job?.input.model || 'sonnet',
325
436
  /* Same rule, and the job's own key as the fallback — it was filtered by the
326
437
  same check when the run started. */
327
438
  apiKey:
328
439
  typeof body.apiKey === 'string' && isRealKey(body.apiKey)
329
440
  ? body.apiKey
330
- : job.input.apiKey || '',
441
+ : job?.input.apiKey || '',
331
442
  // Drafting reads what we already proved; giving it the web again would
332
443
  // invite it to "check something" and pay for a second research run.
333
444
  allowedTools: [],
@@ -4,7 +4,7 @@
4
4
  * Local-first: paid tiers are unlocked by a key validated HERE, not by the plan
5
5
  * stored in the user's own db.json. Best-effort + offline-grace cache.
6
6
  */
7
- const LICENSE_API = (process.env.QIKSY_LICENSE_API || 'https://qiksy-licenses.qiksy.workers.dev').replace(/\/+$/, '');
7
+ export const LICENSE_API = (process.env.QIKSY_LICENSE_API || 'https://qiksy-licenses.qiksy.workers.dev').replace(/\/+$/, '');
8
8
  const PRODUCT = 'travel';
9
9
  export const RECHECK_MS = 6 * 3600 * 1000;
10
10
  export const OFFLINE_GRACE_MS = 7 * 86400 * 1000;
@@ -18,7 +18,7 @@ import { buildSearchPrompt, buildBookPrompt, buildFinalizePrompt } from './lib/p
18
18
  import { createJob, jobLog, jobStep, listJobs, getJob, cancelJob, cancelActiveByTrip } from './lib/jobs.mjs';
19
19
  import { ensureBrowser } from './scripts/launch-browser.mjs';
20
20
  import { PLANS, SELF_SETTABLE_PLANS } from './lib/plans.mjs';
21
- import { verifyLicense, licensePlanId, RECHECK_MS } from './lib/license.mjs';
21
+ import { verifyLicense, licensePlanId, RECHECK_MS, LICENSE_API } from './lib/license.mjs';
22
22
  import { probeEngine, startLogin, submitLoginCode } from './lib/engine.mjs';
23
23
  import { buildProposalHtml } from './lib/proposal.mjs';
24
24
 
@@ -587,6 +587,50 @@ const server = createServer(async (req, res) => {
587
587
  return json(res, 200, db.settings);
588
588
  }
589
589
 
590
+ /* Лицензия по АККАУНТУ: тот, кто вошёл в студию, не должен копировать ключ из
591
+ письма — его почта уже подтверждена, а воркер по этой почте знает ключ (решение
592
+ владельца 26.07.2026). Фронт присылает ref_code+token, которые студия и так
593
+ раздаёт приложениям; воркер валидирует их так же, как /account/me. */
594
+ if (req.method === 'POST' && path === '/api/license/auto') {
595
+ const { ref_code, token } = await readBody(req);
596
+ if (!ref_code || !token) return json(res, 400, { error: 'нужны ref_code и token' });
597
+ // Уже есть рабочий ключ — не трогаем: человек мог ввести другой руками.
598
+ if (db.settings.license && licensePlanId(db.settings.licenseInfo) !== 'free') {
599
+ return json(res, 200, { ok: true, already: true, billing: billingInfo() });
600
+ }
601
+ try {
602
+ const r = await fetch(
603
+ `${LICENSE_API}/licenses/mine?ref_code=${encodeURIComponent(ref_code)}&token=${encodeURIComponent(token)}&product=travel`,
604
+ { signal: AbortSignal.timeout(6000) }
605
+ );
606
+ const j = await r.json().catch(() => ({}));
607
+ if (!j.key) return json(res, 200, { ok: true, key: '', billing: billingInfo() });
608
+ db.settings.license = j.key;
609
+ db.settings.licenseInfo = null;
610
+ save(db);
611
+ await refreshLicense();
612
+ return json(res, 200, { ok: true, key: j.key.slice(0, 6) + '…', billing: billingInfo() });
613
+ } catch {
614
+ return json(res, 200, { ok: false, billing: billingInfo() });
615
+ }
616
+ }
617
+
618
+ /* Ключ ушёл в письмо, письмо потерялось — попросить его ещё раз. Воркер отвечает
619
+ 200 всегда (не раскрывает, есть ли лицензия у этой почты). */
620
+ if (req.method === 'POST' && path === '/api/license/resend') {
621
+ const { email } = await readBody(req);
622
+ if (!email) return json(res, 400, { error: 'нужен email' });
623
+ try {
624
+ await fetch(`${LICENSE_API}/resend`, {
625
+ method: 'POST',
626
+ headers: { 'Content-Type': 'application/json' },
627
+ body: JSON.stringify({ email, product: 'travel' }),
628
+ signal: AbortSignal.timeout(6000),
629
+ });
630
+ } catch { /* сеть — фронт всё равно скажет «если ключ есть, письмо придёт» */ }
631
+ return json(res, 200, { ok: true });
632
+ }
633
+
590
634
  // ── license: ввести/сменить/убрать ключ и сразу проверить у воркера ──
591
635
  if (req.method === 'POST' && path === '/api/license') {
592
636
  const { key } = await readBody(req);
@@ -839,6 +839,20 @@ export const requestHandler = async (req, res) => {
839
839
  logCost('Сбор профиля из рассказа', r.costUsd);
840
840
  return json(res, 200, { profile: parsed, gaps: Array.isArray(parsed.gaps) ? parsed.gaps : [], costUsd: r.costUsd });
841
841
  }
842
+ /* The builder's other three AI actions (parse a pasted résumé, write a summary,
843
+ polish it) used to call Anthropic straight from the browser with a key kept in
844
+ localStorage. That key cannot exist any more — API_KEY_LOGIN is off and there is
845
+ no field to enter one — so all three were dead for every user while the fourth,
846
+ which comes through here, worked. Same route for all of them now. */
847
+ if (req.method === 'POST' && path === '/api/builder/text') {
848
+ const b = await readBody(req);
849
+ const prompt = (b.prompt || '').trim();
850
+ if (!prompt) return json(res, 400, { error: 'empty prompt' });
851
+ const r = await runClaude({ prompt, model: db.settings.model });
852
+ if (!r.ok) return json(res, 502, { error: r.error || 'claude failed' });
853
+ logCost(b.label || 'Конструктор резюме', r.costUsd);
854
+ return json(res, 200, { text: r.result || '', costUsd: r.costUsd });
855
+ }
842
856
  if (req.method === 'PATCH' && seg[1] === 'positions' && seg[2]) {
843
857
  const p = db.positions.find(x => x.id === seg[2]);
844
858
  if (!p) return json(res, 404, { error: 'no such position' });