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,27 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebSearch",
5
+ "WebFetch",
6
+ "mcp__playwright__browser_navigate",
7
+ "mcp__playwright__browser_navigate_back",
8
+ "mcp__playwright__browser_click",
9
+ "mcp__playwright__browser_type",
10
+ "mcp__playwright__browser_press_key",
11
+ "mcp__playwright__browser_wait_for",
12
+ "mcp__playwright__browser_snapshot",
13
+ "mcp__playwright__browser_take_screenshot",
14
+ "mcp__playwright__browser_evaluate",
15
+ "mcp__playwright__browser_run_code",
16
+ "mcp__playwright__browser_tabs",
17
+ "mcp__playwright__browser_file_upload",
18
+ "mcp__playwright__browser_handle_dialog",
19
+ "mcp__playwright__browser_resize",
20
+ "Bash(node scripts/*)",
21
+ "Bash(npm run *)",
22
+ "Bash(curl:*)",
23
+ "Bash(lsof:*)"
24
+ ]
25
+ },
26
+ "enableAllProjectMcpServers": true
27
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "mcpServers": {
3
+ "playwright": {
4
+ "type": "stdio",
5
+ "command": "npx",
6
+ "args": ["@playwright/mcp@latest", "--cdp-endpoint", "http://localhost:9222"]
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,24 @@
1
+ # Work Finder — заметки для Claude
2
+
3
+ Назначение: поиск работы/подработки для Димы и коллег. UI на :5544 (`npm start`), данные в `data/db.json`, фоновые операции — spawn `claude -p` (см. `lib/agent.mjs`).
4
+
5
+ ## Критичные особенности
6
+
7
+ - **Разрешения headless-агентов**: глобальный `~/.claude/settings.json` держит WebSearch/WebFetch в `ask` → в `-p` режиме это автоотказ, который НЕ обходится ни `--allowedTools`, ни `--dangerously-skip-permissions`. Решение: `--permission-prompt-tool mcp__approver__approve` + `scripts/approver-mcp.mjs` (белый список: WebSearch, WebFetch, ToolSearch, mcp__playwright__*). Не убирай этот механизм и не «чини» его через правку глобальных настроек.
8
+ - **`--tools` обязателен** при спавне агентов: без него агенты получают Bash/Edit/Write из глобального allow-списка пользователя.
9
+ - **`--strict-mcp-config` обязателен**: иначе проектный `.mcp.json` + `enableAllProjectMcpServers` подсовывают браузер каждому агенту, и поиск уползает в многочасовой браузерный краулинг (был реальный таймаут).
10
+ - **Браузер** — общий CDP Chrome на :9222 (обычно QA-браузер iucn-mono, в нём залогинен LinkedIn). `scripts/launch-browser.mjs` переиспользует живой порт, свой профиль поднимает только если порт пуст.
11
+ - В сопроводительных письмах промпт требует «только факты из резюме» — модель без этого приукрашивает (ловили «daily user of HTTPie»).
12
+
13
+ ## Данные
14
+
15
+ `data/db.json`: `profiles` (резюме в markdown + location — по ней агент проверяет право работать удалённо), `positions` (title/keywords/engagement/profileId/selected — мульти-чойс лейблы в UI), `vacancies` (статусы: new/prepared/ready/sent/answered/interview/rejected/hidden; гео-поля remote_scope global|region|country|none, eligibility_note, posted_date), `leads` (компании с сигналом найма — сейчас сигнал «свежий инвестраунд»; contact_* фаундера/CTO, pitch — холодное письмо, статусы new/ready/sent/answered/archive), `settings` (model, autoSubmit, searchViaBrowser, resultsPerSearch, freshnessDays, searchExtra).
16
+
17
+ Поиск вакансий работает в 3 режима сразу: ATS-дорки (site:boards.greenhouse.io и др.), тред HN "Who is hiring" текущего месяца, обычные борды/карьерные страницы. Идеи ещё 7 не реализованных сценариев поиска (реестры ФОП/юрлиц, свежие лицензии, наём рекрутера, смена CTO, Product Hunt, агентства, тендеры) — обсуждались 2026-07-02, спросить Диму прежде чем делать.
18
+ Свежая база стартует ПУСТОЙ (решение владельца 2026-07-19): никаких сид-профилей — после подключения ключа юзер создаёт первого кандидата через zero-profile онбординг фронта. Личные файлы (`dima.md`, LinkedIn-дампы) из репозитория удалены; `data/` в `.gitignore` (runtime-состояние живёт только на машине).
19
+
20
+ ## Тесты руками
21
+
22
+ - поиск: `POST /api/search {positionId}` → job; смотреть `GET /api/jobs/:id`
23
+ - автозаявка останавливается перед submit при `autoSubmit:false` (по умолчанию)
24
+ - профиль коллеги: добавить в UI → «Импорт из LinkedIn» (нужен его публичный URL и залогиненный браузер)
@@ -0,0 +1,44 @@
1
+ # Work Finder
2
+
3
+ Инструмент поиска работы/подработки для себя и коллег. Headless-агенты Claude Code ищут в вебе продуктовые/IT-компании с открытыми вакансиями под заданные позиции, а браузерный агент (Playwright MCP + ваш залогиненный Chrome) заполняет формы откликов.
4
+
5
+ ## Запуск
6
+
7
+ ```bash
8
+ npm start # сервер → http://localhost:5544
9
+ npm run browser # поднять/переиспользовать CDP Chrome на :9222 (для автозаявок и импорта LinkedIn)
10
+ ```
11
+
12
+ Требуется: Node 18+, Claude Code CLI (`claude`) с активной подпиской, Google Chrome.
13
+
14
+ ## Как пользоваться
15
+
16
+ 1. **Профили / резюме** — по одному на человека. Кнопка «Импорт из LinkedIn» вытаскивает резюме из открытого в браузере профиля (нужен залогиненный LinkedIn в CDP-браузере) и структурирует его через Claude. Можно править вручную.
17
+ 2. **Позиции** — «Senior React Developer», «AI Engineer»… + ключевые слова, чей профиль, формат (штат / контракт / оба). Кнопка «Искать» на позиции или «Искать по всем».
18
+ 3. **Вакансии** — найденное складывается сюда с дедупликацией. Статусы: новая → отклик готов → заполнено → отправлено → ответили/интервью/отказ.
19
+ 4. **✍️ Подготовить отклик** — Claude читает страницу вакансии и пишет сопроводительное письмо строго по фактам резюме. Письмо можно править.
20
+ 5. **🤖 Автозаявка** — браузерный агент открывает вакансию в новой вкладке, находит форму, заполняет её (имя, email, LinkedIn, письмо, доп. вопросы по резюме) и **останавливается перед отправкой** (скриншот + отчёт, что осталось). Включить полную автоотправку можно в настройках (`autoSubmit`).
21
+
22
+ ## Настройки
23
+
24
+ - **Модель**: haiku (дёшево) / sonnet (баланс, по умолчанию) / opus. Ориентир стоимости: один поиск на sonnet ≈ $1–1.5 (по API-тарифам; на подписке — в счёт лимитов).
25
+ - **Поиск через браузер** — глубже (djinni/DOU под вашим логином), но заметно медленнее; по умолчанию выключен, поиск идёт через WebSearch/WebFetch.
26
+ - **Доп. инструкции** — свободный текст: зарплатная вилка, страны, что исключать.
27
+
28
+ ## Архитектура
29
+
30
+ ```
31
+ server.mjs Node http (без зависимостей), порт 5544
32
+ lib/store.mjs data/db.json — профили, позиции, вакансии, настройки
33
+ lib/agent.mjs запуск `claude -p` (stream-json), парсинг результата
34
+ lib/prompts.mjs промпты: поиск / письмо / автозаявка / импорт резюме
35
+ lib/jobs.mjs очередь фоновых задач (макс. 2 агента параллельно)
36
+ scripts/approver-mcp.mjs MCP-сервер разрешений для headless-агентов (см. ниже)
37
+ scripts/launch-browser.mjs CDP Chrome на :9222 (переиспользует запущенный)
38
+ scripts/extract-linkedin.mjs выгрузка профиля LinkedIn через CDP
39
+ public/ UI (vanilla JS)
40
+ ```
41
+
42
+ **Важно про разрешения:** в глобальных `~/.claude/settings.json` инструменты WebSearch/WebFetch стоят в списке `ask`, что в headless-режиме означает автоотказ (и перекрывает `--allowedTools`). Поэтому агенты запускаются с `--permission-prompt-tool mcp__approver__approve` — наш мини-MCP-сервер одобряет только белый список (WebSearch, WebFetch, mcp__playwright__*), всё прочее запрещено. Плюс `--tools` жёстко ограничивает встроенный набор (агентам недоступны Bash/Edit/Write).
43
+
44
+ **Браузер:** агенты подключаются к Chrome с `--remote-debugging-port=9222`. Если запущен QA-браузер iucn-mono — используется он (там залогинен LinkedIn); иначе `npm run browser` поднимет Chrome с локальным профилем `.cdp-browser-profile/`. Не запускайте две автозаявки одновременно с активной работой в этом же браузере.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Qiksy Work Finder — the local engine launcher.
4
+ *
5
+ * This is what a customer runs (as `npx qiksy-work-finder`, or wrapped inside the
6
+ * desktop app). It is a thin, friendly front over the existing zero-dependency Node
7
+ * backend (../server.mjs): it prints a branded banner, checks whether Claude Code is
8
+ * installed, boots the engine on localhost:5544, waits until it answers, and opens the
9
+ * browser on the tool. The engine keeps the process alive; Ctrl-C stops it.
10
+ *
11
+ * Deliberately no new heavy dependencies — the whole point of the engine is that it is
12
+ * small and runs on the user's OWN Claude (their subscription, their machine, their data).
13
+ */
14
+
15
+ import { spawnSync } from 'node:child_process';
16
+ import { createRequire } from 'node:module';
17
+ import { platform } from 'node:process';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { mkdirSync } from 'node:fs';
21
+
22
+ // Runtime state (db, LinkedIn dumps, CDP profile) lives in a stable user folder, NOT in
23
+ // the package under node_modules / the npx cache — otherwise the user's candidates vanish
24
+ // between runs. server.mjs/store.mjs read this before their first write.
25
+ if (!process.env.WF_DATA_DIR) {
26
+ const dir = join(homedir(), '.qiksy-work-finder');
27
+ try {
28
+ mkdirSync(dir, { recursive: true });
29
+ process.env.WF_DATA_DIR = dir;
30
+ } catch {
31
+ /* fall back to the package's own data/ if home isn't writable */
32
+ }
33
+ }
34
+
35
+ const PORT = Number(process.env.WF_PORT) || 5544;
36
+ const URL = `http://localhost:${PORT}`;
37
+ const isWin = platform === 'win32';
38
+
39
+ const c = {
40
+ b: (s) => `\x1b[1m${s}\x1b[0m`,
41
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
42
+ blue: (s) => `\x1b[38;5;69m${s}\x1b[0m`,
43
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
44
+ amber: (s) => `\x1b[33m${s}\x1b[0m`,
45
+ };
46
+
47
+ function banner() {
48
+ process.stdout.write(
49
+ '\n' +
50
+ c.blue(c.b(' ◆ Qiksy Work Finder')) +
51
+ c.dim(' — your local hiring engine\n') +
52
+ c.dim(' Runs on your own Claude · nothing leaves your machine\n\n'),
53
+ );
54
+ }
55
+
56
+ /** Is the `claude` CLI on PATH? On Windows it is a .cmd shim, so ask through a shell there. */
57
+ function hasClaude() {
58
+ try {
59
+ const r = spawnSync('claude', ['--version'], { stdio: 'ignore', shell: isWin, timeout: 8000 });
60
+ return r.status === 0;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function openBrowser(url) {
67
+ try {
68
+ if (platform === 'darwin') spawnSync('open', [url], { stdio: 'ignore' });
69
+ else if (isWin) spawnSync('cmd', ['/c', 'start', '""', url], { stdio: 'ignore' });
70
+ else spawnSync('xdg-open', [url], { stdio: 'ignore' });
71
+ } catch {
72
+ /* the URL is printed below regardless */
73
+ }
74
+ }
75
+
76
+ async function waitForPort(port, tries = 60) {
77
+ for (let i = 0; i < tries; i++) {
78
+ try {
79
+ const res = await fetch(`http://localhost:${port}/api/state`, { signal: AbortSignal.timeout(800) });
80
+ if (res.ok || res.status === 401 || res.status === 402) return true;
81
+ } catch {
82
+ /* not up yet */
83
+ }
84
+ await new Promise((r) => setTimeout(r, 500));
85
+ }
86
+ return false;
87
+ }
88
+
89
+ async function main() {
90
+ banner();
91
+
92
+ if (!hasClaude()) {
93
+ process.stdout.write(
94
+ c.amber(' ⚠ Claude Code was not found on this machine.\n') +
95
+ c.dim(' Work Finder runs on your own Claude — install it once, then sign in from the app:\n') +
96
+ c.dim(' → https://claude.com/claude-code\n') +
97
+ c.dim(" (You can still start the engine now and sign in from the window that opens.)\n\n"),
98
+ );
99
+ } else {
100
+ process.stdout.write(c.green(' ✓ Claude Code detected\n\n'));
101
+ }
102
+
103
+ process.stdout.write(c.dim(` Starting the engine on ${URL} …\n`));
104
+
105
+ // Boot the backend — it calls server.listen() at import time and keeps the process alive.
106
+ const require = createRequire(import.meta.url);
107
+ const serverPath = require.resolve('../server.mjs');
108
+ await import(serverPath);
109
+
110
+ const up = await waitForPort(PORT);
111
+ if (!up) {
112
+ process.stdout.write(c.amber(`\n Engine did not answer on ${URL} yet — open it manually in your browser.\n`));
113
+ } else {
114
+ process.stdout.write(c.green(`\n ✓ Work Finder is running.\n`));
115
+ openBrowser(URL);
116
+ }
117
+ process.stdout.write(
118
+ '\n' +
119
+ c.b(` Open: ${URL}\n`) +
120
+ c.dim(' Leave this window open while you use Work Finder. Press Ctrl-C to stop.\n\n'),
121
+ );
122
+ }
123
+
124
+ main().catch((err) => {
125
+ process.stderr.write(c.amber(`\n Could not start Work Finder: ${err?.message || err}\n`));
126
+ process.exit(1);
127
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Headless Claude Code runner: spawns `claude -p` with stream-json output,
3
+ * feeds tool-use events to onEvent (job log), returns the final result text.
4
+ */
5
+ import { spawnClaude } from './spawn-claude.mjs';
6
+ import { resolve } from 'node:path';
7
+ import { ROOT } from './store.mjs';
8
+
9
+ // Все живые дочерние агенты: убиваем при остановке сервера, чтобы
10
+ // осиротевшие claude-процессы не дожигали токены в пустоту.
11
+ const activeChildren = new Set();
12
+ function killAllChildren() {
13
+ for (const child of activeChildren) {
14
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
15
+ }
16
+ activeChildren.clear();
17
+ }
18
+ for (const sig of ['SIGINT', 'SIGTERM']) {
19
+ process.on(sig, () => { killAllChildren(); process.exit(0); });
20
+ }
21
+ process.on('exit', killAllChildren);
22
+
23
+ const APPROVER_SERVER = {
24
+ type: 'stdio',
25
+ command: process.execPath,
26
+ args: [resolve(ROOT, 'scripts/approver-mcp.mjs')],
27
+ };
28
+ const PLAYWRIGHT_SERVER = {
29
+ type: 'stdio',
30
+ command: 'npx',
31
+ args: ['@playwright/mcp@latest', '--cdp-endpoint', 'http://localhost:9222'],
32
+ };
33
+
34
+ export function runClaude({
35
+ prompt,
36
+ allowedTools = [], // built-in tools the agent may use; [] = no built-ins
37
+ withBrowser = false,
38
+ model = 'sonnet',
39
+ timeoutMs = 10 * 60_000,
40
+ onEvent = () => {},
41
+ onPartial = null, // (obj) => void — строки `VACANCY: {...}` из текста агента, по мере готовности
42
+ signal = null, // AbortSignal — отмена задачи пользователем
43
+ }) {
44
+ return new Promise(resolvePromise => {
45
+ // The user's global settings put WebSearch/WebFetch in the "ask" list,
46
+ // which auto-denies in -p mode and overrides --allowedTools and even
47
+ // --dangerously-skip-permissions. So permissions go through our own
48
+ // approver MCP server (--permission-prompt-tool) with a whitelist,
49
+ // and --tools hard-limits the built-in toolset (no Bash/Edit/Write).
50
+ const mcpServers = { approver: APPROVER_SERVER };
51
+ if (withBrowser) mcpServers.playwright = PLAYWRIGHT_SERVER;
52
+ const args = [
53
+ '-p', prompt,
54
+ '--output-format', 'stream-json', '--verbose',
55
+ '--model', model,
56
+ '--tools', allowedTools.length ? [...allowedTools, 'ToolSearch'].join(',') : '',
57
+ '--mcp-config', JSON.stringify({ mcpServers }),
58
+ '--strict-mcp-config',
59
+ '--permission-prompt-tool', 'mcp__approver__approve',
60
+ ];
61
+ if (allowedTools.length) args.push('--allowedTools', [...allowedTools, ...(withBrowser ? ['mcp__playwright'] : [])].join(','));
62
+
63
+ // A nested interactive session is refused, but `-p` is fine; strip the
64
+ // markers anyway so the child never thinks it is inside another session.
65
+ const env = { ...process.env };
66
+ delete env.CLAUDECODE;
67
+ delete env.CLAUDE_CODE_ENTRYPOINT;
68
+
69
+ const child = spawnClaude(args, { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
70
+ activeChildren.add(child);
71
+ child.on('close', () => activeChildren.delete(child));
72
+
73
+ let resultText = '';
74
+ let errText = '';
75
+ let costUsd = null;
76
+ let settled = false;
77
+ let partialBuf = '';
78
+ let partialsEmitted = 0;
79
+ const t0 = Date.now();
80
+
81
+ const finish = out => {
82
+ if (settled) return;
83
+ settled = true;
84
+ clearTimeout(timer);
85
+ resolvePromise({ durationMs: Date.now() - t0, costUsd, ...out });
86
+ };
87
+
88
+ const timer = setTimeout(() => {
89
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
90
+ finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s` });
91
+ }, timeoutMs);
92
+
93
+ if (signal) {
94
+ const onAbort = () => {
95
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
96
+ finish({ ok: false, error: 'отменено' });
97
+ };
98
+ if (signal.aborted) onAbort();
99
+ else signal.addEventListener('abort', onAbort, { once: true });
100
+ }
101
+
102
+ let buf = '';
103
+ child.stdout.on('data', chunk => {
104
+ buf += chunk;
105
+ let nl;
106
+ while ((nl = buf.indexOf('\n')) >= 0) {
107
+ const line = buf.slice(0, nl).trim();
108
+ buf = buf.slice(nl + 1);
109
+ if (!line) continue;
110
+ let ev;
111
+ try { ev = JSON.parse(line); } catch { continue; }
112
+ if (ev.type === 'assistant') {
113
+ for (const block of ev.message?.content || []) {
114
+ if (block.type === 'tool_use') {
115
+ const input = JSON.stringify(block.input || {});
116
+ onEvent(`⚙ ${block.name} ${input.length > 160 ? input.slice(0, 160) + '…' : input}`);
117
+ } else if (block.type === 'text' && block.text && onPartial) {
118
+ partialBuf += block.text + '\n';
119
+ const matches = [...partialBuf.matchAll(/^VACANCY:\s*(\{.*\})\s*$/gm)];
120
+ for (let i = partialsEmitted; i < matches.length; i++) {
121
+ try { onPartial(JSON.parse(matches[i][1])); } catch { /* побитый партиал — придёт в финальном массиве */ }
122
+ }
123
+ partialsEmitted = Math.max(partialsEmitted, matches.length);
124
+ }
125
+ }
126
+ } else if (ev.type === 'result') {
127
+ resultText = ev.result || '';
128
+ costUsd = ev.total_cost_usd ?? null;
129
+ if (ev.is_error) errText = resultText || ev.subtype || 'agent error';
130
+ }
131
+ }
132
+ });
133
+ child.stderr.on('data', chunk => { errText += chunk; });
134
+
135
+ child.on('error', err => finish({ ok: false, error: `spawn failed: ${err.message}` }));
136
+ child.on('close', code => {
137
+ if (code === 0 && resultText) finish({ ok: true, result: resultText });
138
+ else finish({ ok: false, error: (errText || `claude exited with code ${code}`).slice(0, 2000) });
139
+ });
140
+ });
141
+ }
142
+
143
+ /** Pull a JSON value out of agent prose: direct parse → fenced block → bracket slice. */
144
+ export function extractJson(text) {
145
+ if (!text) return null;
146
+ const attempts = [text.trim()];
147
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
148
+ if (fence) attempts.push(fence[1].trim());
149
+ const firstArr = text.indexOf('[');
150
+ const lastArr = text.lastIndexOf(']');
151
+ if (firstArr >= 0 && lastArr > firstArr) attempts.push(text.slice(firstArr, lastArr + 1));
152
+ const firstObj = text.indexOf('{');
153
+ const lastObj = text.lastIndexOf('}');
154
+ if (firstObj >= 0 && lastObj > firstObj) attempts.push(text.slice(firstObj, lastObj + 1));
155
+ for (const candidate of attempts) {
156
+ try { return JSON.parse(candidate); } catch { /* next */ }
157
+ }
158
+ return null;
159
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Каталог ролей: готовые позиции с выверенными ключевыми словами,
3
+ * чтобы HR не придумывал формулировки сам (меньше кастома — стабильнее поиск).
4
+ * `match` — регэксп автодетекта роли по тексту резюме.
5
+ */
6
+ export const ROLE_CATALOG = [
7
+ { id: 'react', group: 'Frontend', title: 'React / Frontend Developer', keywords: 'React, TypeScript, Next.js, Tailwind', match: /react(?!\s*native)/i },
8
+ { id: 'angular', group: 'Frontend', title: 'Angular Developer', keywords: 'Angular, TypeScript, RxJS, NgRx', match: /angular/i },
9
+ { id: 'vue', group: 'Frontend', title: 'Vue / Nuxt Developer', keywords: 'Vue.js, Nuxt, TypeScript, Pinia', match: /\bvue\b|nuxt/i },
10
+ { id: 'fullstack-js', group: 'Fullstack', title: 'Full-Stack JavaScript Developer', keywords: 'JavaScript, TypeScript, React, Node.js', match: /full[\s-]?stack.{0,40}(js|javascript|node|react)|(node|react).{0,40}full[\s-]?stack/is },
11
+ { id: 'node', group: 'Backend', title: 'Node.js Backend Developer', keywords: 'Node.js, NestJS, TypeScript, PostgreSQL', match: /node\.?js|nestjs/i },
12
+ { id: 'python', group: 'Backend', title: 'Python Backend Developer', keywords: 'Python, Django, FastAPI, PostgreSQL, Celery', match: /python|django|fastapi/i },
13
+ { id: 'java', group: 'Backend', title: 'Java Developer', keywords: 'Java, Spring Boot, Hibernate', match: /\bjava\b(?!script)|spring boot/i },
14
+ { id: 'dotnet', group: 'Backend', title: '.NET Developer', keywords: 'C#, .NET, ASP.NET Core', match: /\.net|c#|csharp/i },
15
+ { id: 'go', group: 'Backend', title: 'Go Developer', keywords: 'Go, Golang, microservices', match: /\bgolang\b|\bgo\b developer/i },
16
+ { id: 'php', group: 'Backend', title: 'PHP Developer', keywords: 'PHP, Laravel, Symfony', match: /\bphp\b|laravel|symfony/i },
17
+ { id: 'ai-engineer', group: 'AI', title: 'AI / LLM Engineer', keywords: 'LLM, RAG, agents, LangChain, MCP, prompt engineering', match: /\bllm\b|langchain|\brag\b|prompt engineer|anthropic|openai|\bmcp\b/i },
18
+ { id: 'ai-integrator', group: 'AI', title: 'AI Systems Integrator', keywords: 'AI integration, Claude, MCP, automation, agents', match: /ai (systems? )?integrat|ai assistant|ai-augmented/i },
19
+ { id: 'ml', group: 'AI', title: 'ML Engineer', keywords: 'Machine Learning, PyTorch, TensorFlow, MLOps', match: /machine learning|pytorch|tensorflow|keras|mlops/i },
20
+ { id: 'solutions-architect', group: 'Architecture', title: 'Solutions Architect', keywords: 'solution architecture, system design, integrations', match: /solutions? architect|архитектор/i },
21
+ { id: 'cloud-architect', group: 'Architecture', title: 'Cloud Architect', keywords: 'AWS, Azure, GCP, cloud architecture, Terraform', match: /cloud architect|aws.{0,30}architect/i },
22
+ { id: 'devops', group: 'Infra', title: 'DevOps / SRE Engineer', keywords: 'Kubernetes, Docker, Terraform, CI/CD, AWS', match: /devops|\bsre\b|kubernetes|terraform/i },
23
+ { id: 'qa-auto', group: 'QA', title: 'QA Automation Engineer', keywords: 'Playwright, Cypress, Selenium, test automation', match: /qa automation|playwright|cypress|selenium|автоматизац.{0,20}тест/i },
24
+ { id: 'qa-manual', group: 'QA', title: 'QA Engineer (manual)', keywords: 'manual testing, test cases, regression', match: /manual (qa|test)|ручное тестирование/i },
25
+ { id: 'mobile', group: 'Mobile', title: 'Mobile Developer (React Native / Flutter)', keywords: 'React Native, Flutter, iOS, Android', match: /react native|flutter|swift|kotlin/i },
26
+ { id: 'designer', group: 'Design', title: 'UI/UX Designer', keywords: 'Figma, UI/UX, product design', match: /ui\/?ux|product design|figma/i },
27
+ { id: 'pm', group: 'Management', title: 'Project / Product Manager', keywords: 'product management, agile, roadmap, stakeholders', match: /product manager|project manager|product owner/i },
28
+ ];
29
+
30
+ export const SENIORITY = ['', 'Junior', 'Middle', 'Senior', 'Lead'];
31
+
32
+ export const LOCATIONS = [
33
+ 'Ukraine', 'Poland', 'Czech Republic', 'Germany', 'Spain', 'Portugal',
34
+ 'Romania', 'Bulgaria', 'Georgia', 'Armenia', 'Kazakhstan', 'Other',
35
+ ];
36
+
37
+ /** Автоподбор ролей по тексту резюме: топ-3 совпадения в порядке каталога. */
38
+ export function detectRoles(resumeText) {
39
+ if (!resumeText) return [];
40
+ return ROLE_CATALOG.filter(r => r.match.test(resumeText)).slice(0, 3).map(r => r.id);
41
+ }
@@ -0,0 +1,73 @@
1
+ // Search CHANNELS the agent can cover. The universe is grouped so the whole
2
+ // coverage roadmap slots in here (a new source joins an existing group's fragment),
3
+ // and — because search is prompt-driven — enabling/disabling a group just shapes the
4
+ // source instructions; no per-source API integration is needed for the toggle to work.
5
+ //
6
+ // Narrowing the set is a PAID feature (owner 2026-07-25): free ALWAYS searches every
7
+ // channel — coverage is identical for everyone — only a paying user may target/exclude.
8
+ // The server enforces this by passing canNarrow=false for free plans.
9
+ //
10
+ // `label` is English (the agent reads it in the prompt). The UI's localized names live
11
+ // in the frontend i18n (settings.ch.*), keyed by the same id.
12
+
13
+ export const CHANNELS = [
14
+ {
15
+ id: 'ats',
16
+ label: 'Company careers & ATS',
17
+ frag: 'Company career pages & ATS — dork "<role> remote": site:boards.greenhouse.io, site:jobs.lever.co, site:jobs.ashbyhq.com, site:apply.workable.com, site:careers.smartrecruiters.com, *.myworkdayjobs.com, Recruitee, Teamtailor, BambooHR. Always prefer the company\'s OWN careers/ATS URL.',
18
+ },
19
+ {
20
+ id: 'boards',
21
+ label: 'Job boards & aggregators',
22
+ frag: 'Global job boards & aggregators — DIVERSIFY, ≤2-3 per board: RemoteOK, WeWorkRemotely, Remotive, Himalayas.app, Wellfound, Otta, Jobgether, Google for Jobs, Adzuna, EU boards.',
23
+ },
24
+ {
25
+ id: 'community',
26
+ label: 'Community (HN, Reddit)',
27
+ frag: 'Community threads: the current month\'s Hacker News "Ask HN: Who is hiring?" (search hn.algolia.com) and relevant Reddit hiring threads — companies that post ONLY there, often worldwide-remote.',
28
+ },
29
+ {
30
+ id: 'cis',
31
+ label: 'CIS / Ukraine boards',
32
+ frag: 'CIS / Ukraine boards: djinni.co, jobs.dou.ua, Work.ua, robota.ua, Habr Career — the candidate\'s home market; public listings are searchable.',
33
+ },
34
+ {
35
+ id: 'relocate',
36
+ label: 'Relocation / EU',
37
+ frag: 'Relocation / EU boards: justjoin.it, nofluffjobs.com, relocate.me, Landing.jobs — roles that offer relocation or EU-wide remote.',
38
+ },
39
+ {
40
+ id: 'signals',
41
+ label: 'Early hiring signals',
42
+ frag: 'Hiring SIGNALS before a role hits any board: freshly funded startups, brand-new YC batch companies, Product Hunt launches, GitHub orgs advertising "we\'re hiring" — reach the company or founder directly. These are found EARLIER than boards.',
43
+ },
44
+ ];
45
+
46
+ export const CHANNEL_IDS = CHANNELS.map((c) => c.id);
47
+
48
+ /** Which channels this search runs. Free (canNarrow=false) always gets ALL of them —
49
+ * narrowing is a paid privilege. Falls back to the old excludeBoards flag, and never
50
+ * returns an empty set. */
51
+ export function enabledChannelIds(settings, canNarrow) {
52
+ if (!canNarrow) return CHANNEL_IDS;
53
+ const sel = settings?.channels;
54
+ if (!Array.isArray(sel)) {
55
+ return settings?.excludeBoards ? CHANNEL_IDS.filter((id) => id !== 'boards') : CHANNEL_IDS;
56
+ }
57
+ const valid = sel.filter((id) => CHANNEL_IDS.includes(id));
58
+ return valid.length ? valid : CHANNEL_IDS;
59
+ }
60
+
61
+ /** Build the SEARCH STRATEGY block for the prompt from the enabled channels, plus an
62
+ * explicit "do NOT return listings from these" line for the disabled ones. */
63
+ export function searchStrategyBlock(settings, canNarrow) {
64
+ const enabled = new Set(enabledChannelIds(settings, canNarrow));
65
+ const on = CHANNELS.filter((c) => enabled.has(c.id));
66
+ const lines = on.map((c, i) => `${String.fromCharCode(97 + i)}) ${c.frag}`);
67
+ let block = `SEARCH STRATEGY — combine ALL the channels below, do not lean on one:\n${lines.join('\n')}`;
68
+ const off = CHANNELS.filter((c) => !enabled.has(c.id));
69
+ if (off.length) {
70
+ block += `\n\nCHANNELS DISABLED BY THE USER — do NOT return any vacancy hosted on these (you may glance at them for DISCOVERY only, but the returned vacancy_url must never be hosted there): ${off.map((c) => c.label).join('; ')}.`;
71
+ }
72
+ return block;
73
+ }