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,217 @@
1
+ /** Prompt builders for the headless Claude agents. */
2
+
3
+ import { searchStrategyBlock } from './channels.mjs';
4
+
5
+ const ENGAGEMENT_TEXT = {
6
+ staff: 'staff employment (in-house position) only',
7
+ contract: 'B2B contract / vendor / freelance engagement only',
8
+ both: 'staff employment OR B2B contract / vendor — both are acceptable',
9
+ };
10
+
11
+ function resumeExcerpt(profile, maxLen = 2500) {
12
+ const r = (profile?.resume || '').trim();
13
+ return r.length > maxLen ? r.slice(0, maxLen) + '\n…(truncated)' : r;
14
+ }
15
+
16
+ /**
17
+ * @param {object} position
18
+ * @param {object} profile
19
+ * @param {string[]} excludeCompanies already found — never returned twice
20
+ * @param {object} settings
21
+ * @param {{wave?: number, waves?: number}} [pass] which sweep this is, when the
22
+ * search runs to exhaustion: each wave gets a DIFFERENT angle, because asking the
23
+ * same question again returns the same answers.
24
+ */
25
+ /**
26
+ * Which slice of the freshness range this sweep works.
27
+ *
28
+ * The user picks a WINDOW ("0 to 8 days old"), not a cutoff, and the sweeps walk it
29
+ * newest-first: sweep 1 takes the freshest slice, each next one reaches further back,
30
+ * and the final sweep opens the whole window as a catch-all. That covers the range
31
+ * instead of re-reading its first day over and over — and it doubles as the change of
32
+ * angle a repeat sweep needs to return anything new.
33
+ */
34
+ function freshSlice(from, to, wave, waves) {
35
+ if (to <= from) return { from, to };
36
+ if (wave >= waves) return { from, to }; // last pass sees everything
37
+ const step = Math.max(1, Math.ceil((to - from) / Math.max(1, waves - 1)));
38
+ const lo = from + step * (wave - 1);
39
+ return lo >= to ? { from, to } : { from: lo, to: Math.min(to, lo + step) };
40
+ }
41
+
42
+ export function searchPrompt(position, profile, excludeCompanies, settings, pass = {}) {
43
+ const n = settings.resultsPerSearch || 8;
44
+ const wave = pass.wave || 1;
45
+ const waves = pass.waves || 1;
46
+ const fresh = freshSlice(
47
+ Math.max(0, settings.freshnessFrom ?? 0),
48
+ Math.max(1, settings.freshnessDays ?? 14),
49
+ wave,
50
+ waves,
51
+ );
52
+ return `You are a job-search agent. Find companies that are CURRENTLY hiring for the position below. Use WebSearch (and WebFetch to verify pages).
53
+
54
+ POSITION: ${position.title}
55
+ KEYWORDS / STACK: ${position.keywords || '—'}
56
+ ENGAGEMENT: ${ENGAGEMENT_TEXT[position.engagement] || ENGAGEMENT_TEXT.both}
57
+ CANDIDATE LOCATION: ${profile?.location || 'Ukraine'} — works remotely from there (or B2B contract)
58
+ ${settings.searchExtra ? `EXTRA INSTRUCTIONS FROM USER: ${settings.searchExtra}\n` : ''}
59
+ CANDIDATE (for relevance filtering):
60
+ ${resumeExcerpt(profile, 1200)}
61
+
62
+ ${searchStrategyBlock(settings, pass.canNarrow)}
63
+
64
+ GLOBAL COVERAGE RULE: spread findings across regions and sources — companies headquartered in US, UK/EU, DACH, Nordics, Israel, APAC etc. Take AT MOST 2-3 results from any single board or source; the rest must come from other sources. The goal is worldwide reach, not one local board.
65
+
66
+ RULES:
67
+ - Prefer product / IT companies posting vacancies on their OWN career pages or ATS; when a board led you to a company, dig to the direct company vacancy URL.
68
+ - NEVER construct or guess a vacancy URL. Return only URLs you actually fetched and confirmed to show THIS job (the page contains the job title). If you cannot confirm a direct URL, put the careers page in careers_url and leave vacancy_url null — a wrong link is worse than no link.
69
+ - Verify each opening is real and current (posted / still listed now). Skip expired listings.
70
+ - FRESHNESS WINDOW for this pass: openings posted or updated ${fresh.from === 0 ? `within the last ${fresh.to} days` : `between ${fresh.from} and ${fresh.to} days ago`}. Job boards show posting dates — use them; on career pages without dates, accept only if the source that led you there marks it as within this window. Fill "posted_date" when known. Do NOT return anything outside the window: a later pass covers the older ground.
71
+ - GEOGRAPHY OF REMOTE — critical. "Remote" usually means remote-within-a-country/region. For EVERY vacancy read the location restrictions on the page ("Remote — Worldwide", "Remote (US only)", "EU work permit required", "must be based in…", timezone limits) and classify remote_scope:
72
+ * "global" — work from anywhere / worldwide, or explicitly open to the candidate's country;
73
+ * "region" — restricted to a region (EU, EMEA, LATAM, US…) — note which;
74
+ * "country" — remote but within one country;
75
+ * "none" — onsite/hybrid.
76
+ SKIP vacancies where the candidate in ${profile?.location || 'Ukraine'} is clearly NOT eligible (e.g. "US only" for a candidate in Europe). Prefer "global" ones; "region" is ok when the candidate's location fits or a B2B contract plausibly works.
77
+ - Skip pure staffing agencies and lead-generation spam.
78
+ - Skip these companies (already found earlier): ${excludeCompanies.length ? excludeCompanies.join(', ') : 'none'}.
79
+ - Find AT LEAST ${n} distinct openings at distinct companies — more is better. There is no upper limit: return every match you can confirm in this pass.
80
+ - SCORE THE FIT: for every vacancy set "match" to an INTEGER 0-100 — how well THIS role fits the candidate above (stack & seniority overlap, remote eligibility from ${profile?.location || 'Ukraine'}, engagement type). Be honest and discriminating: reserve 90+ for a strong direct match, 60-75 for a plausible partial fit, below 50 for a stretch. The "notes" field should justify the number in one line.
81
+ - WORK FAST but do not stop early: broad WebSearch queries first, then WebFetch the promising pages. Streaming a confirmed find costs nothing extra, so stream as you go.
82
+ - TIME BUDGET: up to ~12 minutes for this pass. Stop when you genuinely run out of NEW companies, not when you reach a number.
83
+ ${wave > 1 ? `- THIS IS SWEEP #${wave}. Everything the earlier sweeps found is in the exclusion list above and must not come back. Asking the same question again returns the same answers, so change the ANGLE: different search phrasings and role synonyms, different ATS hosts, different regions and countries, smaller and less-known companies, older-but-still-open postings, adjacent titles the candidate also fits. If a whole angle turns up nothing new, say so and finish the pass — an honest empty sweep ends the search cleanly.` : ''}
84
+
85
+ PROGRESS STREAMING: the user watches results arrive live. As soon as you have CONFIRMED one vacancy (page fetched, fresh, geography checked), immediately print a single line:
86
+ VACANCY: {"company":...} ← one-line JSON, exact same fields as the array items below
87
+ …then continue searching. Stream every confirmed finding this way.
88
+
89
+ FINAL OUTPUT: at the end return ONLY a JSON array with ALL findings (including the streamed ones), no prose, no markdown fences:
90
+ [{"company":"","website":"","careers_url":"","vacancy_title":"","vacancy_url":"","location":"","remote":true,"remote_scope":"global|region|country|none","eligibility_note":"one short line: can the candidate apply from ${profile?.location || 'Ukraine'}, what limits apply","engagement":"staff|contract|both|unknown","apply_method":"form|email|linkedin|unknown","apply_email":null,"salary":null,"posted_date":"YYYY-MM-DD or null","source":"where you found it","match":85,"notes":"1-2 sentences: why it fits, key requirements"}]`;
91
+ }
92
+
93
+ export function coverLetterPrompt(vacancy, profile) {
94
+ return `You are helping a candidate apply for a job. Write a concise, specific cover letter (150-250 words) for this vacancy. If a vacancy URL is given, use WebFetch to read the actual job description first and mirror its language (English unless the posting is clearly in another language).
95
+
96
+ VACANCY:
97
+ - Company: ${vacancy.company}
98
+ - Title: ${vacancy.vacancy_title}
99
+ - URL: ${vacancy.vacancy_url || vacancy.careers_url || '—'}
100
+ - Notes: ${vacancy.notes || '—'}
101
+
102
+ CANDIDATE:
103
+ ${resumeExcerpt(profile)}
104
+
105
+ Rules: no generic fluff ("I am excited to apply…" openers), lead with the strongest overlap between the candidate and THIS role, mention 2-3 concrete matching achievements/skills, close with availability (remote, staff or B2B contract both possible). Sign as ${profile?.name || 'the candidate'}, ${profile?.email || ''}.
106
+ STRICT HONESTY: use ONLY facts present in the resume above. Never invent projects, employers, metrics, or claims like "I use your product daily". If the resume lacks a requirement, do not pretend otherwise — emphasise adjacent real experience instead.
107
+
108
+ OUTPUT: return ONLY the cover letter text, nothing else.`;
109
+ }
110
+
111
+ export function autoApplyPrompt(vacancy, profile, coverLetter, autoSubmit) {
112
+ return `You are a browser automation agent applying to a job on behalf of the candidate (with their explicit consent). Use the playwright MCP tools. The browser is already running; open pages in a NEW tab (browser_tabs → new) and do not touch the user's other tabs.
113
+
114
+ TASK:
115
+ 1. Open: ${vacancy.vacancy_url || vacancy.careers_url}
116
+ 2. Find the application form for the vacancy "${vacancy.vacancy_title}" (follow an "Apply" button if needed).
117
+ 3. Fill the form:
118
+ - Name: ${profile?.name || ''}
119
+ - Email: ${profile?.email || ''}
120
+ - LinkedIn: ${profile?.linkedin || ''}
121
+ - Location: ${profile?.location || 'Ukraine'} (remote)
122
+ - Cover letter / message field: use the COVER LETTER below.
123
+ - If a CV file upload is REQUIRED and no file is available, note it in the report instead of failing.
124
+ - Any other required questions: answer truthfully and briefly based on the CANDIDATE RESUME below; if an answer cannot be derived from the resume, leave it and report.
125
+ 4. ${autoSubmit
126
+ ? 'SUBMIT the application, then take a screenshot of the confirmation.'
127
+ : 'Do NOT press the final submit button. Fill everything, take a screenshot of the completed form, and report that it is ready for manual review.'}
128
+
129
+ COVER LETTER:
130
+ ${coverLetter || '(generate a short one from the resume)'}
131
+
132
+ CANDIDATE RESUME:
133
+ ${resumeExcerpt(profile)}
134
+
135
+ OUTPUT: return ONLY JSON: {"status":"submitted|ready_for_review|no_form_found|failed","details":"what was done, what fields were filled, what remains","form_url":"url of the form page"}`;
136
+ }
137
+
138
+ export function leadsSearchPrompt(profiles, excludeCompanies, settings) {
139
+ const candidates = profiles
140
+ .map(p => `— ${p.name} (based in ${p.location || 'Europe'}): ${resumeExcerpt(p, 700)}`)
141
+ .join('\n\n');
142
+ const month = new Date().toLocaleString('en', { month: 'long', year: 'numeric' });
143
+ return `You are a lead-generation agent for two software engineers seeking work (staff or B2B contract). Find PRODUCT companies that recently raised funding — a strong signal they are hiring or will hire soon, even with NO public job postings.
144
+
145
+ TASK:
146
+ 1. WebSearch for product/tech companies that announced a funding round (pre-seed / seed / Series A / Series B) within the LAST 60 DAYS (today: ${month}). Sources: TechCrunch, tech.eu, EU-Startups, SiliconCanals, Crunchbase news, YC batch announcements, VC portfolio news pages.
147
+ 2. Keep companies whose product/stack plausibly needs at least one candidate below: AI assistants / LLM integration, React/TypeScript web products, Python backends.
148
+ 3. For each company find an outreach contact: founder / CTO / VP Engineering — name plus a public email, LinkedIn URL, or the site's contact/careers form URL (WebFetch the company site, check About/Team pages).
149
+ 4. Also note hiring evidence if any exists (ATS page, careers page, "we're hiring" mentions).
150
+ 5. Skip these companies (already known): ${excludeCompanies.length ? excludeCompanies.join(', ') : 'none'}.
151
+ ${settings.searchExtra ? `6. EXTRA USER INSTRUCTIONS: ${settings.searchExtra}\n` : ''}
152
+ CANDIDATES:
153
+ ${candidates}
154
+
155
+ OUTPUT: return ONLY a JSON array, 6-10 items, no prose:
156
+ [{"company":"","website":"","signal":"Raised $X seed, May 2026, led by <investor>","source_url":"","why_fit":"1-2 sentences: what they build and why the candidate fits","fits_profile":"exact candidate name from above","contact_name":"","contact_role":"","contact_channel":"email|linkedin|form|unknown","contact_value":"email address / linkedin url / form url","hiring_evidence":"careers or ATS url, or null"}]`;
157
+ }
158
+
159
+ export function pitchPrompt(lead, profile) {
160
+ return `Write a cold outreach email (110-170 words) from a software engineer to a company that just raised funding. If useful, WebFetch the company site to reference their product accurately.
161
+
162
+ COMPANY: ${lead.company} — ${lead.website || ''}
163
+ SIGNAL: ${lead.signal || 'recent funding round'}
164
+ WHAT THEY BUILD / WHY FIT: ${lead.why_fit || '—'}
165
+ ADDRESSEE: ${lead.contact_name || 'founder/CTO'} (${lead.contact_role || ''})
166
+
167
+ SENDER (the candidate):
168
+ ${resumeExcerpt(profile)}
169
+
170
+ Rules:
171
+ - Subject line first ("Subject: …"), then the email body.
172
+ - Hook: congratulate briefly on the round (one clause, no flattery), then one sentence on the concrete value the candidate brings to THEIR product right now.
173
+ - 2-3 factual proof points from the resume. STRICT HONESTY: only facts present in the resume, never invent.
174
+ - Offer: available as staff hire or B2B contractor, remote from ${profile?.location || 'Europe'}; suggest a short intro call.
175
+ - Sign: ${profile?.name || ''}, ${profile?.email || ''}, ${profile?.linkedin || ''}.
176
+ - Language: English unless their site is clearly in another language.
177
+
178
+ OUTPUT: only the subject + email text, nothing else.`;
179
+ }
180
+
181
+ export function importResumePrompt(rawText) {
182
+ return `Below is raw text scraped from a LinkedIn profile (it contains UI noise: menus, footers, ads). Extract the person's professional resume and rewrite it as clean English markdown with sections: header (name, headline, location, contacts), Summary, Experience (company, role, dates, bullets), Projects, Certifications, Skills, Education. Keep every real fact, drop all UI noise. Return ONLY the markdown.
183
+
184
+ RAW PROFILE TEXT:
185
+ ${rawText.slice(0, 18_000)}`;
186
+ }
187
+
188
+ /** «Tell about yourself» → a structured professional profile. Hard-scoped to the
189
+ * profession: never touches personal/sensitive data, ignores off-topic, and lists
190
+ * professional GAPS to make the résumé (and therefore the job search) stronger. */
191
+ export function buildFromStoryPrompt(story, lang = 'en') {
192
+ const langName = lang === 'uk' ? 'Ukrainian' : lang === 'ru' ? 'Russian' : 'English';
193
+ return `You are a professional résumé assistant inside a job-search tool. A person just described themselves in their own words (possibly voice-transcribed — informal, out of order, with filler). Turn it into a STRUCTURED PROFESSIONAL profile.
194
+
195
+ STRICT SCOPE — you build a professional résumé, nothing else:
196
+ - Use ONLY professional information: role/title, seniority, years of experience, tech stack & skills, companies, projects, responsibilities, achievements, education/certifications, languages spoken (professional), location for remote eligibility.
197
+ - NEVER include or ask for personal/sensitive data: age, date of birth, marital status, children, gender, religion, politics, health, ethnicity, photo, salary. If the person mentioned any of it, silently omit it.
198
+ - Ignore anything off-topic (weather, small talk, jokes). Do NOT answer questions or chat — only extract résumé facts.
199
+
200
+ GAPS — help make the profile complete for strong job matching. In "gaps", list 3–6 SHORT, SPECIFIC, professional items that are missing or vague and would improve matching, e.g. "No years of experience stated", "No company names for your roles", "Backend stack unclear — which DB / framework?", "No seniority level (Senior / Middle?)", "No notable projects or achievements". Professional only — NEVER suggest asking for personal info.
201
+
202
+ Write "headline", "summary" and the experience "bullets" in ${langName}. Keep skill names in their common form (React, Node.js, PostgreSQL…).
203
+
204
+ Return ONLY valid JSON — no prose, no markdown fences — in exactly this shape:
205
+ {
206
+ "name": "", "location": "", "email": "", "linkedin": "",
207
+ "headline": "", "summary": "",
208
+ "skills": ["React", "TypeScript"],
209
+ "experience": [{ "role": "", "company": "", "dates": "", "bullets": ["", ""] }],
210
+ "education": [{ "degree": "", "school": "", "dates": "" }],
211
+ "gaps": ["", ""]
212
+ }
213
+ Leave a field as "" or [] when the person didn't provide it (and add the important ones to "gaps").
214
+
215
+ PERSON'S DESCRIPTION:
216
+ ${String(story).slice(0, 8000)}`;
217
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Start the `claude` CLI — on any operating system.
3
+ *
4
+ * On macOS and Linux `claude` on PATH is a real executable and Node's own spawn finds
5
+ * it. ON WINDOWS IT IS NOT: npm installs a `claude.cmd` shim, and Node cannot execute
6
+ * a .cmd without a shell — recent versions refuse outright (the fix for CVE-2024-27980).
7
+ * So every call landed on ENOENT and the panel announced "Claude Code is not installed"
8
+ * on a machine where it was installed and working perfectly. The probe, the sign-in and
9
+ * the agent runs all went through this one door, so on Windows nothing worked at all.
10
+ *
11
+ * `shell: true` would make the error go away and open a hole instead: the agent passes
12
+ * the user's OWN TEXT in argv — a resume, a job description, an MCP config as JSON —
13
+ * and through cmd.exe every `&`, `|`, `^` and quote in it becomes a shell metacharacter.
14
+ * cross-spawn is the standard answer to exactly this (npm itself runs on it): it
15
+ * resolves the shim and escapes arguments for cmd.exe properly.
16
+ *
17
+ * Imported LAZILY, with a fallback, so the backends keep the "no dependencies"
18
+ * property they were written for: run from this monorepo they get cross-spawn, run
19
+ * standalone on macOS or Linux they fall back to Node's spawn — which is exactly what
20
+ * they used before this file existed.
21
+ */
22
+
23
+ import { spawn as nodeSpawn } from 'node:child_process';
24
+
25
+ let impl = null;
26
+ try {
27
+ impl = (await import('cross-spawn')).default;
28
+ } catch {
29
+ // Only Windows actually needs it; saying so anywhere else would be noise.
30
+ if (process.platform === 'win32') {
31
+ console.warn(
32
+ '[claude] cross-spawn is missing, and on Windows the CLI cannot be started without it.\n' +
33
+ ' Run `npm install` in the qiksy-studio root, then start this server again.',
34
+ );
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {string[]} args argv for the CLI
40
+ * @param {import('node:child_process').SpawnOptions} [opts]
41
+ */
42
+ export function spawnClaude(args, opts = {}) {
43
+ return (impl ?? nodeSpawn)('claude', args, opts);
44
+ }
@@ -0,0 +1,125 @@
1
+ /** JSON file store: data/db.json. Zero deps, sync IO (single-user tool). */
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { resolve, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
7
+ /* Where runtime state lives (db, LinkedIn dumps, the CDP profile). Defaults to the
8
+ package's own data/ for dev, but the shipped launcher points WF_DATA_DIR at a stable
9
+ user folder (~/.qiksy-work-finder) — a published package must NOT write into node_modules
10
+ / the npx cache, or the user loses their candidates between runs. */
11
+ export const DATA_DIR = process.env.WF_DATA_DIR ? resolve(process.env.WF_DATA_DIR) : resolve(ROOT, 'data');
12
+ const DB_PATH = process.env.WF_DB ? resolve(process.env.WF_DB) : resolve(DATA_DIR, 'db.json');
13
+
14
+ export const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
15
+
16
+ // A fresh install starts EMPTY — no seeded candidate (owner decision 2026-07-19:
17
+ // every flow begins from zero — after connecting the key the user creates their
18
+ // own first candidate; the frontend's zero-profile onboarding guides that step).
19
+ function defaults() {
20
+ return {
21
+ settings: {
22
+ model: 'sonnet',
23
+ autoSubmit: false,
24
+ searchViaBrowser: false,
25
+ searchExtra: '',
26
+ /* Per SWEEP, and a FLOOR, not a ceiling — the search runs sweep after sweep
27
+ until one turns up nothing new (server.mjs startSearch). It used to be a
28
+ hard cap of 8, which is why "React developer, worldwide" came back with
29
+ four: our own prompt told the agent to stop. */
30
+ resultsPerSearch: 20,
31
+ /* A WINDOW, not a cutoff: [freshnessFrom … freshnessDays] days old. The sweeps
32
+ walk it newest-first so the range is covered instead of its first day being
33
+ re-read every pass. Three days was aggressive enough to hide most of the
34
+ market; two weeks is still "current" and leaves something to find. */
35
+ freshnessFrom: 0,
36
+ freshnessDays: 14,
37
+ /* How many sweeps at most. Unbounded would spend the user's whole Claude
38
+ quota in one night; two barren sweeps in a row also end it early. */
39
+ maxSweeps: 6,
40
+ /* How long the search history is kept, in days. The database grows with every
41
+ run and nothing ever removed anything, so it only went one way. Purging is
42
+ deliberately CONSERVATIVE: a run's record and its UNTOUCHED findings go, but
43
+ anything the user acted on (wrote a letter, applied, changed the status)
44
+ stays forever — deleting the record of an application you sent would be
45
+ losing the user's own work, not tidying up. */
46
+ historyDays: 30,
47
+ excludeBoards: false, // true = только прямые вакансии компаний/ATS, без джоб-бордов
48
+ plan: 'free', // ручной план: только free/dev. Платные (start/pro) — по лицензии.
49
+ license: '', // ключ лицензии (wf_…); пусто = бесплатный тариф
50
+ licenseInfo: null, // кэш последней проверки: {plan, active, paidUntil, trial, checkedAt}
51
+ },
52
+ // usage — только информативный лог стоимости токенов юзера (его ключ). Кредитов нет.
53
+ usage: { totalCostUsd: 0, log: [] },
54
+ profiles: [],
55
+ positions: [],
56
+ vacancies: [],
57
+ leads: [],
58
+ searches: [], // история запусков поиска: {id, at, titles, profileIds, vacancyIds, added}
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Drop search history older than `historyDays`, and with it the findings nobody ever
64
+ * touched. Runs on load, so it costs nothing and needs no scheduler.
65
+ *
66
+ * "Untouched" means status still `new`, no cover letter, no apply result. Everything
67
+ * else is the user's own work and is kept regardless of age — the history window is
68
+ * about our noise, not about their applications.
69
+ *
70
+ * @returns {{runs:number, vacancies:number}} what was removed
71
+ */
72
+ export function purgeHistory(db) {
73
+ const days = Math.max(1, db.settings?.historyDays ?? 30);
74
+ const cutoff = Date.now() - days * 86_400_000;
75
+ const oldRuns = (db.searches || []).filter(r => (r.at || 0) < cutoff);
76
+ if (!oldRuns.length) return { runs: 0, vacancies: 0 };
77
+
78
+ const doomed = new Set(oldRuns.flatMap(r => r.vacancyIds || []));
79
+ const keep = v =>
80
+ !doomed.has(v.id) ||
81
+ v.status !== 'new' ||
82
+ v.coverLetter ||
83
+ v.applyResult;
84
+
85
+ const before = db.vacancies.length;
86
+ db.vacancies = db.vacancies.filter(keep);
87
+ db.searches = (db.searches || []).filter(r => (r.at || 0) >= cutoff);
88
+ return { runs: oldRuns.length, vacancies: before - db.vacancies.length };
89
+ }
90
+
91
+ export function load() {
92
+ if (!existsSync(DB_PATH)) {
93
+ const db = defaults();
94
+ save(db);
95
+ return db;
96
+ }
97
+ const db = JSON.parse(readFileSync(DB_PATH, 'utf8'));
98
+ // migrate: fill keys added after the db was first created
99
+ db.settings = { model: 'sonnet', autoSubmit: false, searchViaBrowser: false, searchExtra: '', excludeBoards: false, ...defaults().settings, ...db.settings };
100
+ /* Raise the two limiters for anyone still on the OLD defaults — they were ours,
101
+ not the user's choice, and they are the reason an exhaustive search returned a
102
+ handful. A value the user actually picked is left exactly as it is. */
103
+ if (db.settings.resultsPerSearch === 8) db.settings.resultsPerSearch = 20;
104
+ if (db.settings.freshnessDays === 3) db.settings.freshnessDays = 14;
105
+ db.settings.maxSweeps ??= 6;
106
+ db.settings.freshnessFrom ??= 0;
107
+ db.settings.historyDays ??= 30;
108
+ purgeHistory(db);
109
+ db.positions = db.positions.map(p => ({ selected: true, ...p }));
110
+ db.leads ||= [];
111
+ db.searches ||= [];
112
+ db.settings.plan ||= 'dev'; // существующая (наша) база — без лимитов
113
+ db.settings.license ??= '';
114
+ db.settings.licenseInfo ??= null;
115
+ // сбрасываем наследие кредитов/кошелька, если осталось от старой базы
116
+ delete db.settings.markup;
117
+ delete db.wallet;
118
+ db.usage = { totalCostUsd: db.usage?.totalCostUsd || 0, log: db.usage?.log || [] };
119
+ return db;
120
+ }
121
+
122
+ export function save(db) {
123
+ mkdirSync(dirname(DB_PATH), { recursive: true });
124
+ writeFileSync(DB_PATH, JSON.stringify(db, null, 2), 'utf8');
125
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "qiksy-work-finder",
3
+ "version": "1.0.0",
4
+ "description": "Qiksy Work Finder — your local hiring engine. Claude agents sweep the whole job market, match roles to your profile and draft the letters. Runs on your own Claude; nothing leaves your machine.",
5
+ "type": "module",
6
+ "bin": {
7
+ "qiksy-work-finder": "bin/qiksy-work-finder.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/qiksy-work-finder.mjs",
11
+ "serve": "node server.mjs",
12
+ "browser": "node scripts/launch-browser.mjs",
13
+ "extract": "node scripts/extract-linkedin.mjs"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "lib",
18
+ "scripts",
19
+ "public",
20
+ "server.mjs",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "qiksy",
25
+ "work-finder",
26
+ "job-search",
27
+ "claude",
28
+ "ai-agents"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "dependencies": {
34
+ "cross-spawn": "^7.0.6"
35
+ },
36
+ "license": "UNLICENSED",
37
+ "homepage": "https://qiksy.app/work-finder/",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }