qiksy 1.1.0 → 1.1.2
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/tools/client-finder/vendor/lib/jobs.mjs +7 -1
- package/tools/client-finder/vendor/lib/prompts.mjs +111 -0
- package/tools/client-finder/vendor/server.mjs +124 -13
- package/tools/travel/vendor/lib/license.mjs +1 -1
- package/tools/travel/vendor/server.mjs +51 -5
- package/tools/work-finder/vendor/server.mjs +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
308
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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
|
|
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);
|
|
@@ -652,7 +696,9 @@ const server = createServer(async (req, res) => {
|
|
|
652
696
|
|
|
653
697
|
if (req.method === 'PUT' && !seg[3]) {
|
|
654
698
|
if (!['draft', 'options'].includes(t.status)) {
|
|
655
|
-
|
|
699
|
+
// A machine code, not Russian prose: the client renders it in the reader's
|
|
700
|
+
// own language (EN/UK users were shown raw Russian). Audit 2026-07-26.
|
|
701
|
+
return json(res, 400, { error: `TRIP_LOCKED:${t.status}` });
|
|
656
702
|
}
|
|
657
703
|
const b = await readBody(req);
|
|
658
704
|
for (const k of ['title', 'from', 'to', 'departDate', 'returnDate', 'meetingAt', 'budget', 'criteria']) {
|
|
@@ -684,7 +730,7 @@ const server = createServer(async (req, res) => {
|
|
|
684
730
|
const sb = await readBody(req).catch(() => ({}));
|
|
685
731
|
if (LANGS.includes(sb.lang)) t.lang = sb.lang;
|
|
686
732
|
if (!['draft', 'options'].includes(t.status)) {
|
|
687
|
-
return json(res, 400, { error:
|
|
733
|
+
return json(res, 400, { error: `SEARCH_LOCKED:${t.status}` });
|
|
688
734
|
}
|
|
689
735
|
if (!clientWorkAllowed(t)) return json(res, 402, { error: 'PLAN_REQUIRED' });
|
|
690
736
|
if (!tripTravelers(t).length) return json(res, 400, { error: 'выберите хотя бы одного путника из команды' });
|
|
@@ -705,7 +751,7 @@ const server = createServer(async (req, res) => {
|
|
|
705
751
|
if (!Number.isInteger(num)) return json(res, 400, { error: 'нужен номер варианта: { "n": 2 }' });
|
|
706
752
|
// из ready_to_pay тоже можно — так оформляется связка (второй вариант следом)
|
|
707
753
|
if (!['options', 'ready_to_pay'].includes(t.status)) {
|
|
708
|
-
return json(res, 400, { error:
|
|
754
|
+
return json(res, 400, { error: `BOOK_LOCKED:${t.status}` });
|
|
709
755
|
}
|
|
710
756
|
if (!clientWorkAllowed(t)) return json(res, 402, { error: 'PLAN_REQUIRED' });
|
|
711
757
|
if (!t.options.some(o => o.n === num)) return json(res, 400, { error: `варианта №${num} нет в подборе` });
|
|
@@ -719,7 +765,7 @@ const server = createServer(async (req, res) => {
|
|
|
719
765
|
}
|
|
720
766
|
if (req.method === 'POST' && seg[3] === 'paid') {
|
|
721
767
|
if (t.status !== 'ready_to_pay') {
|
|
722
|
-
return json(res, 400, { error:
|
|
768
|
+
return json(res, 400, { error: `PAID_LOCKED:${t.status}` });
|
|
723
769
|
}
|
|
724
770
|
t.status = 'paid';
|
|
725
771
|
addActivity(t, 'HR подтвердила оплату — генерирую письмо, смету и чеклист');
|
|
@@ -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' });
|