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,390 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Client Finder — the local server behind the panel.
4
+ *
5
+ * npm run backend:cf # → http://localhost:5546
6
+ *
7
+ * Local-first, exactly like Work Finder and Travel: it runs on the seller's own
8
+ * machine, spends the seller's own Claude key through the `claude` CLI, and writes
9
+ * results to their disk. Nothing about a prospect ever reaches us.
10
+ *
11
+ * The panel is a thin client over four calls: what can I sell (`/api/state`), is
12
+ * this brief allowed (`/api/check`), go (`/api/run`), how is it going
13
+ * (`/api/jobs/:id`). The guard on `/api/check` and `/api/run` is plain JavaScript
14
+ * and runs before any token is spent — see lib/scope.mjs for why that matters.
15
+ */
16
+ import { createServer } from 'node:http';
17
+ import { writeFileSync, mkdirSync } from 'node:fs';
18
+ import { resolve, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { runClaude, extractJson } from './lib/agent.mjs';
21
+ import { researchPrompt, briefToIcp, draftPrompt } from './lib/prompts.mjs';
22
+ import { checkBrief } from './lib/scope.mjs';
23
+ import { PRODUCTS, byId } from './lib/catalog.mjs';
24
+ import { CHANNELS, needsBrowser } from './lib/channels.mjs';
25
+ import { FORMATS, formatById, checkDraft } from './lib/formats.mjs';
26
+ import { verifyAll, verifyProspect } from './lib/verify.mjs';
27
+ import { toMarkdown } from './lib/report.mjs';
28
+ import { createJob, getJob, view, listJobs, log, cancel } from './lib/jobs.mjs';
29
+ import { status as browserStatus, connect as browserConnect, openTab } from './lib/browser.mjs';
30
+ import { deliveryFor } from './lib/deliver.mjs';
31
+ import { checkPartner } from './lib/access.mjs';
32
+ import { probeEngine, startLogin, submitLoginCode } from './lib/engine.mjs';
33
+
34
+ /* A REAL Anthropic key, not merely something that starts with "sk-".
35
+ That looser check let the literal `sk-subscription` through — a placeholder a
36
+ previous panel sent when the machine was signed in with a subscription — and it was
37
+ then exported as ANTHROPIC_API_KEY, overriding that very sign-in. Every run failed
38
+ with "Invalid API key" for exactly the setup the subscription path exists for.
39
+ Empty means "use whatever auth this machine already has", which is what we want. */
40
+ const isRealKey = (v) => typeof v === 'string' && /^sk-ant-/.test(v.trim());
41
+
42
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)));
43
+ const PORT = Number(process.env.PORT) || 5546;
44
+
45
+ /** Origins allowed to drive this server: the panel in dev, the hub, and prod. */
46
+ const UI_ORIGINS = new Set([
47
+ 'http://localhost:4311', // the panel in dev (port assigned by scripts/new-app.mjs)
48
+ 'http://localhost:4300',
49
+ 'http://localhost:8199',
50
+ 'https://qiksy.app',
51
+ ]);
52
+
53
+ const json = (res, code, body) => {
54
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
55
+ res.end(JSON.stringify(body));
56
+ };
57
+
58
+ const readBody = (req) =>
59
+ new Promise((res) => {
60
+ let b = '';
61
+ req.on('data', (c) => (b += c));
62
+ req.on('end', () => {
63
+ try {
64
+ res(JSON.parse(b || '{}'));
65
+ } catch {
66
+ res({});
67
+ }
68
+ });
69
+ });
70
+
71
+ /* ── the run ──────────────────────────────────────────────────────────────── */
72
+
73
+ async function execute(job) {
74
+ const product = byId(job.input.productId);
75
+ const icp = briefToIcp({ product, brief: job.input.brief, channels: job.input.channels });
76
+
77
+ job.phase = 'searching';
78
+ log(job, `Ищу покупателей для «${product.name}»`);
79
+ if (needsBrowser(job.input.channels)) {
80
+ // Browser channels are declared but not wired yet: say so out loud rather than
81
+ // silently searching the open web and letting the picked channel imply more
82
+ // than happened.
83
+ log(job, '⚠ Каналы с логином (LinkedIn и др.) пока не подключены — этот прогон идёт по открытому вебу');
84
+ }
85
+
86
+ const res = await runClaude({
87
+ prompt: researchPrompt(icp, job.input.count),
88
+ model: job.input.model,
89
+ apiKey: job.input.apiKey,
90
+ signal: job.controller.signal,
91
+ onEvent: (line) => log(job, line),
92
+ /* Verify AS IT ARRIVES, not once at the end.
93
+ A prospect streamed mid-run used to carry no `verified` field at all, and the
94
+ final sweep ran after the cancelled/failed early-returns — so a run stopped at
95
+ 7 of 10, or timed out, left every row unproven while the UI showed nothing
96
+ about it. On a product whose landing sells "проверенные ссылки" that is the
97
+ worst possible silence. Pure HTTP: no model, no tokens. */
98
+ onPartial: (p) => {
99
+ job.prospects.push(p);
100
+ log(job, `✅ ${job.prospects.length}. ${p.company}`);
101
+ void verifyProspect(p)
102
+ .then((v) => Object.assign(p, { verified: v.verified }))
103
+ .catch(() => {
104
+ /* a blip — the final sweep still covers it */
105
+ });
106
+ },
107
+ });
108
+
109
+ if (job.status === 'cancelled') return;
110
+
111
+ if (!res.ok) {
112
+ job.status = 'failed';
113
+ /* A twenty-minute wait should not be rewarded with `timeout after 1200s`. The
114
+ findings are real and writable — say that, because the panel now allows
115
+ drafting from a stopped run. */
116
+ job.error = /timeout/i.test(String(res.error))
117
+ ? 'Прогон шёл 20 минут и остановлен по лимиту. Найденное осталось — писать по нему можно.'
118
+ : res.error;
119
+ job.errorRaw = res.error;
120
+ job.finishedAt = Date.now();
121
+ return;
122
+ }
123
+
124
+ job.costUsd = res.costUsd;
125
+
126
+ // The model-side backstop fired: the brief looked like a brief to the guard but
127
+ // was not one. Surface it as a refusal, not as an empty result.
128
+ const refusal = String(res.result || '').match(/OUT_OF_SCOPE:\s*(\{.*\})/);
129
+ if (refusal) {
130
+ job.status = 'refused';
131
+ try {
132
+ job.error = JSON.parse(refusal[1]).say || 'Это вне задачи панели.';
133
+ } catch {
134
+ job.error = 'Это вне задачи панели.';
135
+ }
136
+ job.finishedAt = Date.now();
137
+ return;
138
+ }
139
+
140
+ const parsed = extractJson(res.result);
141
+ let prospects = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.prospects) ? parsed.prospects : job.prospects;
142
+ if (!prospects.length) prospects = job.prospects;
143
+
144
+ /* The final array parsed from the model is a DIFFERENT array than the streamed one,
145
+ so the verdicts earned above have to be carried across by url — otherwise every
146
+ page is fetched a second time for an answer already in hand. */
147
+ const alreadyKnown = new Map(job.prospects.filter((p) => p.verified).map((p) => [p.url, p.verified]));
148
+ for (const p of prospects) {
149
+ if (!p.verified && alreadyKnown.has(p.url)) p.verified = alreadyKnown.get(p.url);
150
+ }
151
+
152
+ job.phase = 'verifying';
153
+ log(job, `Проверяю ссылки у ${prospects.length} кандидат(ов)…`);
154
+ prospects = await verifyAll(prospects, (p) => {
155
+ const v = p.verified;
156
+ log(job, `${!v.siteAlive ? '🔴' : v.dead.length ? '🟠' : '🟢'} ${p.company}`);
157
+ });
158
+
159
+ job.prospects = prospects;
160
+ job.phase = 'done';
161
+ job.status = 'done';
162
+ job.finishedAt = Date.now();
163
+
164
+ // Results live on the seller's disk, in the same folder the CLI writes to, so
165
+ // both entry points produce one history rather than two.
166
+ try {
167
+ // CF_DATA_DIR lets a packaged install keep results in the user's own folder
168
+ // (~/.qiksy/client-finder). Without it they would land inside the npx cache, which
169
+ // is wiped without warning. Unset → the CLI's own folder, so both entry points
170
+ // still write one history.
171
+ const runs = process.env.CF_DATA_DIR ? resolve(process.env.CF_DATA_DIR, 'runs') : resolve(ROOT, 'runs');
172
+ mkdirSync(runs, { recursive: true });
173
+ const stamp = new Date(job.startedAt).toISOString().replace(/[:.]/g, '-').slice(0, 19);
174
+ const meta = {
175
+ icp: 'panel',
176
+ model: job.input.model,
177
+ startedAt: new Date(job.startedAt).toISOString(),
178
+ finishedAt: new Date().toISOString(),
179
+ durationMs: job.finishedAt - job.startedAt,
180
+ costUsd: job.costUsd,
181
+ requested: job.input.count,
182
+ returned: prospects.length,
183
+ notes: `Панель · продукт ${product.name} · каналы: ${job.input.channels.join(', ') || 'web'}`,
184
+ };
185
+ writeFileSync(resolve(runs, `${stamp}-panel.json`), JSON.stringify({ icp, meta, prospects }, null, 2));
186
+ writeFileSync(resolve(runs, `${stamp}-panel.md`), toMarkdown({ icp, prospects, meta }));
187
+ } catch (e) {
188
+ log(job, `не смог записать отчёт: ${e.message}`);
189
+ }
190
+ }
191
+
192
+ /* ── http ─────────────────────────────────────────────────────────────────── */
193
+
194
+ const server = createServer(async (req, res) => {
195
+ const url = new URL(req.url, 'http://x');
196
+ const path = url.pathname;
197
+ const seg = path.split('/').filter(Boolean);
198
+
199
+ const origin = req.headers.origin;
200
+ if (origin && UI_ORIGINS.has(origin)) {
201
+ res.setHeader('Access-Control-Allow-Origin', origin);
202
+ res.setHeader('Vary', 'Origin');
203
+ if (req.method === 'OPTIONS') {
204
+ // Chrome Private Network Access: a PUBLIC page (qiksy.app, https) reaching this
205
+ // LOCAL server (localhost) is blocked unless the preflight is answered with this
206
+ // header. Without it the panel connects from the dev origin but never from the
207
+ // live domain — which reads to the user as "engine not running".
208
+ if (req.headers['access-control-request-private-network'] === 'true') {
209
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
210
+ }
211
+ res.writeHead(204, {
212
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
213
+ 'Access-Control-Allow-Headers': 'Content-Type',
214
+ 'Access-Control-Max-Age': '86400',
215
+ });
216
+ return res.end();
217
+ }
218
+ }
219
+
220
+ try {
221
+ /* Start Anthropic's own sign-in on this machine and hand back ITS link, so the
222
+ panel can offer "open" and "copy" instead of firing a browser off-screen. We
223
+ never build a "connect with Claude" of our own — this launches theirs, and the
224
+ panel polls /api/engine until it turns green. */
225
+ if (req.method === 'POST' && path === '/api/engine/login') {
226
+ return json(res, 200, await startLogin());
227
+ }
228
+
229
+ /* The callback page shows a code instead of redirecting when it cannot reach the
230
+ CLI's loopback (another machine, or a browser that won't open localhost). This
231
+ is where that code goes. */
232
+ if (req.method === 'POST' && path === '/api/engine/login/code') {
233
+ const { code } = await readBody(req);
234
+ return json(res, 200, submitLoginCode(code));
235
+ }
236
+
237
+ /* Can this machine run the agent at all? The gate asks BEFORE demanding a key,
238
+ because the CLI may already be logged in with a Claude subscription — in which
239
+ case a key is not just optional, it is irrelevant. ?force=1 re-probes after the
240
+ user has gone and logged in. */
241
+ if (req.method === 'GET' && path === '/api/engine') {
242
+ const force = /[?&]force=1/.test(req.url || '');
243
+ return json(res, 200, await probeEngine({ force }));
244
+ }
245
+
246
+ if (req.method === 'GET' && path === '/api/state') {
247
+ return json(res, 200, { products: PRODUCTS, channels: CHANNELS, formats: FORMATS, jobs: listJobs() });
248
+ }
249
+
250
+ // Validate as you type. Free — no model, no tokens.
251
+ if (req.method === 'POST' && path === '/api/check') {
252
+ const { brief } = await readBody(req);
253
+ return json(res, 200, checkBrief(brief));
254
+ }
255
+
256
+ if (req.method === 'POST' && path === '/api/access') {
257
+ const { partner, account } = await readBody(req);
258
+ return json(res, 200, await checkPartner(partner, account));
259
+ }
260
+
261
+ if (req.method === 'POST' && path === '/api/run') {
262
+ const body = await readBody(req);
263
+ const access = await checkPartner(body.partner, body.account);
264
+ if (!access.ok) return json(res, 403, { reason: 'Доступ только для партнёров', hint: access.reason });
265
+ const product = byId(body.productId);
266
+ // The product is a choice from OUR catalog, never free text — that is what
267
+ // makes "sell my own thing here" structurally impossible rather than merely
268
+ // discouraged.
269
+ if (!product) return json(res, 400, { error: 'Выберите продукт из списка' });
270
+
271
+ const guard = checkBrief(body.brief);
272
+ if (!guard.ok) return json(res, 422, guard);
273
+
274
+ const job = createJob({
275
+ productId: product.id,
276
+ channels: Array.isArray(body.channels) && body.channels.length ? body.channels : ['web'],
277
+ brief: String(body.brief).trim(),
278
+ /* Only a REAL key travels on. Anything else — a placeholder from a stale
279
+ client, junk from a typo — would be exported as ANTHROPIC_API_KEY and
280
+ override the machine's own Claude sign-in, breaking the subscription path
281
+ for the one user it exists for. Empty means "use whatever this machine
282
+ already has", which is exactly right. */
283
+ apiKey: typeof body.apiKey === 'string' && isRealKey(body.apiKey) ? body.apiKey : '',
284
+ count: Math.min(Math.max(Number(body.count) || 10, 1), 30),
285
+ model: body.model === 'opus' ? 'opus' : 'sonnet',
286
+ });
287
+ // Fire and forget: the panel polls /api/jobs/:id.
288
+ execute(job).catch((e) => {
289
+ job.status = 'failed';
290
+ job.error = e.message;
291
+ job.finishedAt = Date.now();
292
+ });
293
+ return json(res, 200, view(job));
294
+ }
295
+
296
+
297
+ /* ── drafts ───────────────────────────────────────────────────────────────
298
+ * A separate, cheap pass over prospects we already verified: no web tools, no
299
+ * second search. That also makes re-drafting the same list for another
300
+ * platform free of another research bill.
301
+ */
302
+ if (req.method === 'POST' && path === '/api/draft') {
303
+ const body = await readBody(req);
304
+ const access = await checkPartner(body.partner, body.account);
305
+ if (!access.ok) return json(res, 403, { error: access.reason });
306
+ const job = getJob(body.jobId);
307
+ if (!job) return json(res, 404, { error: 'нет такого прогона' });
308
+ const product = byId(job.input.productId);
309
+ 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;
313
+ if (!picked.length) return json(res, 400, { error: 'нечего писать — список пуст' });
314
+
315
+ const out = await runClaude({
316
+ prompt: draftPrompt({
317
+ product,
318
+ prospects: picked,
319
+ format,
320
+ sender: body.sender,
321
+ language: body.language,
322
+ platform: body.platform,
323
+ }),
324
+ model: job.input.model,
325
+ /* Same rule, and the job's own key as the fallback — it was filtered by the
326
+ same check when the run started. */
327
+ apiKey:
328
+ typeof body.apiKey === 'string' && isRealKey(body.apiKey)
329
+ ? body.apiKey
330
+ : job.input.apiKey || '',
331
+ // Drafting reads what we already proved; giving it the web again would
332
+ // invite it to "check something" and pay for a second research run.
333
+ allowedTools: [],
334
+ });
335
+ if (!out.ok) return json(res, 500, { error: out.error });
336
+
337
+ const parsed = extractJson(out.result);
338
+ const list = Array.isArray(parsed) ? parsed : [];
339
+ const drafts = picked.map((p, i) => {
340
+ const d = list[i] || {};
341
+ return {
342
+ company: p.company,
343
+ url: p.url,
344
+ contact: p.contact || null,
345
+ subject: d.subject || '',
346
+ body: d.body || '',
347
+ check: checkDraft(format, d),
348
+ };
349
+ });
350
+ return json(res, 200, { format: format.id, costUsd: out.costUsd, drafts });
351
+ }
352
+
353
+
354
+ /* ── browser ("выход в браузер") ──────────────────────────────────────────
355
+ * A switch, not a mode of operation: off, drafts land in the panel and you
356
+ * paste them yourself; on, we open the right window in a Chrome you are
357
+ * logged into. Pressing send stays yours either way — that is the line
358
+ * between a tool and the automation that gets accounts restricted.
359
+ */
360
+ if (req.method === 'GET' && path === '/api/browser') {
361
+ return json(res, 200, await browserStatus());
362
+ }
363
+ if (req.method === 'POST' && path === '/api/browser/connect') {
364
+ return json(res, 200, await browserConnect());
365
+ }
366
+ if (req.method === 'POST' && path === '/api/browser/open') {
367
+ const { format, draft } = await readBody(req);
368
+ if (!draft) return json(res, 400, { error: 'нечего открывать' });
369
+ const plan = deliveryFor(String(format || 'email'), draft);
370
+ const out = await openTab(plan.url);
371
+ return json(res, out.ok ? 200 : 409, { ...plan, ...out });
372
+ }
373
+
374
+ if (req.method === 'GET' && seg[1] === 'jobs' && seg[2]) return json(res, 200, view(getJob(seg[2])));
375
+ if (req.method === 'POST' && seg[1] === 'jobs' && seg[2] && seg[3] === 'cancel') {
376
+ return json(res, 200, { ok: cancel(seg[2]) });
377
+ }
378
+ if (req.method === 'GET' && path === '/api/jobs') return json(res, 200, listJobs());
379
+
380
+ json(res, 404, { error: 'not found' });
381
+ } catch (e) {
382
+ json(res, 500, { error: e.message });
383
+ }
384
+ });
385
+
386
+ server.listen(PORT, () => {
387
+ console.log(`\n🔎 Client Finder backend → http://localhost:${PORT}`);
388
+ console.log(` продуктов в каталоге: ${PRODUCTS.length} · каналов: ${CHANNELS.filter((c) => c.ready).length} готов(ы)`);
389
+ console.log(` панель: http://localhost:4309\n`);
390
+ });
@@ -0,0 +1,58 @@
1
+ # Travel Assistant (web)
2
+
3
+ Ассистент поездок: headless-агенты Claude Code ищут в вебе билеты, отели и трансферы под маршрут, даты, класс и приоритет, а браузерный агент (Playwright MCP + Chrome) доводит выбранный вариант до **экрана оплаты** и останавливается — карту вводит только человек.
4
+
5
+ **Кому это (решение владельца 25.07.2026).** Основной сценарий — HR, который возит людей на конференции и встречи («Киев → Брюссель, отель рядом с площадкой, трансфер»). Тот же инструмент закрывает турагентство (много клиентов, каждому — своё предложение) и человека, который планирует поездку себе и семье. Тариф считает **группы**, а не людей: бесплатно — одна своя группа, платно — клиенты/сотрудники без лимита.
6
+
7
+ **Чем отличается от глобальных AI-планировщиков.** Агент обязан проверить **локальный рынок обоих городов**, а не только Booking/Google Flights: национальные ЖД, местное приложение такси, локальные сервисы аренды квартир, прямой тариф отеля — и честно предупредить, если местный сервис требует локальный номер телефона или не принимает иностранную карту.
8
+
9
+ ## Запуск
10
+
11
+ ```bash
12
+ npm start # сервер → http://localhost:5545
13
+ npm run browser # поднять/переиспользовать CDP Chrome на :9222 (для оформления броней)
14
+ ```
15
+
16
+ Требуется: Node 18+, Claude Code CLI (`claude`) с активной подпиской, Google Chrome.
17
+
18
+ Переменные окружения: `TA_PORT` — порт сервера (по умолчанию 5545), `TA_DB` — путь к базе (по умолчанию `data/db.json`).
19
+
20
+ ## Как пользоваться
21
+
22
+ 1. **Путешественники** — плитка группы («Я и моя семья» или клиент агентства) + карточки людей внутри (ФИО, паспорт, лояльность, особенности). Свежая установка пустая; данные живут только локально в `data/db.json`. Каждая следующая группа = клиент, это платный тариф.
23
+ 2. **Консоль на главном экране** — «[кто едет] едет в [город]», откуда/туда/обратно, бюджет, состав, **класс** (эконом · премиум · бизнес) и **приоритет** (дешевле · оптимально · быстрее · комфорт), что искать — и одна кнопка, которая создаёт поездку и сразу запускает агента. Редкое (критерии текстом, маршрут-цепочка, встреча) — за «Больше параметров».
24
+ 3. **Поиск** — агент идёт по источникам (Google Flights → Skyscanner → Kiwi; Booking → Airbnb; аэроэкспрессы → Welcome Pickups), учитывает тайминг прилёта против времени встречи и часовые пояса, найденные варианты прилетают в карточку поездки вживую. Итог: до 5 вариантов + рекомендация связки.
25
+ 4. **«Оформить №N»** — браузерный агент открывает вариант в новой вкладке, заполняет пассажира по карточке (как гость, без создания аккаунтов) и **останавливается на экране оплаты**. Отчёт: что заполнено, итоговая сумма, предупреждения. Капча или логин — агент вернёт ссылку для ручного продолжения.
26
+ 5. **«Оплачено ✓»** — после оплаты картой ассистент генерирует три блока для копирования: письмо путнику, смету расходов (с суточными, если заданы) и чеклист перед поездкой.
27
+ 6. **«Предложение клиенту»** (Pro) — вся подборка одной самодостаточной HTML-страницей под именем агентства: маршрут, кто едет, варианты с ценами, рекомендованная связка. Открыть, отправить, распечатать в PDF. Генерируется локально (`lib/proposal.mjs`), данные клиента машину не покидают.
28
+
29
+ **Пока агент работает** видно, что он делает словами — «ищу…», «читаю skyup.aero», «нашёл: Motel One» — плюс список пройденных источников, таймер и счётчик найденного; сырой лог инструментов свёрнут под «Технический лог». Джобы живут в памяти, поэтому при перезапуске сервера зависшие поездки откатываются сами (`searching → draft`, `booking → options`) с записью в истории поездки.
30
+
31
+ ## Настройки
32
+
33
+ - **Модель**: haiku (дёшево) / sonnet (баланс, по умолчанию) / opus.
34
+ - **Поиск через браузер** — глубже (сайты с логином), но медленнее; по умолчанию поиск идёт через WebSearch/WebFetch.
35
+ - **Доп. инструкции** — свободный текст, попадает в промпт поиска.
36
+ - **Корпоративные дефолты** — юр.лицо для счетов, класс перелёта/поезда, звёздность отеля, per diem, валюта.
37
+
38
+ ## Архитектура
39
+
40
+ ```
41
+ server.mjs Node http (без зависимостей), порт 5545, только 127.0.0.1
42
+ lib/store.mjs data/db.json — команда, поездки, настройки (+ демо-сид)
43
+ lib/agent.mjs запуск `claude -p` (stream-json), парсинг результата
44
+ lib/prompts.mjs промпты: поиск / оформление / финальные документы
45
+ lib/jobs.mjs очередь фоновых задач (макс. 2 агента параллельно)
46
+ scripts/approver-mcp.mjs MCP-сервер разрешений для headless-агентов
47
+ scripts/launch-browser.mjs CDP Chrome на :9222 (переиспользует запущенный)
48
+ public/ UI (vanilla JS)
49
+ ```
50
+
51
+ **Разрешения агентов:** в глобальных `~/.claude/settings.json` инструменты WebSearch/WebFetch стоят в списке `ask`, что в headless-режиме означает автоотказ. Поэтому агенты запускаются с `--permission-prompt-tool mcp__approver__approve` — мини-MCP-сервер одобряет только белый список (WebSearch, WebFetch, mcp__playwright__*), всё прочее запрещено. `--tools` жёстко ограничивает встроенный набор (агентам недоступны Bash/Edit/Write).
52
+
53
+ ## Безопасность
54
+
55
+ - **Карту вводит только человек.** Агент никогда не вводит номер карты/CVC/срок и не нажимает Pay/Confirm/Buy — это прошито в промпт оформления и продублировано в UI.
56
+ - Паспортные данные живут только в локальном `data/db.json`: в поисковый промпт не попадают, в логах джобов маскируются (`FK1***56`), дев-сервер студии их не раздаёт.
57
+ - Агент не создаёт аккаунты и не вводит пароли; при капче/логине останавливается и передаёт ссылку HR.
58
+ - Сервер слушает только `127.0.0.1`.
@@ -0,0 +1,174 @@
1
+ # Travel Assistant (web) — контракт архитектуры
2
+
3
+ Веб-версия ассистента `~/Desktop/travel-assistant/` (Cowork + CLAUDE.md). Архитектура — точная калька с Work Finder (`~/Desktop/work-finder/`): zero-dependency Node http-сервер + headless-агенты `claude -p` + vanilla JS UI. Этот файл — контракт между backend, frontend и тестами. Менять контракт можно только синхронно во всех трёх местах.
4
+
5
+ ## Мета
6
+
7
+ - Папка: `qiksy-studio/products/travel-assistant/`
8
+ - Порт: **5545** (у Work Finder — 5544). Переопределяется env `TA_PORT`, а не `PORT` — общий `PORT` читают чужие дев-серверы в воркспейсе, и коллизия увела бы бэкенд не на тот порт.
9
+ - Путь к базе: `data/db.json`, переопределяется env `TA_DB` (нужно тестам).
10
+ - Node ≥ 18, **без npm-зависимостей**. `npm start` → `node server.mjs`.
11
+ - Язык UI и всех текстов — **русский**.
12
+ - Домен и правила поведения агентов берём из `~/Desktop/travel-assistant/CLAUDE.md` (источники поиска, тайминги/часовые пояса, критерии, «не выдумывай», ≤5 вариантов, рейтинг ≥7.0, стоп перед оплатой).
13
+
14
+ ## Файлы (владение при сборке)
15
+
16
+ ```
17
+ products/travel-assistant/
18
+ server.mjs # http-сервер + роутинг + статика public/ [BE]
19
+ lib/store.mjs # db.json: load/save (атомарно), seed [BE]
20
+ lib/jobs.mjs # очередь фоновых задач (макс 2) [BE]
21
+ lib/agent.mjs # запуск claude -p, парсинг stream-json [BE]
22
+ lib/prompts.mjs # промпты: search / book / finalize [BE]
23
+ scripts/approver-mcp.mjs # MCP-сервер разрешений для headless-агентов [BE]
24
+ scripts/launch-browser.mjs # CDP Chrome :9222 (переиспользует живой) [BE]
25
+ package.json # name, scripts: start, browser [BE]
26
+ README.md # как запустить + что умеет [BE]
27
+ public/index.html # разметка всех вью [FE]
28
+ public/app.js # состояние, поллинг, рендер [FE]
29
+ public/styles.css # дизайн-токены + компоненты [FE]
30
+ docs/CONTRACT.md # этот файл
31
+ ```
32
+
33
+ Тесты: `qiksy-studio/tests/travel-assistant.spec.mjs` [TESTS].
34
+
35
+ ## Схема db.json
36
+
37
+ ```jsonc
38
+ {
39
+ "team": [{
40
+ "id": "t_abc123", "nameRu": "Иванов Алексей Сергеевич", "nameEn": "Ivanov Oleksii",
41
+ "position": "Senior Backend Developer", "dob": "14.03.1990", "gender": "М",
42
+ "citizenship": "Ukraine", "passportNo": "FK123456", "passportValidUntil": "22.08.2030",
43
+ "email": "a.ivanov@company.com", "phone": "+380 67 111 22 33",
44
+ "loyaltyAir": "Lufthansa Miles&More 992100012345", "loyaltyHotel": "Booking Genius (level 2)",
45
+ "notes": "окно, не любит ранние рейсы до 7:00, рост 192 см — Economy Plus / exit row",
46
+ "demo": true
47
+ }],
48
+ "corporate": {
49
+ "legalEntity": "", "travelAgency": "", "geniusLevel": "", "airlineCodes": "",
50
+ "insurance": "", "transferVendor": "",
51
+ "flightClass": "эконом", "trainClass": "2 класс", "hotelStars": "3-4*",
52
+ "perDiem": "", "hrTimezone": "Europe/Kyiv", "currency": "EUR"
53
+ },
54
+ "trips": [{
55
+ "id": "trip_abc123", "createdAt": 0, "updatedAt": 0,
56
+ "title": "Львов → Берлин", // генерится из from/to если пусто
57
+ "travelerIds": ["t_abc123"],
58
+ "from": "Львов", "to": "Берлин",
59
+ "departDate": "2026-08-15", "returnDate": "2026-08-20", // return может быть ""
60
+ "meetingAt": "встреча 10:00 16 августа", // опционально
61
+ "budget": "до 400€ на всё", // свободный текст
62
+ "needTransport": true, "needHotel": true, "needTransfer": "auto", // auto|yes|no
63
+ "criteria": "без пересадок, отель ближе к Alexanderplatz",
64
+ "status": "draft", // draft|searching|options|booking|ready_to_pay|paid|cancelled
65
+ "options": [], // см. «Формат Option»
66
+ "summary": "", // абзац-резюме от поискового агента
67
+ "recommendation": { "text": "", "ids": [], "comboTotal": "" },
68
+ "bookingReports": [], // [{ optionN, text, stoppedAt, totalShown, warnings:[], at }]
69
+ "blocks": { "letter": "", "costTable": "", "checklist": "" },
70
+ "activity": [] // [{ at, text }] — журнал событий поездки
71
+ }],
72
+ "settings": { "model": "sonnet", "browserSearch": false, "extraInstructions": "" }
73
+ }
74
+ ```
75
+
76
+ Статусная машина поездки: `draft → searching → options → booking → ready_to_pay → paid`; `cancelled` — из любого статуса; повторный поиск разрешён из `options` (перезаписывает options). Ошибка джоба возвращает статус назад (`searching→draft`, `booking→options`).
77
+
78
+ Seed: при первом старте (нет db.json) — 5 демо-карточек команды из CLAUDE.md ассистента (Иванов, Кравченко, Мельник, Шевченко, Бондар; `demo: true`), corporate/settings по умолчанию, trips пустой.
79
+
80
+ ## Формат Option (выход поискового агента, элемент `trip.options`)
81
+
82
+ ```jsonc
83
+ {
84
+ "n": 1, // сквозная нумерация: транспорт сначала, отели после
85
+ "kind": "flight", // flight|train|bus|hotel|transfer
86
+ "title": "Wizz Air W6 6051", // отель: название отеля
87
+ "brand": "Wizz Air",
88
+ "price": { "amount": 89, "currency": "EUR", "unit": "total" }, // unit: total|per_night
89
+ "badge": "recommended", // recommended|cheapest|null
90
+ "link": "https://...", // прямая ссылка на бронирование, НЕ главная страница
91
+ "notes": "",
92
+ // транспорт (flight/train/bus):
93
+ "depart": { "time": "14:30", "date": "2026-08-15", "place": "Львов LWO" },
94
+ "arrive": { "time": "16:05", "date": "2026-08-15", "place": "Берлин BER" },
95
+ "duration": "2 ч 35 мин", "direct": true,
96
+ "stops": [], // [{ "place": "Варшава WAW", "wait": "1 ч 20 мин" }]
97
+ "baggage": "ручная кладь 10 кг включена, багаж 20 кг +35€",
98
+ // отель (hotel):
99
+ "rating": { "score": 9.2, "reviews": 4494 }, "stars": 4,
100
+ "address": "Invalidenstraße 54, Mitte", "distanceKm": 1.1,
101
+ "nights": 5, "pricePerNight": 84, "totalPrice": 420,
102
+ "amenities": [{ "label": "бесплатная отмена", "green": true }, { "label": "Wi-Fi", "green": false }],
103
+ // трансфер (transfer):
104
+ "service": "FEX (аэропортовый экспресс)", "pickupAt": "BER T1", "vehicle": ""
105
+ }
106
+ ```
107
+
108
+ Ненужные для kind поля агент опускает. Ответ поискового агента целиком:
109
+
110
+ ```jsonc
111
+ { "options": [...максимум 5...], "summary": "Из найденного я бы выделил...",
112
+ "recommendation": { "text": "...", "ids": [1, 4], "comboTotal": "≈ 509€" },
113
+ "notFound": "" } // честное «не нашёл X, потому что Y» — если применимо
114
+ ```
115
+
116
+ ## REST API
117
+
118
+ Все ответы JSON. Ошибки: `{ "error": "текст" }` + соответствующий HTTP-код.
119
+
120
+ | Метод и путь | Что делает |
121
+ |---|---|
122
+ | `GET /api/state` | `{ team, corporate, trips, settings, jobs }` — всё сразу (jobs — активные + 20 последних) |
123
+ | `POST /api/team` | создать карточку (body = поля без id) |
124
+ | `PUT /api/team/:id` | обновить карточку |
125
+ | `DELETE /api/team/:id` | удалить карточку |
126
+ | `PUT /api/corporate` | обновить корпоративные настройки |
127
+ | `PUT /api/settings` | обновить настройки (model/browserSearch/extraInstructions) |
128
+ | `POST /api/trips` | создать поездку (status=draft) |
129
+ | `PUT /api/trips/:id` | править поля поездки (только в draft/options) |
130
+ | `DELETE /api/trips/:id` | удалить поездку |
131
+ | `POST /api/trips/:id/search` | старт поиска → job search, status=searching |
132
+ | `POST /api/trips/:id/book` | body `{ "n": 2 }` → job book, status=booking |
133
+ | `POST /api/trips/:id/paid` | HR оплатила → job finalize, status=paid (блоки генерятся асинхронно) |
134
+ | `POST /api/trips/:id/cancel` | status=cancelled |
135
+ | `GET /api/jobs` | список джобов `[{ id, type, tripId, status, startedAt, finishedAt, error, logTail }]` |
136
+ | `GET /api/jobs/:id` | полный джоб + полный лог (массив строк) |
137
+
138
+ Job: `{ id, type: 'search'|'book'|'finalize', tripId, status: 'queued'|'running'|'done'|'error', log: [], error, startedAt, finishedAt }`. Очередь — максимум 2 одновременно, как в Work Finder. Джобы живут в памяти (не в db.json).
139
+
140
+ ## Агенты (lib/prompts.mjs + lib/agent.mjs)
141
+
142
+ Механика запуска `claude -p` — скопировать из work-finder `lib/agent.mjs` (stream-json, извлечение JSON из ответа, обработка ошибок, модель из settings). Разрешения — через approver-mcp, как там.
143
+
144
+ 1. **search** — инструменты WebSearch/WebFetch (если `settings.browserSearch` — плюс Playwright MCP как в work-finder). В промпт входит: маршрут/даты/бюджет/критерии/meetingAt; корпоративные дефолты; карточки путников **без паспортных данных** (только имя, должность, лояльность, особенности); правила из CLAUDE.md ассистента: порядок источников (Google Flights → Skyscanner → Kiwi; Booking → Airbnb; аэроэкспрессы → Welcome Pickups → Bolt/Uber), тайминг прилёта относительно встречи и часовые пояса (местное время всегда), когда нужен/не нужен трансфер, «не выдумывай — честно скажи не нашёл», ≤5 вариантов, отели рейтинг ≥7.0, бюджет не превышать, группа >1 — логика групповых поездок. Требуемый выход — строго JSON по формату Option выше (схему привести в промпте дословно).
145
+ 2. **book** («Оформить №N») — браузерный агент: Playwright MCP поверх CDP :9222 (`npm run browser` поднимает Chrome — скрипт-калька с work-finder). В промпт: option целиком + полная карточка путника (тут паспорт нужен для полей) + corporate (юр.лицо для счёта). Жёсткие правила из CLAUDE.md: заполнять как гость (не создавать аккаунты), дойти до **экрана оплаты и остановиться**; никогда не вводить номер карты/CVC/срок; никогда не жать Pay/Confirm/Buy; паспортные данные не вставлять в URL; если капча/логин — остановиться и вернуть ссылку для ручного продолжения. Выход JSON: `{ "filled": ["пассажир", "тариф", ...], "stoppedAt": "экран оплаты Booking", "totalShown": "420€", "warnings": [], "manualLink": "" }` → сохраняется в `trip.bookingReports`, status=ready_to_pay.
146
+ 3. **finalize** (после «Оплачено») — без браузера, без веб-поиска: по trip + bookingReports сгенерировать три блока из CLAUDE.md (§«Шаг 5»): письмо путнику, сводка стоимости (markdown-таблица, + суточные если заданы), чеклист перед поездкой. Выход JSON `{ "letter": "...", "costTable": "...", "checklist": "..." }` → `trip.blocks`.
147
+
148
+ ## UI (public/) — русский, три вью
149
+
150
+ Роутинг по hash: `#trips` (дефолт) / `#trip/<id>` / `#team` / `#settings`. Поллинг `GET /api/state` раз в 2 с, пока есть активный джоб — раз в 1 с.
151
+
152
+ - **Поездки**: кнопка «Новая поездка» (форма: откуда/куда, даты, кто едет — чекбоксы из команды, встреча, бюджет, что нужно: транспорт/отель/трансфер(auto|да|нет), критерии). Список карточек: маршрут, даты, путники, статус-бейдж, суммы.
153
+ - **Поездка** (деталь): hero-блок в стиле артефактов ассистента (микро-метка «КОМАНДИРОВКА · ФИО · должность», заголовок «Город А → Город Б», мета-строка с датами/ночами/встречей); пайплайн статусов; при `searching/booking` — живой лог джоба (logTail); при `options` — плашка «⭐ Рекомендованная связка» (текст + итог + кнопка «Оформить связку») и карточки вариантов со сквозной нумерацией: транспорт — таймлайн (время-станция → капсула длительности на градиентной линии → время-станция, метка «прямой»/пересадки), отель — плашка рейтинга Booking-стиля (#003580, «9.2 · 4 494»), ★-звёздность, чипы удобств (зелёные — отмена/завтрак), расстояние с SVG-пином; у каждой карточки: цена справа (крупно, «от/за ночь» мелко), бейджи («⭐ рекомендую» зелёный, «самый дешёвый» янтарный), ссылка «Открыть ↗» и кнопка **«Оформить №N»** (POST book); при `ready_to_pay` — отчёт агента (filled/stoppedAt/totalShown/warnings) + кнопка «Оплачено ✓» (POST paid) + предупреждение «карту вводит только HR»; при `paid` — три блока (письмо / смета / чеклист) с кнопками копирования (скрипт copy → fallback execCommand → выделение, как в CLAUDE.md).
154
+ - **Команда**: карточки сотрудников (демо-бейдж), добавление/правка/удаление — модалка со всеми полями.
155
+ - **Настройки**: корпоративные дефолты + модель (haiku/sonnet/opus) + «поиск через браузер» + доп. инструкции.
156
+
157
+ ### Дизайн-токены (светлая тема, дословно из CLAUDE.md ассистента)
158
+
159
+ ```css
160
+ --bg:#f6f8fc; --card:#fff; --ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8;
161
+ --line:#e6eaf2; --line-2:#eef1f7; --navy:#0a2540; --navy-2:#142a4d; --accent:#1f6feb;
162
+ --gold:#b56500; --gold-bg:#fff3df; --green:#15803d; --green-bg:#ecfdf3; --booking:#003580;
163
+ --shadow-sm:0 1px 2px rgba(15,23,42,.04),0 1px 3px rgba(15,23,42,.06);
164
+ --shadow-md:0 4px 12px rgba(15,23,42,.08); --shadow-lg:0 12px 28px rgba(10,37,64,.18);
165
+ ```
166
+
167
+ Радиусы: карточки 14, hero 18, кнопки 8, чипы 999, номер-бейдж 10. Шрифт — системный стек, база 14px/1.5. Иконки — inline SVG `viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"`, эмодзи запрещены (кроме ⭐ в бейджах и ★★★★ звёздности). Фон body — заливка `--bg` (это standalone-приложение, не Cowork-артефакт). Экранирование любых данных при рендере обязательно (escapeHtml — как в work-finder).
168
+
169
+ ## Безопасность (не обсуждается)
170
+
171
+ - Платёжные данные не хранить, не запрашивать, не вводить; финальную кнопку оплаты не нажимать — это правило прошивается в промпт book-агента и дублируется в UI.
172
+ - Паспортные данные: живут только в db.json локально; в поисковый промпт не попадают; в лог джоба не выводить полностью (маскировать номер: `FK1***56`).
173
+ - Сервер слушает только localhost (`127.0.0.1`).
174
+ - `data/db.json` не должен попадать ни под какую статическую раздачу и ни под какой файловый watcher: каждая запись в него иначе устраивает live-reload шторм. (Старый общий дев-сервер студии `serve.mjs`, который раздавал весь корень воркспейса, удалён 2026-07-22 — приложения теперь обслуживает Vite из своих папок, так что раздачи этого пути больше нет по построению.)