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 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
- function start(tool) {
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
- child.stderr?.on('data', (d) => process.stderr.write(String(d).replace(/^/gm, tag)));
216
- child.on('exit', (code) => {
217
- if (code && code !== 0) console.log(c.red(` ✗ ${tool.name} stopped (exit ${code})`));
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\n');
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
- const started = [];
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
- console.log(c.dim(' Keep this window open while you work. Ctrl+C to stop.\n'));
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.0.0",
4
- "description": "Qiksy one local engine for every Qiksy tool (Work Finder, Travel Assistant, Client Finder). Runs on your own Claude; nothing leaves your machine.",
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
- 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;
@@ -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' });