qiksy 1.0.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/bin/qiksy.mjs +271 -8
- package/package.json +2 -2
- 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/lib/store.mjs +5 -0
- package/tools/travel/vendor/server.mjs +63 -1
- package/tools/work-finder/vendor/server.mjs +14 -0
package/bin/qiksy.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Everything runs on the user's machine, on their own Claude. Nothing is uploaded.
|
|
14
14
|
*/
|
|
15
15
|
import { spawn, spawnSync } from 'node:child_process';
|
|
16
|
-
import { existsSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
17
|
import { homedir } from 'node:os';
|
|
18
18
|
import { dirname, join, resolve } from 'node:path';
|
|
19
19
|
import { platform } from 'node:process';
|
|
@@ -140,6 +140,28 @@ async function isUp(port) {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Who holds this port: nobody, our own tool, or a stranger.
|
|
145
|
+
*
|
|
146
|
+
* Running the installer twice is the most likely thing a non-technical person does —
|
|
147
|
+
* and before this check the second run printed green ticks (the FIRST engine was
|
|
148
|
+
* answering) followed by a raw EADDRINUSE stack trace, which reads as "it broke".
|
|
149
|
+
* All three tools answer /api/state with JSON, so that is the fingerprint.
|
|
150
|
+
*/
|
|
151
|
+
async function whoHolds(port) {
|
|
152
|
+
try {
|
|
153
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/state`, { signal: AbortSignal.timeout(1500) });
|
|
154
|
+
if (r.ok) {
|
|
155
|
+
const ct = r.headers.get('content-type') || '';
|
|
156
|
+
if (ct.includes('json')) return 'ours';
|
|
157
|
+
}
|
|
158
|
+
return 'stranger';
|
|
159
|
+
} catch {
|
|
160
|
+
/* nothing answered on HTTP — but something may still hold the socket */
|
|
161
|
+
}
|
|
162
|
+
return pidsOn(port).length ? 'stranger' : 'free';
|
|
163
|
+
}
|
|
164
|
+
|
|
143
165
|
async function cmdStatus() {
|
|
144
166
|
console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — status')}\n`);
|
|
145
167
|
let anyUp = false;
|
|
@@ -191,9 +213,178 @@ async function cmdDoctor() {
|
|
|
191
213
|
console.log(c.dim(' Your work is kept in the data folder above — restarting never deletes it.\n'));
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
/* ══ Autostart ════════════════════════════════════════════════════════════════
|
|
217
|
+
Without this the engine lives exactly as long as the window Claude started it in
|
|
218
|
+
— close the laptop lid, reboot, and the tools "stop working" for reasons the
|
|
219
|
+
person cannot see. `install` registers it with the OS so it comes back on its own;
|
|
220
|
+
`uninstall` takes it back out. Everything is per-user: no sudo, no system files. */
|
|
221
|
+
|
|
222
|
+
const AGENT_LABEL = 'app.qiksy.engine';
|
|
223
|
+
|
|
224
|
+
function autostartPaths() {
|
|
225
|
+
const home = homedir();
|
|
226
|
+
return {
|
|
227
|
+
mac: join(home, 'Library', 'LaunchAgents', `${AGENT_LABEL}.plist`),
|
|
228
|
+
linux: join(home, '.config', 'systemd', 'user', 'qiksy.service'),
|
|
229
|
+
win: join(process.env.APPDATA || home, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'qiksy.cmd'),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The command the OS should run. Prefer this package's own entry over `npx`, which
|
|
234
|
+
* needs the network and a cache that gets cleaned. */
|
|
235
|
+
function selfCommand() {
|
|
236
|
+
return { node: process.execPath, script: join(PKG, 'bin', 'qiksy.mjs') };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function autostartInstalled() {
|
|
240
|
+
const p = autostartPaths();
|
|
241
|
+
try {
|
|
242
|
+
if (platform === 'darwin') return existsSync(p.mac);
|
|
243
|
+
if (isWin) return existsSync(p.win);
|
|
244
|
+
return existsSync(p.linux);
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function cmdInstall({ quiet = false } = {}) {
|
|
251
|
+
const p = autostartPaths();
|
|
252
|
+
const { node, script } = selfCommand();
|
|
253
|
+
const say = (line) => { if (!quiet) console.log(line); };
|
|
254
|
+
say(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — start automatically')}\n`);
|
|
255
|
+
try {
|
|
256
|
+
if (platform === 'darwin') {
|
|
257
|
+
mkdirSync(dirname(p.mac), { recursive: true });
|
|
258
|
+
writeFileSync(
|
|
259
|
+
p.mac,
|
|
260
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
261
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
262
|
+
<plist version="1.0"><dict>
|
|
263
|
+
<key>Label</key><string>${AGENT_LABEL}</string>
|
|
264
|
+
<key>ProgramArguments</key><array><string>${node}</string><string>${script}</string></array>
|
|
265
|
+
<key>EnvironmentVariables</key><dict><key>QIKSY_NO_OPEN</key><string>1</string></dict>
|
|
266
|
+
<key>RunAtLoad</key><true/>
|
|
267
|
+
<key>KeepAlive</key><true/>
|
|
268
|
+
<key>StandardOutPath</key><string>${join(DATA_HOME, 'engine.log')}</string>
|
|
269
|
+
<key>StandardErrorPath</key><string>${join(DATA_HOME, 'engine.log')}</string>
|
|
270
|
+
</dict></plist>\n`
|
|
271
|
+
);
|
|
272
|
+
spawnSync('launchctl', ['unload', p.mac], { stdio: 'ignore' });
|
|
273
|
+
spawnSync('launchctl', ['load', p.mac], { stdio: 'ignore' });
|
|
274
|
+
say(c.green(' ✓ Qiksy will start by itself when you log in.'));
|
|
275
|
+
} else if (isWin) {
|
|
276
|
+
mkdirSync(dirname(p.win), { recursive: true });
|
|
277
|
+
writeFileSync(p.win, `@echo off\r\nstart "" /min "${node}" "${script}"\r\n`);
|
|
278
|
+
say(c.green(' ✓ Qiksy will start by itself when you sign in.'));
|
|
279
|
+
} else {
|
|
280
|
+
mkdirSync(dirname(p.linux), { recursive: true });
|
|
281
|
+
writeFileSync(
|
|
282
|
+
p.linux,
|
|
283
|
+
`[Unit]\nDescription=Qiksy local engine\n\n[Service]\nExecStart=${node} ${script}\nEnvironment=QIKSY_NO_OPEN=1\nRestart=always\n\n[Install]\nWantedBy=default.target\n`
|
|
284
|
+
);
|
|
285
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
286
|
+
spawnSync('systemctl', ['--user', 'enable', '--now', 'qiksy.service'], { stdio: 'ignore' });
|
|
287
|
+
say(c.green(' ✓ Qiksy will start by itself when you log in.'));
|
|
288
|
+
}
|
|
289
|
+
say(c.dim(' Undo any time with: npx qiksy uninstall\n'));
|
|
290
|
+
return true;
|
|
291
|
+
} catch (e) {
|
|
292
|
+
if (!quiet) {
|
|
293
|
+
console.log(c.amber(` ! Could not set that up: ${e.message}`));
|
|
294
|
+
console.log(c.dim(' You can still start it by hand with: npx qiksy\n'));
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function cmdUninstall() {
|
|
301
|
+
const p = autostartPaths();
|
|
302
|
+
console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — stop starting automatically')}\n`);
|
|
303
|
+
try {
|
|
304
|
+
if (platform === 'darwin') {
|
|
305
|
+
spawnSync('launchctl', ['unload', p.mac], { stdio: 'ignore' });
|
|
306
|
+
if (existsSync(p.mac)) rmSync(p.mac);
|
|
307
|
+
} else if (isWin) {
|
|
308
|
+
if (existsSync(p.win)) rmSync(p.win);
|
|
309
|
+
} else {
|
|
310
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'qiksy.service'], { stdio: 'ignore' });
|
|
311
|
+
if (existsSync(p.linux)) rmSync(p.linux);
|
|
312
|
+
}
|
|
313
|
+
console.log(c.green(' ✓ It will no longer start on its own.'));
|
|
314
|
+
console.log(c.dim(' It may still be running now — turn it off with: npx qiksy stop\n'));
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.log(c.amber(` ! ${e.message}\n`));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ══ Staying alive ════════════════════════════════════════════════════════════
|
|
321
|
+
The people this is built for (recruiters, agencies) will not read a log, will not
|
|
322
|
+
know what a process is, and will not reason about ports. So the engine has to look
|
|
323
|
+
after itself: a tool that dies is restarted, a tool that stops answering is
|
|
324
|
+
restarted, and both are backed off so a genuinely broken tool cannot spin forever.
|
|
325
|
+
Nothing here ever asks the person to do anything. */
|
|
326
|
+
|
|
194
327
|
const children = [];
|
|
328
|
+
/** id → { tool, child, restarts, downSince } */
|
|
329
|
+
const supervised = new Map();
|
|
330
|
+
const MAX_QUICK_RESTARTS = 5; // within RESTART_WINDOW, then we slow right down
|
|
331
|
+
const RESTART_WINDOW = 60_000;
|
|
332
|
+
|
|
333
|
+
function scheduleRestart(tool, why) {
|
|
334
|
+
const s = supervised.get(tool.id);
|
|
335
|
+
if (!s || s.stopping) return;
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
if (!s.windowStart || now - s.windowStart > RESTART_WINDOW) {
|
|
338
|
+
s.windowStart = now;
|
|
339
|
+
s.restarts = 0;
|
|
340
|
+
}
|
|
341
|
+
s.restarts++;
|
|
342
|
+
// 1s, 2s, 4s, 8s… capped: a tool broken for a real reason must not burn the CPU.
|
|
343
|
+
const delay = Math.min(1000 * 2 ** Math.max(0, s.restarts - 1), 30_000);
|
|
344
|
+
if (s.restarts > MAX_QUICK_RESTARTS) {
|
|
345
|
+
if (!s.warned) {
|
|
346
|
+
console.log(c.amber(` ! ${tool.name} keeps stopping. It will keep retrying quietly in the background.`));
|
|
347
|
+
console.log(c.dim(` If you need it now: npx qiksy doctor`));
|
|
348
|
+
s.warned = true;
|
|
349
|
+
}
|
|
350
|
+
} else if (process.env.QIKSY_DEBUG) {
|
|
351
|
+
console.log(c.dim(` · restarting ${tool.name} (${why}) in ${Math.round(delay / 1000)}s`));
|
|
352
|
+
}
|
|
353
|
+
s.timer = setTimeout(() => {
|
|
354
|
+
if (!s.stopping) start(tool, true);
|
|
355
|
+
}, delay);
|
|
356
|
+
}
|
|
195
357
|
|
|
196
|
-
|
|
358
|
+
/** Every 30s: is each tool still answering? A hung process holds its port but serves
|
|
359
|
+
* nothing, which looks exactly like "the tool broke" from the browser. */
|
|
360
|
+
function startWatchdog() {
|
|
361
|
+
const timer = setInterval(async () => {
|
|
362
|
+
for (const [, s] of supervised) {
|
|
363
|
+
if (s.stopping || !s.child) continue;
|
|
364
|
+
const alive = await isUp(s.tool.port);
|
|
365
|
+
if (alive) {
|
|
366
|
+
s.downSince = 0;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
// Two consecutive misses before acting — one slow moment is not a death.
|
|
370
|
+
if (!s.downSince) {
|
|
371
|
+
s.downSince = Date.now();
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (Date.now() - s.downSince < 25_000) continue;
|
|
375
|
+
s.downSince = 0;
|
|
376
|
+
try {
|
|
377
|
+
s.child.kill('SIGKILL');
|
|
378
|
+
} catch {
|
|
379
|
+
/* already gone */
|
|
380
|
+
}
|
|
381
|
+
// its 'exit' handler schedules the restart
|
|
382
|
+
}
|
|
383
|
+
}, 30_000);
|
|
384
|
+
timer.unref?.();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function start(tool, isRestart = false) {
|
|
197
388
|
const dir0 = join(PKG, 'tools', tool.id, 'vendor');
|
|
198
389
|
const entry = join(dir0, 'server.mjs');
|
|
199
390
|
if (!existsSync(entry)) return null;
|
|
@@ -212,15 +403,40 @@ function start(tool) {
|
|
|
212
403
|
// three-way tail stays readable.
|
|
213
404
|
const tag = c.dim(`[${tool.id}] `);
|
|
214
405
|
child.stdout?.on('data', (d) => process.stdout.write(String(d).replace(/^/gm, tag)));
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
406
|
+
// A Node stack trace tells the person nothing and reads as "it broke". Keep the
|
|
407
|
+
// detail for QIKSY_DEBUG, and say the one useful sentence instead.
|
|
408
|
+
child.stderr?.on('data', (d) => {
|
|
409
|
+
const s = String(d);
|
|
410
|
+
if (process.env.QIKSY_DEBUG) return process.stderr.write(s.replace(/^/gm, tag));
|
|
411
|
+
if (/EADDRINUSE/.test(s)) {
|
|
412
|
+
console.log(c.amber(` ! ${tool.name} could not start — port ${tool.port} is already in use.`));
|
|
413
|
+
console.log(c.dim(' Check with: npx qiksy status'));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (/^\s*(at |node:|throw |\^|Error:)/m.test(s) && !/warn/i.test(s)) return; // swallow the trace
|
|
417
|
+
process.stderr.write(s.replace(/^/gm, tag));
|
|
218
418
|
});
|
|
419
|
+
child.on('exit', (code, signal) => {
|
|
420
|
+
const s = supervised.get(tool.id);
|
|
421
|
+
if (!s || s.stopping) return;
|
|
422
|
+
s.child = null;
|
|
423
|
+
// Bring it back. This is the whole point: the person must never have to notice.
|
|
424
|
+
scheduleRestart(tool, signal || `exit ${code}`);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const rec = supervised.get(tool.id) || { tool, restarts: 0 };
|
|
428
|
+
rec.tool = tool;
|
|
429
|
+
rec.child = child;
|
|
430
|
+
supervised.set(tool.id, rec);
|
|
219
431
|
children.push(child);
|
|
220
432
|
return child;
|
|
221
433
|
}
|
|
222
434
|
|
|
223
435
|
function stopAll() {
|
|
436
|
+
for (const [, s] of supervised) {
|
|
437
|
+
s.stopping = true;
|
|
438
|
+
if (s.timer) clearTimeout(s.timer);
|
|
439
|
+
}
|
|
224
440
|
for (const ch of children) {
|
|
225
441
|
try {
|
|
226
442
|
ch.kill('SIGTERM');
|
|
@@ -245,13 +461,17 @@ process.on('SIGTERM', () => {
|
|
|
245
461
|
if (cmd === 'status') return cmdStatus();
|
|
246
462
|
if (cmd === 'stop') return cmdStop();
|
|
247
463
|
if (cmd === 'doctor' || cmd === 'check') return cmdDoctor();
|
|
464
|
+
if (cmd === 'install' || cmd === 'autostart') return cmdInstall();
|
|
465
|
+
if (cmd === 'uninstall') return cmdUninstall();
|
|
248
466
|
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
249
467
|
console.log(`\n${c.blue(c.b(' ◆ Qiksy'))}${c.dim(' — your local engine')}\n`);
|
|
250
468
|
console.log(' npx qiksy start everything (keep the window open)');
|
|
251
469
|
console.log(' npx qiksy status what is running right now');
|
|
252
470
|
console.log(' npx qiksy stop turn it off');
|
|
253
471
|
console.log(' npx qiksy restart turn it off and on again');
|
|
254
|
-
console.log(' npx qiksy doctor check what is wrong
|
|
472
|
+
console.log(' npx qiksy doctor check what is wrong');
|
|
473
|
+
console.log(' npx qiksy install start automatically when you log in');
|
|
474
|
+
console.log(' npx qiksy uninstall stop starting automatically\n');
|
|
255
475
|
return;
|
|
256
476
|
}
|
|
257
477
|
if (cmd === 'restart') {
|
|
@@ -270,8 +490,40 @@ process.on('SIGTERM', () => {
|
|
|
270
490
|
console.log(c.dim(' The tools still open and show you how to connect it.\n'));
|
|
271
491
|
}
|
|
272
492
|
|
|
273
|
-
|
|
493
|
+
// Look before binding: an already-running engine must be reported as good news, and
|
|
494
|
+
// a port held by someone else must be explained, not crashed on.
|
|
495
|
+
const plan = [];
|
|
274
496
|
for (const tool of TOOLS) {
|
|
497
|
+
plan.push({ tool, holder: await whoHolds(tool.port) });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const already = plan.filter((p) => p.holder === 'ours');
|
|
501
|
+
const blocked = plan.filter((p) => p.holder === 'stranger');
|
|
502
|
+
|
|
503
|
+
if (already.length === TOOLS.length) {
|
|
504
|
+
console.log(c.green(' ✓ Already running — nothing to do.\n'));
|
|
505
|
+
for (const { tool } of already) console.log(c.dim(` ${tool.name.padEnd(18)} http://localhost:${tool.port}`));
|
|
506
|
+
console.log(c.b('\n Go back to your Qiksy tab — it is connected.'));
|
|
507
|
+
console.log(c.dim(' Turn it off with: npx qiksy stop\n'));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (const { tool } of already) {
|
|
512
|
+
console.log(c.green(` ✓ ${tool.name.padEnd(18)} already running`));
|
|
513
|
+
}
|
|
514
|
+
for (const { tool } of blocked) {
|
|
515
|
+
console.log(c.amber(` ! ${tool.name.padEnd(18)} port ${tool.port} is used by another program`));
|
|
516
|
+
console.log(c.dim(` ${tool.name} will stay off until that program is closed. Everything else still works.`));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const toStart = plan.filter((p) => p.holder === 'free').map((p) => p.tool);
|
|
520
|
+
if (!toStart.length) {
|
|
521
|
+
console.log(c.dim('\n Nothing left to start.\n'));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const started = [];
|
|
526
|
+
for (const tool of toStart) {
|
|
275
527
|
const ch = start(tool);
|
|
276
528
|
if (ch) started.push(tool);
|
|
277
529
|
else console.log(c.dim(` – ${tool.name} is not bundled in this build`));
|
|
@@ -294,9 +546,20 @@ process.on('SIGTERM', () => {
|
|
|
294
546
|
if (ok) up.push(tool);
|
|
295
547
|
}
|
|
296
548
|
|
|
549
|
+
startWatchdog();
|
|
550
|
+
|
|
297
551
|
console.log('');
|
|
298
552
|
console.log(c.b(' Everything is running. Go back to your Qiksy tab — it connects on its own.'));
|
|
299
|
-
|
|
553
|
+
|
|
554
|
+
// Register autostart on the FIRST successful run, without being asked. The target
|
|
555
|
+
// user will not run a second command, and an engine that dies with the window is
|
|
556
|
+
// an engine that "randomly stops working" — the single worst failure for them.
|
|
557
|
+
// Opt out with QIKSY_NO_AUTOSTART=1, undo with `npx qiksy uninstall`.
|
|
558
|
+
if (up.length && !process.env.QIKSY_NO_AUTOSTART && !autostartInstalled()) {
|
|
559
|
+
const ok = cmdInstall({ quiet: true });
|
|
560
|
+
if (ok) console.log(c.dim(' From now on it starts by itself when you log in (undo: npx qiksy uninstall).'));
|
|
561
|
+
}
|
|
562
|
+
console.log(c.dim(' You can close this window — it keeps running. Turn it off: npx qiksy stop\n'));
|
|
300
563
|
|
|
301
564
|
if (up.length && !process.env.QIKSY_NO_OPEN) openBrowser('https://qiksy.app/');
|
|
302
565
|
})();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Qiksy
|
|
3
|
+
"version": "1.1.1",
|
|
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": {
|
|
7
7
|
"qiksy": "bin/qiksy.mjs"
|
|
@@ -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;
|
|
@@ -25,6 +25,11 @@ const SETTINGS_DEFAULTS = {
|
|
|
25
25
|
model: 'sonnet', browserSearch: false, extraInstructions: '',
|
|
26
26
|
plan: 'free', license: '', licenseInfo: null,
|
|
27
27
|
brandName: '', brandContact: '', brandNote: '',
|
|
28
|
+
/* Renewal reminders are ON by default and can be turned OFF here — someone who
|
|
29
|
+
tried Pro for a month and went back to Free should not be nagged forever, and
|
|
30
|
+
the switch belongs next to the plan, not in the sidebar the badge appears in
|
|
31
|
+
(owner decision 2026-07-26). */
|
|
32
|
+
renewalNotice: true,
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
/** Своя группа — «я и моя семья». Есть всегда, создаётся молча: на free человек
|
|
@@ -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
|
|
|
@@ -93,6 +93,9 @@ function billingInfo() {
|
|
|
93
93
|
travelerLimit: isFinite(p.travelerLimit) ? p.travelerLimit : null,
|
|
94
94
|
teamLimit: isFinite(p.travelerLimit) ? p.travelerLimit : null,
|
|
95
95
|
manualPlan: db.settings.plan,
|
|
96
|
+
// Days until the paid term ends (null = nothing paid / no expiry). The UI warns
|
|
97
|
+
// from a week out, because a tool that goes quiet without notice feels broken.
|
|
98
|
+
daysLeft: li && li.paidUntil ? Math.ceil((li.paidUntil - Date.now()) / 86400000) : null,
|
|
96
99
|
license: {
|
|
97
100
|
set: !!db.settings.license,
|
|
98
101
|
key: db.settings.license ? db.settings.license.slice(0, 6) + '…' : '',
|
|
@@ -128,6 +131,18 @@ function rollback(trip, fromStatus, toStatus, activityText) {
|
|
|
128
131
|
|
|
129
132
|
const tripTravelers = trip => trip.travelerIds.map(id => db.team.find(m => m.id === id)).filter(Boolean);
|
|
130
133
|
|
|
134
|
+
/* Когда оплаченный срок кончился, план сам падает на free (lib/license.mjs) — а
|
|
135
|
+
значит НОВАЯ работа по чужим клиентам останавливается: запустить поиск или
|
|
136
|
+
оформление для группы, которая не своя, больше нельзя (решение владельца
|
|
137
|
+
26.07.2026). Уже найденное остаётся на месте: поездки, варианты, отчёты видны и
|
|
138
|
+
копируются. Данные — не заложник, платная — работа. Свои поездки идут как на free. */
|
|
139
|
+
const isOwnParty = trip => {
|
|
140
|
+
const self = db.parties[0];
|
|
141
|
+
return !trip.partyId || !self || trip.partyId === self.id;
|
|
142
|
+
};
|
|
143
|
+
const paidPlan = () => ['pro', 'dev'].includes(effectivePlanId());
|
|
144
|
+
const clientWorkAllowed = trip => paidPlan() || isOwnParty(trip);
|
|
145
|
+
|
|
131
146
|
/* Jobs live in memory, trip statuses live on disk — so a server restart (an edit, a
|
|
132
147
|
crash, a reboot) left trips frozen mid-flight: "searching" forever, with no job
|
|
133
148
|
behind it and nothing but "Cancel trip" to press. Roll those back at boot and say
|
|
@@ -562,6 +577,7 @@ const server = createServer(async (req, res) => {
|
|
|
562
577
|
for (const k of ['brandName', 'brandContact', 'brandNote']) {
|
|
563
578
|
if (k in body) body[k] = String(body[k] || '').slice(0, 400);
|
|
564
579
|
}
|
|
580
|
+
if ('renewalNotice' in body) body.renewalNotice = !!body.renewalNotice;
|
|
565
581
|
// Платные планы включаются лицензией: руками только free/dev.
|
|
566
582
|
if ('plan' in body && !SELF_SETTABLE_PLANS.includes(body.plan)) delete body.plan;
|
|
567
583
|
delete body.license;
|
|
@@ -571,6 +587,50 @@ const server = createServer(async (req, res) => {
|
|
|
571
587
|
return json(res, 200, db.settings);
|
|
572
588
|
}
|
|
573
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
|
+
|
|
574
634
|
// ── license: ввести/сменить/убрать ключ и сразу проверить у воркера ──
|
|
575
635
|
if (req.method === 'POST' && path === '/api/license') {
|
|
576
636
|
const { key } = await readBody(req);
|
|
@@ -670,6 +730,7 @@ const server = createServer(async (req, res) => {
|
|
|
670
730
|
if (!['draft', 'options'].includes(t.status)) {
|
|
671
731
|
return json(res, 400, { error: `поиск доступен из статусов draft/options (сейчас: ${t.status})` });
|
|
672
732
|
}
|
|
733
|
+
if (!clientWorkAllowed(t)) return json(res, 402, { error: 'PLAN_REQUIRED' });
|
|
673
734
|
if (!tripTravelers(t).length) return json(res, 400, { error: 'выберите хотя бы одного путника из команды' });
|
|
674
735
|
// повторный поиск перезаписывает прошлый подбор
|
|
675
736
|
t.options = [];
|
|
@@ -690,6 +751,7 @@ const server = createServer(async (req, res) => {
|
|
|
690
751
|
if (!['options', 'ready_to_pay'].includes(t.status)) {
|
|
691
752
|
return json(res, 400, { error: `оформление доступно из статуса options (сейчас: ${t.status})` });
|
|
692
753
|
}
|
|
754
|
+
if (!clientWorkAllowed(t)) return json(res, 402, { error: 'PLAN_REQUIRED' });
|
|
693
755
|
if (!t.options.some(o => o.n === num)) return json(res, 400, { error: `варианта №${num} нет в подборе` });
|
|
694
756
|
if (!db.team.some(m => m.id === t.travelerIds[0])) return json(res, 400, { error: 'у поездки нет путника с карточкой' });
|
|
695
757
|
const prev = t.status;
|
|
@@ -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' });
|