qiksy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/ARCHITECTURE.md +78 -0
  2. package/INSTALL-PROMPT.md +53 -0
  3. package/bin/qiksy.mjs +302 -0
  4. package/package.json +32 -0
  5. package/tools/client-finder/vendor/README.md +70 -0
  6. package/tools/client-finder/vendor/find.mjs +121 -0
  7. package/tools/client-finder/vendor/icp/assist-pl-shops.json +30 -0
  8. package/tools/client-finder/vendor/icp/assist-ua-shops.json +30 -0
  9. package/tools/client-finder/vendor/knowledge/assist.md +68 -0
  10. package/tools/client-finder/vendor/knowledge/custom.md +36 -0
  11. package/tools/client-finder/vendor/knowledge/multilogin.md +29 -0
  12. package/tools/client-finder/vendor/knowledge/pay.md +26 -0
  13. package/tools/client-finder/vendor/knowledge/qa-copilot.md +43 -0
  14. package/tools/client-finder/vendor/knowledge/travel.md +43 -0
  15. package/tools/client-finder/vendor/knowledge/work-finder.md +52 -0
  16. package/tools/client-finder/vendor/lib/access.mjs +108 -0
  17. package/tools/client-finder/vendor/lib/agent.mjs +204 -0
  18. package/tools/client-finder/vendor/lib/approver-mcp.mjs +78 -0
  19. package/tools/client-finder/vendor/lib/browser.mjs +108 -0
  20. package/tools/client-finder/vendor/lib/catalog.mjs +86 -0
  21. package/tools/client-finder/vendor/lib/channels.mjs +54 -0
  22. package/tools/client-finder/vendor/lib/deliver.mjs +89 -0
  23. package/tools/client-finder/vendor/lib/engine.mjs +283 -0
  24. package/tools/client-finder/vendor/lib/formats.mjs +108 -0
  25. package/tools/client-finder/vendor/lib/jobs.mjs +75 -0
  26. package/tools/client-finder/vendor/lib/knowledge.mjs +26 -0
  27. package/tools/client-finder/vendor/lib/prompts.mjs +183 -0
  28. package/tools/client-finder/vendor/lib/report.mjs +75 -0
  29. package/tools/client-finder/vendor/lib/scope.mjs +162 -0
  30. package/tools/client-finder/vendor/lib/spawn-claude.mjs +44 -0
  31. package/tools/client-finder/vendor/lib/verify.mjs +96 -0
  32. package/tools/client-finder/vendor/package.json +8 -0
  33. package/tools/client-finder/vendor/server.mjs +390 -0
  34. package/tools/travel/vendor/README.md +58 -0
  35. package/tools/travel/vendor/docs/CONTRACT.md +174 -0
  36. package/tools/travel/vendor/docs/ENGINE-MIGRATION.md +33 -0
  37. package/tools/travel/vendor/lib/agent.mjs +190 -0
  38. package/tools/travel/vendor/lib/engine.mjs +283 -0
  39. package/tools/travel/vendor/lib/jobs.mjs +125 -0
  40. package/tools/travel/vendor/lib/license.mjs +41 -0
  41. package/tools/travel/vendor/lib/plans.mjs +28 -0
  42. package/tools/travel/vendor/lib/prompts.mjs +394 -0
  43. package/tools/travel/vendor/lib/proposal.mjs +207 -0
  44. package/tools/travel/vendor/lib/spawn-claude.mjs +44 -0
  45. package/tools/travel/vendor/lib/store.mjs +75 -0
  46. package/tools/travel/vendor/package.json +12 -0
  47. package/tools/travel/vendor/public/app.js +996 -0
  48. package/tools/travel/vendor/public/index.html +112 -0
  49. package/tools/travel/vendor/public/styles.css +760 -0
  50. package/tools/travel/vendor/scripts/approver-mcp.mjs +80 -0
  51. package/tools/travel/vendor/scripts/launch-browser.mjs +77 -0
  52. package/tools/travel/vendor/server.mjs +755 -0
  53. package/tools/work-finder/vendor/.claude/settings.local.json +27 -0
  54. package/tools/work-finder/vendor/.mcp.json +9 -0
  55. package/tools/work-finder/vendor/CLAUDE.md +24 -0
  56. package/tools/work-finder/vendor/README.md +44 -0
  57. package/tools/work-finder/vendor/bin/qiksy-work-finder.mjs +127 -0
  58. package/tools/work-finder/vendor/lib/agent.mjs +159 -0
  59. package/tools/work-finder/vendor/lib/catalog.mjs +41 -0
  60. package/tools/work-finder/vendor/lib/channels.mjs +73 -0
  61. package/tools/work-finder/vendor/lib/engine.mjs +283 -0
  62. package/tools/work-finder/vendor/lib/jobs.mjs +127 -0
  63. package/tools/work-finder/vendor/lib/license.mjs +60 -0
  64. package/tools/work-finder/vendor/lib/linkcheck.mjs +72 -0
  65. package/tools/work-finder/vendor/lib/linkedin.mjs +22 -0
  66. package/tools/work-finder/vendor/lib/plans.mjs +33 -0
  67. package/tools/work-finder/vendor/lib/prompts.mjs +217 -0
  68. package/tools/work-finder/vendor/lib/spawn-claude.mjs +44 -0
  69. package/tools/work-finder/vendor/lib/store.mjs +125 -0
  70. package/tools/work-finder/vendor/package.json +41 -0
  71. package/tools/work-finder/vendor/public/app.js +1863 -0
  72. package/tools/work-finder/vendor/public/index.html +518 -0
  73. package/tools/work-finder/vendor/public/styles.css +930 -0
  74. package/tools/work-finder/vendor/scripts/approver-mcp.mjs +69 -0
  75. package/tools/work-finder/vendor/scripts/extract-linkedin.mjs +81 -0
  76. package/tools/work-finder/vendor/scripts/launch-browser.mjs +78 -0
  77. package/tools/work-finder/vendor/scripts/screenshot.mjs +17 -0
  78. package/tools/work-finder/vendor/server.mjs +1006 -0
@@ -0,0 +1,755 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Travel Assistant (web) — ассистент командировок на headless-агентах Claude Code.
4
+ *
5
+ * npm start → http://localhost:5545
6
+ *
7
+ * Small JSON API + static UI. Долгие операции (поиск, оформление, финальные
8
+ * документы) — фоновые джобы, которые спавнят `claude -p` (lib/agent.mjs);
9
+ * UI поллит /api/state. Архитектура — калька с work-finder.
10
+ */
11
+ import { createServer } from 'node:http';
12
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
13
+ import { resolve, extname, sep } from 'node:path';
14
+
15
+ import { load, save, uid, ROOT, SELF_PARTY_ID } from './lib/store.mjs';
16
+ import { runClaude, extractJson } from './lib/agent.mjs';
17
+ import { buildSearchPrompt, buildBookPrompt, buildFinalizePrompt } from './lib/prompts.mjs';
18
+ import { createJob, jobLog, jobStep, listJobs, getJob, cancelJob, cancelActiveByTrip } from './lib/jobs.mjs';
19
+ import { ensureBrowser } from './scripts/launch-browser.mjs';
20
+ import { PLANS, SELF_SETTABLE_PLANS } from './lib/plans.mjs';
21
+ import { verifyLicense, licensePlanId, RECHECK_MS } from './lib/license.mjs';
22
+ import { probeEngine, startLogin, submitLoginCode } from './lib/engine.mjs';
23
+ import { buildProposalHtml } from './lib/proposal.mjs';
24
+
25
+ const PORT = Number(process.env.TA_PORT) || 5545;
26
+ const PUBLIC_DIR = resolve(ROOT, 'public');
27
+ const MIME = {
28
+ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
29
+ '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
30
+ '.ico': 'image/x-icon', '.woff2': 'font/woff2',
31
+ };
32
+
33
+ let db = load();
34
+
35
+
36
+ // ── helpers ────────────────────────────────────────────────────────────────
37
+
38
+ function addActivity(trip, text) {
39
+ trip.activity.push({ at: Date.now(), text });
40
+ if (trip.activity.length > 100) trip.activity.splice(0, trip.activity.length - 100);
41
+ }
42
+
43
+ const touch = trip => { trip.updatedAt = Date.now(); };
44
+
45
+ /* Маршрут-цепочка (решение владельца 2026-07-20): поездка может нести слоты —
46
+ упорядоченные отрезки «переезд/трансфер/отель/точка». Санитизируем на входе:
47
+ только известные типы и строковые поля, максимум 12 слотов. Пустой массив =
48
+ классический простой режим from→to. */
49
+ const LEG_KINDS = ['transport', 'transfer', 'hotel', 'point'];
50
+ const TRAVEL_CLASSES = ['economy', 'premium', 'business'];
51
+ const PRIORITIES = ['cheapest', 'optimal', 'fastest', 'comfort'];
52
+ const LANGS = ['en', 'ru', 'uk'];
53
+ function sanitizeLegs(raw) {
54
+ if (!Array.isArray(raw)) return [];
55
+ return raw.slice(0, 12).map(l => ({
56
+ kind: LEG_KINDS.includes(l?.kind) ? l.kind : 'point',
57
+ from: String(l?.from || '').slice(0, 200),
58
+ to: String(l?.to || '').slice(0, 200),
59
+ date: String(l?.date || '').slice(0, 20),
60
+ mode: String(l?.mode || '').slice(0, 100),
61
+ near: String(l?.near || '').slice(0, 240),
62
+ radiusKm: Number(l?.radiusKm) > 0 ? Math.min(20, Number(l.radiusKm)) : 0,
63
+ note: String(l?.note || '').slice(0, 300),
64
+ })).filter(l => l.from || l.to || l.near || l.note);
65
+ }
66
+
67
+ // ── тарифы ─────────────────────────────────────────────────────────────────
68
+ // Эффективный тариф: `dev` — руками (self-host); платные start/pro — только из
69
+ // валидной лицензии. Ручной выбор в настройках ограничен free/dev.
70
+ function effectivePlanId() {
71
+ if (db.settings.plan === 'dev') return 'dev';
72
+ return licensePlanId(db.settings.licenseInfo);
73
+ }
74
+ const activePlan = () => PLANS[effectivePlanId()] || PLANS.free;
75
+
76
+ async function refreshLicense() {
77
+ const info = await verifyLicense(db.settings.license || '');
78
+ if (info) { db.settings.licenseInfo = info; save(db); }
79
+ return db.settings.licenseInfo;
80
+ }
81
+
82
+ function billingInfo() {
83
+ const id = effectivePlanId();
84
+ const p = PLANS[id] || PLANS.free;
85
+ const li = db.settings.licenseInfo;
86
+ return {
87
+ plan: id,
88
+ planName: p.name,
89
+ // partyLimit is the paywall (how many people's trips you may run); travelerLimit
90
+ // only stops a runaway list inside one group. `teamLimit` stays as the old alias
91
+ // so a UI build from before the group model keeps rendering something sane.
92
+ partyLimit: isFinite(p.partyLimit) ? p.partyLimit : null,
93
+ travelerLimit: isFinite(p.travelerLimit) ? p.travelerLimit : null,
94
+ teamLimit: isFinite(p.travelerLimit) ? p.travelerLimit : null,
95
+ manualPlan: db.settings.plan,
96
+ license: {
97
+ set: !!db.settings.license,
98
+ key: db.settings.license ? db.settings.license.slice(0, 6) + '…' : '',
99
+ plan: li && li.plan ? li.plan : 'free',
100
+ active: !!(li && li.active),
101
+ invalid: !!(li && li.invalid),
102
+ paidUntil: (li && li.paidUntil) || 0,
103
+ trial: !!(li && li.trial),
104
+ checkedAt: (li && li.checkedAt) || 0,
105
+ },
106
+ };
107
+ }
108
+
109
+ // Паспортные номера не должны утекать в лог джоба (tool_use book-агента несёт
110
+ // их в аргументах) — маскируем каждый известный номер: FK123456 → FK1***56.
111
+ const maskPassport = no => (no.length > 5 ? `${no.slice(0, 3)}***${no.slice(-2)}` : '***');
112
+ function maskSecrets(line) {
113
+ let out = String(line);
114
+ for (const m of db.team) {
115
+ if (m.passportNo && m.passportNo.length >= 4) out = out.split(m.passportNo).join(maskPassport(m.passportNo));
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** Откат статуса при ошибке джоба; отменённую/удалённую поездку не трогаем. */
121
+ function rollback(trip, fromStatus, toStatus, activityText) {
122
+ if (trip.status !== fromStatus) return;
123
+ trip.status = toStatus;
124
+ addActivity(trip, `✗ ${activityText}`);
125
+ touch(trip);
126
+ save(db);
127
+ }
128
+
129
+ const tripTravelers = trip => trip.travelerIds.map(id => db.team.find(m => m.id === id)).filter(Boolean);
130
+
131
+ /* Jobs live in memory, trip statuses live on disk — so a server restart (an edit, a
132
+ crash, a reboot) left trips frozen mid-flight: "searching" forever, with no job
133
+ behind it and nothing but "Cancel trip" to press. Roll those back at boot and say
134
+ so in the trip's activity, rather than leaving a dead status on screen. */
135
+ (function recoverInterruptedTrips() {
136
+ const back = { searching: 'draft', booking: 'options' };
137
+ let n = 0;
138
+ for (const t of db.trips) {
139
+ const to = back[t.status];
140
+ if (!to) continue;
141
+ // A search that already produced options is not a loss — keep them and land on
142
+ // "options" instead of throwing the trip back to an empty draft.
143
+ t.status = t.status === 'searching' && (t.options || []).length ? 'options' : to;
144
+ addActivity(t, '⚠ агент прервался (перезапуск сервера) — можно запустить снова');
145
+ touch(t);
146
+ n++;
147
+ }
148
+ if (n) save(db);
149
+ })();
150
+
151
+ // ── job workers ────────────────────────────────────────────────────────────
152
+
153
+ function startSearch(trip) {
154
+ return createJob('search', trip.id, `Поиск: ${trip.title}`, async (job, signal) => {
155
+ const t = db.trips.find(x => x.id === trip.id);
156
+ if (!t) throw new Error('поездка удалена');
157
+ const travelers = tripTravelers(t);
158
+
159
+ // партиалы: агент стримит `OPTION: {...}` — варианты появляются в поездке вживую
160
+ const onPartial = opt => {
161
+ if (!opt || !opt.kind || !opt.title) return;
162
+ if (opt.n == null) opt.n = t.options.length + 1;
163
+ if (t.options.some(o => o.n === opt.n)) return;
164
+ t.options.push(opt);
165
+ touch(t);
166
+ save(db);
167
+ jobLog(job, `+ вариант №${opt.n}: ${opt.title}`);
168
+ jobStep(job, 'found', opt.title || '', opt.kind || '');
169
+ };
170
+
171
+ const res = await runClaude({
172
+ prompt: buildSearchPrompt(t, travelers, db.corporate, db.settings, t.lang),
173
+ allowedTools: ['WebSearch', 'WebFetch'],
174
+ withBrowser: !!db.settings.browserSearch,
175
+ model: db.settings.model,
176
+ timeoutMs: (db.settings.browserSearch ? 25 : 15) * 60_000,
177
+ signal,
178
+ onPartial,
179
+ onEvent: line => jobLog(job, maskSecrets(line)),
180
+ onStep: (kind, text, host) => jobStep(job, kind, maskSecrets(text), host),
181
+ });
182
+ if (!res.ok) {
183
+ // таймаут/обрыв, но партиалы уже в поездке — частичный успех, не ошибка
184
+ if (t.status === 'searching' && t.options.length) {
185
+ t.status = 'options';
186
+ addActivity(t, `⚠ поиск оборвался (${res.error}), но ${t.options.length} вариантов уже найдено`);
187
+ touch(t);
188
+ save(db);
189
+ jobLog(job, `⚠ ${res.error} — но ${t.options.length} вариантов уже добавлено`);
190
+ return { options: t.options.length, truncated: true, costUsd: res.costUsd };
191
+ }
192
+ rollback(t, 'searching', 'draft', `поиск не удался: ${res.error}`);
193
+ throw new Error(res.error);
194
+ }
195
+ const parsed = extractJson(res.result);
196
+ if (!parsed || !Array.isArray(parsed.options)) {
197
+ if (t.status === 'searching' && t.options.length) {
198
+ t.status = 'options';
199
+ addActivity(t, `⚠ финальный ответ агента не распарсился, но ${t.options.length} вариантов уже найдено`);
200
+ touch(t);
201
+ save(db);
202
+ jobLog(job, `⚠ финальный ответ не распарсился — но ${t.options.length} вариантов уже добавлено`);
203
+ return { options: t.options.length, truncated: true, costUsd: res.costUsd };
204
+ }
205
+ rollback(t, 'searching', 'draft', 'агент вернул ответ не в формате { options: […] }');
206
+ throw new Error('agent returned no {options:[…]} JSON: ' + String(res.result).slice(0, 300));
207
+ }
208
+ // финальный JSON включает застримленное; нормализуем как в onPartial —
209
+ // выкидываем неполные, гарантируем уникальный целый n (иначе мёртвая/чужая кнопка)
210
+ const valid = parsed.options.filter(o => o && o.kind && o.title).slice(0, 5);
211
+ const usedN = new Set();
212
+ for (const o of valid) {
213
+ if (Number.isInteger(o.n) && !usedN.has(o.n)) usedN.add(o.n);
214
+ else o.n = null;
215
+ }
216
+ let nextN = 1;
217
+ for (const o of valid) {
218
+ if (o.n != null) continue;
219
+ while (usedN.has(nextN)) nextN++;
220
+ o.n = nextN;
221
+ usedN.add(nextN);
222
+ }
223
+ t.options = valid;
224
+ t.summary = typeof parsed.summary === 'string' ? parsed.summary : '';
225
+ t.recommendation = { text: '', ids: [], comboTotal: '', ...(parsed.recommendation || {}) };
226
+ t.notFound = typeof parsed.notFound === 'string' ? parsed.notFound : '';
227
+ if (t.status === 'searching') t.status = 'options';
228
+ addActivity(t, `✓ найдено вариантов: ${t.options.length}`);
229
+ touch(t);
230
+ save(db);
231
+ jobLog(job, `✓ найдено вариантов: ${t.options.length}`);
232
+ return { options: t.options.length, costUsd: res.costUsd };
233
+ }, () => {
234
+ const t = db.trips.find(x => x.id === trip.id);
235
+ if (t) rollback(t, 'searching', 'draft', 'поиск отменён до запуска');
236
+ });
237
+ }
238
+
239
+ function startBook(trip, n, prevStatus) {
240
+ return createJob('book', trip.id, `Оформление №${n}: ${trip.title}`, async (job, signal) => {
241
+ const t = db.trips.find(x => x.id === trip.id);
242
+ if (!t) throw new Error('поездка удалена');
243
+ const option = t.options.find(o => o.n === n);
244
+ if (!option) throw new Error(`варианта №${n} нет в подборе`);
245
+ const traveler = db.team.find(m => m.id === t.travelerIds[0]);
246
+ if (!traveler) throw new Error('у поездки нет путника с карточкой');
247
+
248
+ jobLog(job, 'проверяю браузер (CDP :9222)…');
249
+ if (!(await ensureBrowser())) {
250
+ rollback(t, 'booking', prevStatus, 'браузер не поднялся на :9222 — запустите npm run browser');
251
+ throw new Error('браузер не поднялся на :9222');
252
+ }
253
+ const res = await runClaude({
254
+ prompt: buildBookPrompt(t, option, traveler, db.corporate, t.lang),
255
+ withBrowser: true,
256
+ allowedTools: ['WebFetch'],
257
+ model: db.settings.model,
258
+ timeoutMs: 15 * 60_000,
259
+ signal,
260
+ onEvent: line => jobLog(job, maskSecrets(line)),
261
+ onStep: (kind, text, host) => jobStep(job, kind, maskSecrets(text), host),
262
+ });
263
+ if (!res.ok) {
264
+ rollback(t, 'booking', prevStatus, `оформление №${n} не удалось: ${res.error}`);
265
+ throw new Error(res.error);
266
+ }
267
+ const outcome = extractJson(res.result) || {};
268
+ t.bookingReports.push({
269
+ optionN: n,
270
+ filled: Array.isArray(outcome.filled) ? outcome.filled : [],
271
+ stoppedAt: outcome.stoppedAt || '',
272
+ totalShown: outcome.totalShown || '',
273
+ warnings: Array.isArray(outcome.warnings) ? outcome.warnings : [],
274
+ manualLink: outcome.manualLink || '',
275
+ text: outcome.stoppedAt ? '' : String(res.result).slice(0, 1000),
276
+ at: Date.now(),
277
+ });
278
+ if (t.status === 'booking') t.status = 'ready_to_pay';
279
+ addActivity(t, `✓ вариант №${n}: ${outcome.stoppedAt || 'см. отчёт агента'}${outcome.totalShown ? `, итог ${outcome.totalShown}` : ''} — карту вводит HR`);
280
+ touch(t);
281
+ save(db);
282
+ jobLog(job, `✓ ${outcome.stoppedAt || 'готово'}${outcome.totalShown ? ` · ${outcome.totalShown}` : ''}`);
283
+ return { optionN: n, ...outcome, costUsd: res.costUsd };
284
+ }, () => {
285
+ const t = db.trips.find(x => x.id === trip.id);
286
+ if (t) rollback(t, 'booking', prevStatus, `оформление №${n} отменено до запуска`);
287
+ });
288
+ }
289
+
290
+ function startFinalize(trip) {
291
+ return createJob('finalize', trip.id, `Документы: ${trip.title}`, async (job, signal) => {
292
+ const t = db.trips.find(x => x.id === trip.id);
293
+ if (!t) throw new Error('поездка удалена');
294
+ const res = await runClaude({
295
+ prompt: buildFinalizePrompt(t, tripTravelers(t), db.corporate, t.lang),
296
+ model: db.settings.model,
297
+ signal,
298
+ onEvent: line => jobLog(job, maskSecrets(line)),
299
+ });
300
+ if (!res.ok) {
301
+ rollback(t, 'paid', 'ready_to_pay', `документы не сгенерировались: ${res.error}`);
302
+ throw new Error(res.error);
303
+ }
304
+ const parsed = extractJson(res.result);
305
+ if (!parsed || typeof parsed.letter !== 'string') {
306
+ rollback(t, 'paid', 'ready_to_pay', 'агент вернул ответ не в формате {letter,costTable,checklist}');
307
+ throw new Error('agent returned no {letter,costTable,checklist} JSON: ' + String(res.result).slice(0, 300));
308
+ }
309
+ t.blocks = {
310
+ letter: parsed.letter || '',
311
+ costTable: parsed.costTable || '',
312
+ checklist: parsed.checklist || '',
313
+ };
314
+ addActivity(t, '✓ письмо путнику, смета и чеклист готовы');
315
+ touch(t);
316
+ save(db);
317
+ jobLog(job, '✓ три блока готовы');
318
+ return { costUsd: res.costUsd };
319
+ });
320
+ }
321
+
322
+ // ── http plumbing ──────────────────────────────────────────────────────────
323
+
324
+ const readBody = req => new Promise((res, rej) => {
325
+ let data = '';
326
+ req.on('data', c => { data += c; if (data.length > 2e6) { rej(new Error('тело запроса больше 2 МБ')); req.destroy(); } });
327
+ req.on('end', () => { try { res(data ? JSON.parse(data) : {}); } catch (e) { rej(e); } });
328
+ req.on('error', rej);
329
+ });
330
+
331
+ const json = (res, code, obj) => {
332
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
333
+ res.end(JSON.stringify(obj));
334
+ };
335
+
336
+ // UI показывает, поднят ли CDP-браузер для оформления броней.
337
+ let browserAlive = false;
338
+ async function probeBrowser() {
339
+ try {
340
+ const r = await fetch('http://localhost:9222/json/version', { signal: AbortSignal.timeout(1000) });
341
+ browserAlive = r.ok;
342
+ } catch { browserAlive = false; }
343
+ }
344
+ probeBrowser();
345
+ setInterval(probeBrowser, 15_000).unref();
346
+
347
+ // UI origins allowed to call this local backend cross-origin: the Nx dev
348
+ // servers AND the deployed studio (its static build fetches http://localhost:5545
349
+ // directly — browsers treat localhost as a secure origin, so an https page may).
350
+ const UI_ORIGINS = new Set([
351
+ 'https://studio.qiksy.app',
352
+ 'https://qiksy.app',
353
+ ...Array.from({ length: 9 }, (_, i) => `http://localhost:${4300 + i}`),
354
+ 'http://localhost:4200',
355
+ 'http://localhost:' + PORT,
356
+ 'http://127.0.0.1:' + PORT,
357
+ ]);
358
+
359
+ const server = createServer(async (req, res) => {
360
+ const url = new URL(req.url, 'http://x');
361
+ const path = url.pathname;
362
+ const seg = path.split('/').filter(Boolean); // e.g. ['api','trips',':id','search']
363
+
364
+ // Локальный API отдаёт паспорта: Host-проверка отбивает DNS-rebinding (все методы),
365
+ // Origin-проверка — CSRF на мутациях: только известные UI-origin'ы (dev-порты +
366
+ // задеплоенная студия); GET оставлен открытым, иначе рубит no-cors пробу хаба.
367
+ const host = req.headers.host || '';
368
+ if (host !== 'localhost:' + PORT && host !== '127.0.0.1:' + PORT) { res.writeHead(403); res.end('bad host'); return; }
369
+ const origin = req.headers.origin;
370
+ if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
371
+ if (origin && !UI_ORIGINS.has(origin)) { res.writeHead(403); res.end('bad origin'); return; }
372
+ }
373
+ // CORS для разрешённых UI-origin'ов (эхо конкретного origin, не *).
374
+ if (origin && UI_ORIGINS.has(origin)) {
375
+ res.setHeader('Access-Control-Allow-Origin', origin);
376
+ res.setHeader('Vary', 'Origin');
377
+ if (req.method === 'OPTIONS') {
378
+ // Chrome Private Network Access: public qiksy.app → localhost is blocked unless the
379
+ // preflight carries this header. See the note in client-finder-backend.
380
+ if (req.headers['access-control-request-private-network'] === 'true') {
381
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
382
+ }
383
+ res.writeHead(204, {
384
+ 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
385
+ 'Access-Control-Allow-Headers': 'Content-Type',
386
+ 'Access-Control-Max-Age': '86400',
387
+ });
388
+ res.end();
389
+ return;
390
+ }
391
+ }
392
+
393
+ try {
394
+ if (!path.startsWith('/api')) {
395
+ const file = path === '/' ? 'index.html' : path.slice(1);
396
+ const full = resolve(PUBLIC_DIR, file);
397
+ if ((full === PUBLIC_DIR || full.startsWith(PUBLIC_DIR + sep)) && existsSync(full)) {
398
+ res.writeHead(200, { 'Content-Type': MIME[extname(full)] || 'application/octet-stream' });
399
+ res.end(readFileSync(full));
400
+ } else {
401
+ res.writeHead(404); res.end('not found');
402
+ }
403
+ return;
404
+ }
405
+
406
+ // ── state ──
407
+ /* Start Anthropic's own sign-in on this machine and hand back ITS link, so the
408
+ panel can offer "open" and "copy" instead of firing a browser off-screen. We
409
+ never build a "connect with Claude" of our own — this launches theirs, and the
410
+ panel polls /api/engine until it turns green. */
411
+ if (req.method === 'POST' && path === '/api/engine/login') {
412
+ return json(res, 200, await startLogin());
413
+ }
414
+
415
+ /* The callback page shows a code instead of redirecting when it cannot reach the
416
+ CLI's loopback (another machine, or a browser that won't open localhost). This
417
+ is where that code goes. */
418
+ if (req.method === 'POST' && path === '/api/engine/login/code') {
419
+ const { code } = await readBody(req);
420
+ return json(res, 200, submitLoginCode(code));
421
+ }
422
+
423
+ /* Can this machine run the agent at all? The gate asks BEFORE demanding a key,
424
+ because the CLI may already be logged in with a Claude subscription — in which
425
+ case a key is not just optional, it is irrelevant. ?force=1 re-probes after the
426
+ user has gone and logged in. */
427
+ if (req.method === 'GET' && path === '/api/engine') {
428
+ const force = /[?&]force=1/.test(req.url || '');
429
+ return json(res, 200, await probeEngine({ force }));
430
+ }
431
+
432
+ if (req.method === 'GET' && path === '/api/state') {
433
+ return json(res, 200, { ...db, browserAlive, jobs: listJobs(), billing: billingInfo() });
434
+ }
435
+
436
+ // ── jobs ──
437
+ if (req.method === 'GET' && path === '/api/jobs') {
438
+ return json(res, 200, listJobs());
439
+ }
440
+ if (req.method === 'POST' && seg[1] === 'jobs' && seg[2] && seg[3] === 'cancel') {
441
+ return json(res, 200, { ok: cancelJob(seg[2]) });
442
+ }
443
+ if (req.method === 'GET' && seg[1] === 'jobs' && seg[2]) {
444
+ const j = getJob(seg[2]);
445
+ return j ? json(res, 200, j) : json(res, 404, { error: 'нет такого джоба' });
446
+ }
447
+
448
+ // ── free-text route → slots ──
449
+ // Parsing runs on the local Claude sign-in (the same `claude` CLI the search
450
+ // agents use), NOT a browser-side Anthropic key — the key path is gone. A cheap,
451
+ // synchronous one-shot: no background job, just parse and return the legs.
452
+ if (req.method === 'POST' && path === '/api/parse-route') {
453
+ const b = await readBody(req);
454
+ const text = (b.text || '').trim();
455
+ if (!text) return json(res, 400, { error: 'нужен текст маршрута (text)' });
456
+ const prompt = `Разбери описание маршрута командировки на упорядоченные слоты. Верни ТОЛЬКО JSON-массив объектов без пояснений:
457
+ [{"kind":"transport|transfer|hotel|point","from":"","to":"","date":"YYYY-MM-DD или пусто","mode":"","near":"","note":""}]
458
+ Правила: переезд города A→B = transport (from/to); такси/экспресс/метро = transfer (mode = способ); отель = hotel (near = адрес/место рядом с которым искать, note = ночи/пожелания); прочие обязательные точки = point (from = место). Обратный путь НЕ добавляй — он строится зеркально автоматически.
459
+
460
+ ОПИСАНИЕ: ${text}`;
461
+ try {
462
+ const r = await runClaude({ prompt, model: 'haiku', timeoutMs: 90_000 });
463
+ const m = String(r.result || '').match(/\[[\s\S]*\]/);
464
+ const legs = m ? JSON.parse(m[0]) : [];
465
+ return json(res, 200, { legs: Array.isArray(legs) ? legs : [] });
466
+ } catch (e) {
467
+ return json(res, 500, { error: String(e?.message || e) });
468
+ }
469
+ }
470
+
471
+ // ── parties (кто едет: своя семья / клиент агентства) ──
472
+ if (req.method === 'POST' && path === '/api/parties') {
473
+ const b = await readBody(req);
474
+ // Тарифная граница живёт ЗДЕСЬ: free ведёт одну группу (себя и семью),
475
+ // каждая следующая — это чужая поездка, то есть работа агентства.
476
+ const lim = activePlan().partyLimit;
477
+ if (db.parties.length >= lim) {
478
+ return json(res, 402, { error: `PARTY_LIMIT:${lim}` });
479
+ }
480
+ const party = {
481
+ id: 'p_' + uid(),
482
+ kind: 'client',
483
+ name: String(b.name || '').trim().slice(0, 120),
484
+ email: String(b.email || '').trim().slice(0, 160),
485
+ phone: String(b.phone || '').trim().slice(0, 60),
486
+ note: String(b.note || '').trim().slice(0, 600),
487
+ createdAt: Date.now(),
488
+ };
489
+ if (!party.name) return json(res, 400, { error: 'нужно имя клиента (name)' });
490
+ db.parties.push(party);
491
+ save(db);
492
+ return json(res, 200, party);
493
+ }
494
+ if (req.method === 'PUT' && seg[1] === 'parties' && seg[2]) {
495
+ const p = db.parties.find(x => x.id === seg[2]);
496
+ if (!p) return json(res, 404, { error: 'нет такой группы' });
497
+ const b = await readBody(req);
498
+ for (const k of ['name', 'email', 'phone', 'note']) if (b[k] !== undefined) p[k] = String(b[k]).slice(0, 600);
499
+ save(db);
500
+ return json(res, 200, p);
501
+ }
502
+ if (req.method === 'DELETE' && seg[1] === 'parties' && seg[2]) {
503
+ // Своя группа не удаляется — это «я», а не запись в списке.
504
+ if (seg[2] === SELF_PARTY_ID) return json(res, 400, { error: 'своя группа не удаляется' });
505
+ const p = db.parties.find(x => x.id === seg[2]);
506
+ if (!p) return json(res, 404, { error: 'нет такой группы' });
507
+ // Каскадно: люди и поездки клиента уходят вместе с ним, иначе остаются
508
+ // сироты, которые видно только в db.json (UI фильтрует по группе).
509
+ for (const t of db.trips.filter(t => t.partyId === p.id)) cancelActiveByTrip(t.id);
510
+ db.trips = db.trips.filter(t => t.partyId !== p.id);
511
+ db.team = db.team.filter(m => m.partyId !== p.id);
512
+ db.parties = db.parties.filter(x => x.id !== p.id);
513
+ save(db);
514
+ return json(res, 200, { ok: true });
515
+ }
516
+
517
+ // ── team ──
518
+ if (req.method === 'POST' && path === '/api/team') {
519
+ const b = await readBody(req);
520
+ if (!b.nameRu?.trim()) return json(res, 400, { error: 'нужно ФИО (nameRu)' });
521
+ const partyId = db.parties.some(p => p.id === b.partyId) ? b.partyId : db.parties[0].id;
522
+ // Внутри группы лимит щедрый и существует только чтобы список не убежал:
523
+ // тариф считает КЛИЕНТОВ (partyLimit выше), а не членов семьи.
524
+ const lim = activePlan().travelerLimit;
525
+ if (db.team.filter(m => m.partyId === partyId).length >= lim) {
526
+ return json(res, 402, { error: `TRAVELER_LIMIT:${lim}` });
527
+ }
528
+ const card = {
529
+ nameRu: '', nameEn: '', position: '', dob: '', gender: '', citizenship: '',
530
+ passportNo: '', passportValidUntil: '', email: '', phone: '',
531
+ loyaltyAir: '', loyaltyHotel: '', notes: '', demo: false,
532
+ ...b,
533
+ partyId,
534
+ id: 't_' + uid(),
535
+ };
536
+ db.team.push(card);
537
+ save(db);
538
+ return json(res, 200, card);
539
+ }
540
+ if (req.method === 'PUT' && seg[1] === 'team' && seg[2]) {
541
+ const m = db.team.find(x => x.id === seg[2]);
542
+ if (!m) return json(res, 404, { error: 'нет такой карточки' });
543
+ const { id, ...fields } = await readBody(req);
544
+ Object.assign(m, fields);
545
+ save(db);
546
+ return json(res, 200, m);
547
+ }
548
+ if (req.method === 'DELETE' && seg[1] === 'team' && seg[2]) {
549
+ db.team = db.team.filter(m => m.id !== seg[2]);
550
+ save(db);
551
+ return json(res, 200, { ok: true });
552
+ }
553
+
554
+ // ── corporate / settings ──
555
+ if (req.method === 'PUT' && path === '/api/corporate') {
556
+ Object.assign(db.corporate, await readBody(req));
557
+ save(db);
558
+ return json(res, 200, db.corporate);
559
+ }
560
+ if (req.method === 'PUT' && path === '/api/settings') {
561
+ const body = await readBody(req);
562
+ for (const k of ['brandName', 'brandContact', 'brandNote']) {
563
+ if (k in body) body[k] = String(body[k] || '').slice(0, 400);
564
+ }
565
+ // Платные планы включаются лицензией: руками только free/dev.
566
+ if ('plan' in body && !SELF_SETTABLE_PLANS.includes(body.plan)) delete body.plan;
567
+ delete body.license;
568
+ delete body.licenseInfo;
569
+ Object.assign(db.settings, body);
570
+ save(db);
571
+ return json(res, 200, db.settings);
572
+ }
573
+
574
+ // ── license: ввести/сменить/убрать ключ и сразу проверить у воркера ──
575
+ if (req.method === 'POST' && path === '/api/license') {
576
+ const { key } = await readBody(req);
577
+ db.settings.license = typeof key === 'string' ? key.trim() : '';
578
+ db.settings.licenseInfo = null;
579
+ save(db);
580
+ await refreshLicense();
581
+ return json(res, 200, { billing: billingInfo() });
582
+ }
583
+
584
+ // ── trips CRUD ──
585
+ if (req.method === 'POST' && path === '/api/trips') {
586
+ const b = await readBody(req);
587
+ if (!b.from?.trim() || !b.to?.trim()) return json(res, 400, { error: 'нужны поля from и to (откуда/куда)' });
588
+ const travelerIds = Array.isArray(b.travelerIds) ? b.travelerIds : [];
589
+ // Whose trip this is: the body may say, otherwise it follows the travellers
590
+ // that were picked, otherwise it is the owner's own group.
591
+ const partyId = db.parties.some(p => p.id === b.partyId)
592
+ ? b.partyId
593
+ : db.team.find(m => travelerIds.includes(m.id))?.partyId || db.parties[0].id;
594
+ const trip = {
595
+ id: 'trip_' + uid(),
596
+ createdAt: Date.now(),
597
+ updatedAt: Date.now(),
598
+ partyId,
599
+ title: (b.title || '').trim() || `${b.from.trim()} → ${b.to.trim()}`,
600
+ travelerIds,
601
+ from: b.from.trim(),
602
+ to: b.to.trim(),
603
+ departDate: b.departDate || '',
604
+ returnDate: b.returnDate || '',
605
+ meetingAt: b.meetingAt || '',
606
+ budget: b.budget || '',
607
+ needTransport: b.needTransport !== false,
608
+ needHotel: b.needHotel !== false,
609
+ needTransfer: ['auto', 'yes', 'no'].includes(b.needTransfer) ? b.needTransfer : 'auto',
610
+ // Как везём и что важнее — явные поля, а не догадка из свободного текста
611
+ // (решение владельца 2026-07-25: HR возит разных людей по-разному).
612
+ travelClass: TRAVEL_CLASSES.includes(b.travelClass) ? b.travelClass : 'economy',
613
+ // Which language the agent answers in — the UI's own switch, not a guess.
614
+ lang: LANGS.includes(b.lang) ? b.lang : 'en',
615
+ priority: PRIORITIES.includes(b.priority) ? b.priority : 'optimal',
616
+ criteria: b.criteria || '',
617
+ // цепочка: якорный адрес (конференция/офис — отель ищется рядом) + слоты
618
+ anchor: String(b.anchor || '').slice(0, 240),
619
+ legs: sanitizeLegs(b.legs),
620
+ status: 'draft',
621
+ options: [],
622
+ summary: '',
623
+ recommendation: { text: '', ids: [], comboTotal: '' },
624
+ bookingReports: [],
625
+ blocks: { letter: '', costTable: '', checklist: '' },
626
+ activity: [],
627
+ };
628
+ addActivity(trip, 'поездка создана');
629
+ db.trips.unshift(trip);
630
+ save(db);
631
+ return json(res, 200, trip);
632
+ }
633
+ if (seg[1] === 'trips' && seg[2]) {
634
+ const t = db.trips.find(x => x.id === seg[2]);
635
+ if (!t) return json(res, 404, { error: 'нет такой поездки' });
636
+
637
+ if (req.method === 'PUT' && !seg[3]) {
638
+ if (!['draft', 'options'].includes(t.status)) {
639
+ return json(res, 400, { error: `править поездку можно только в статусах draft/options (сейчас: ${t.status})` });
640
+ }
641
+ const b = await readBody(req);
642
+ for (const k of ['title', 'from', 'to', 'departDate', 'returnDate', 'meetingAt', 'budget', 'criteria']) {
643
+ if (b[k] !== undefined) t[k] = b[k];
644
+ }
645
+ if (b.anchor !== undefined) t.anchor = String(b.anchor || '').slice(0, 240);
646
+ if (b.legs !== undefined) t.legs = sanitizeLegs(b.legs);
647
+ for (const k of ['needTransport', 'needHotel']) if (b[k] !== undefined) t[k] = !!b[k];
648
+ if (['auto', 'yes', 'no'].includes(b.needTransfer)) t.needTransfer = b.needTransfer;
649
+ if (TRAVEL_CLASSES.includes(b.travelClass)) t.travelClass = b.travelClass;
650
+ if (PRIORITIES.includes(b.priority)) t.priority = b.priority;
651
+ if (Array.isArray(b.travelerIds)) t.travelerIds = b.travelerIds;
652
+ if (!String(t.title || '').trim()) t.title = `${t.from} → ${t.to}`;
653
+ touch(t);
654
+ save(db);
655
+ return json(res, 200, t);
656
+ }
657
+ if (req.method === 'DELETE' && !seg[3]) {
658
+ cancelActiveByTrip(t.id);
659
+ db.trips = db.trips.filter(x => x.id !== t.id);
660
+ save(db);
661
+ return json(res, 200, { ok: true });
662
+ }
663
+
664
+ // ── trip actions ──
665
+ if (req.method === 'POST' && seg[3] === 'search') {
666
+ // The language is re-read on every run: switch EN→UK in the topbar, search
667
+ // again, and the new options come back in Ukrainian.
668
+ const sb = await readBody(req).catch(() => ({}));
669
+ if (LANGS.includes(sb.lang)) t.lang = sb.lang;
670
+ if (!['draft', 'options'].includes(t.status)) {
671
+ return json(res, 400, { error: `поиск доступен из статусов draft/options (сейчас: ${t.status})` });
672
+ }
673
+ if (!tripTravelers(t).length) return json(res, 400, { error: 'выберите хотя бы одного путника из команды' });
674
+ // повторный поиск перезаписывает прошлый подбор
675
+ t.options = [];
676
+ t.summary = '';
677
+ t.recommendation = { text: '', ids: [], comboTotal: '' };
678
+ t.notFound = '';
679
+ t.status = 'searching';
680
+ addActivity(t, 'поиск вариантов запущен');
681
+ touch(t);
682
+ save(db);
683
+ return json(res, 200, { jobId: startSearch(t).id });
684
+ }
685
+ if (req.method === 'POST' && seg[3] === 'book') {
686
+ const { n } = await readBody(req);
687
+ const num = Number(n);
688
+ if (!Number.isInteger(num)) return json(res, 400, { error: 'нужен номер варианта: { "n": 2 }' });
689
+ // из ready_to_pay тоже можно — так оформляется связка (второй вариант следом)
690
+ if (!['options', 'ready_to_pay'].includes(t.status)) {
691
+ return json(res, 400, { error: `оформление доступно из статуса options (сейчас: ${t.status})` });
692
+ }
693
+ if (!t.options.some(o => o.n === num)) return json(res, 400, { error: `варианта №${num} нет в подборе` });
694
+ if (!db.team.some(m => m.id === t.travelerIds[0])) return json(res, 400, { error: 'у поездки нет путника с карточкой' });
695
+ const prev = t.status;
696
+ t.status = 'booking';
697
+ addActivity(t, `оформление варианта №${num} запущено`);
698
+ touch(t);
699
+ save(db);
700
+ return json(res, 200, { jobId: startBook(t, num, prev).id });
701
+ }
702
+ if (req.method === 'POST' && seg[3] === 'paid') {
703
+ if (t.status !== 'ready_to_pay') {
704
+ return json(res, 400, { error: `«Оплачено» доступно из статуса ready_to_pay (сейчас: ${t.status})` });
705
+ }
706
+ t.status = 'paid';
707
+ addActivity(t, 'HR подтвердила оплату — генерирую письмо, смету и чеклист');
708
+ touch(t);
709
+ save(db);
710
+ return json(res, 200, { jobId: startFinalize(t).id });
711
+ }
712
+ /* The client proposal: the whole selection as one sendable page (Pro).
713
+ A GET that returns HTML, so the UI just opens it in a tab — from there it
714
+ prints to PDF, saves to a file, or gets pasted into a mail. Generated on
715
+ THIS machine; nothing about the client leaves it. */
716
+ if (req.method === 'GET' && seg[3] === 'proposal') {
717
+ const plan = effectivePlanId();
718
+ if (plan !== 'pro' && plan !== 'dev') { res.writeHead(402); res.end('PARTY_LIMIT'); return; }
719
+ const lang = ['en', 'ru', 'uk'].includes(url.searchParams.get('lang')) ? url.searchParams.get('lang') : 'en';
720
+ const html = buildProposalHtml({
721
+ trip: t,
722
+ party: db.parties.find(p => p.id === t.partyId) || null,
723
+ travellers: tripTravelers(t),
724
+ brand: { name: db.settings.brandName, contact: db.settings.brandContact, note: db.settings.brandNote },
725
+ lang,
726
+ });
727
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
728
+ res.end(html);
729
+ return;
730
+ }
731
+
732
+ if (req.method === 'POST' && seg[3] === 'cancel') {
733
+ t.status = 'cancelled';
734
+ addActivity(t, 'поездка отменена');
735
+ touch(t);
736
+ save(db);
737
+ cancelActiveByTrip(t.id);
738
+ return json(res, 200, t);
739
+ }
740
+ }
741
+
742
+ json(res, 404, { error: 'unknown endpoint' });
743
+ } catch (err) {
744
+ json(res, 500, { error: String(err?.message || err) });
745
+ }
746
+ });
747
+
748
+ mkdirSync(resolve(ROOT, 'data'), { recursive: true });
749
+ server.listen(PORT, '127.0.0.1', () => {
750
+ console.log(`Travel Assistant → http://localhost:${PORT}`);
751
+ });
752
+
753
+ // Проверяем лицензию на старте и раз в 6 часов (best-effort; офлайн не роняет кэш).
754
+ if (db.settings.license) refreshLicense().catch(() => {});
755
+ setInterval(() => { if (db.settings.license) refreshLicense().catch(() => {}); }, RECHECK_MS).unref();