qiksy 1.3.1 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Qiksy \u2014 one local engine for every Qiksy tool (Work Finder, Travel Assistant, Client Finder). Runs on your own Claude; nothing leaves your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,7 +72,7 @@ const AUDIENCE = [
72
72
 
73
73
  /** A place — "в Киеве", "во Львове", "in Berlin", a country. */
74
74
  const GEO = [
75
- /\bв(о)?\s+[А-ЯЁ][а-яё]{2,}/,
75
+ /\b(в|во|по|у|під|біля|около)\s+[А-ЯЁІЇЄ][а-яёіїє]{2,}/,
76
76
  /\b(in|near|across)\s+[A-Z][a-z]{2,}/,
77
77
  /(украин|польш|герман|казахстан|европ|киев|львов|варшав|берлин|краков|вроцлав|одесс|харьков|днепр)/i,
78
78
  /\b(ukraine|poland|germany|europe|kyiv|warsaw|berlin|krakow|lviv)/i,
@@ -86,7 +86,7 @@ const QUALIFIER = [
86
86
 
87
87
  /** Verbs of the job itself — "найди", "собери", "нужны". Supportive, not required. */
88
88
  const SEARCH_INTENT = [
89
- /(найд|ищ[иу]|искать|подбер|собер|нужн|список|лид[ыов]|клиент|покупател)/i,
89
+ /(найд|найт|знайд|шука|ищ[иу]|искать|подбер|собер|нужн|потрібн|список|лід|лид[ыов]|клієнт|клиент|покупател|покупц)/i,
90
90
  /\b(find|search|prospect|lead|client|customer|list)\b/i,
91
91
  ];
92
92
 
@@ -134,10 +134,20 @@ export function checkBrief(brief) {
134
134
  hint: `Добавьте кто, где и по какому признаку — иначе агент пойдёт искать «всех подряд» за ваши токены. ${EXAMPLE}`,
135
135
  };
136
136
  }
137
+ /* ONE signal is enough — audience, intent, a place, or a qualifying condition.
138
+ It used to demand geography AND a qualifier together before it would accept a
139
+ brief that named no business type from the list, and the list is the losing
140
+ game this file warns about at the top: «парикмахерские» was simply not in it,
141
+ so "надо мне найти по Львову всех парикмахерские" was refused as having no
142
+ audience. The guard's job is to catch what is obviously NOT a brief — chat, a
143
+ question, someone else's product, all of which are caught above and by the
144
+ three-word floor. Proving that something IS a brief is not its job, and every
145
+ false refusal here costs a real search. */
137
146
  const looksLikeBrief =
138
147
  AUDIENCE.some((re) => re.test(text)) ||
139
148
  SEARCH_INTENT.some((re) => re.test(text)) ||
140
- (GEO.some((re) => re.test(text)) && QUALIFIER.some((re) => re.test(text)));
149
+ GEO.some((re) => re.test(text)) ||
150
+ QUALIFIER.some((re) => re.test(text));
141
151
  if (!looksLikeBrief) {
142
152
  return {
143
153
  ok: false,
@@ -16,7 +16,22 @@ function emitJob(job) {
16
16
 
17
17
  const jobs = new Map();
18
18
  const controllers = new Map(); // job.id → AbortController (только у выполняющихся)
19
- const MAX_CONCURRENT = 2;
19
+ /* How many agents may work at once. Two was fine for one person hunting one role, and
20
+ it is the wrong number for a team: with six candidates queued, seven jobs sat idle
21
+ for nine minutes and the last would have started an hour later (measured 2026-07-26).
22
+ Each agent is a real `claude -p` — roughly a gigabyte of RSS and a share of the
23
+ person's own Claude quota — so this scales with the plan rather than going wide:
24
+ free 1 (do not burn a free account), pro 3, dev/team 4. QIKSY_CONCURRENCY overrides. */
25
+ const CONCURRENCY_BY_PLAN = { free: 1, pro: 3, dev: 4 };
26
+ let MAX_CONCURRENT = Number(process.env.QIKSY_CONCURRENCY) || CONCURRENCY_BY_PLAN.free;
27
+
28
+ /** The server calls this whenever the effective plan changes. */
29
+ export function setConcurrencyForPlan(plan) {
30
+ if (process.env.QIKSY_CONCURRENCY) return; // an explicit override wins
31
+ MAX_CONCURRENT = CONCURRENCY_BY_PLAN[plan] ?? CONCURRENCY_BY_PLAN.free;
32
+ pump();
33
+ }
34
+ export const concurrency = () => MAX_CONCURRENT;
20
35
  let running = 0;
21
36
  const queue = [];
22
37
 
@@ -54,7 +54,16 @@ export function searchPrompt(position, profile, excludeCompanies, settings, pass
54
54
  POSITION: ${position.title}
55
55
  KEYWORDS / STACK: ${position.keywords || '—'}
56
56
  ENGAGEMENT: ${ENGAGEMENT_TEXT[position.engagement] || ENGAGEMENT_TEXT.both}
57
- CANDIDATE LOCATION: ${profile?.location || 'Ukraine'} — works remotely from there (or B2B contract)
57
+ CANDIDATE LOCATION: ${profile?.location || 'Ukraine'}
58
+ WHERE THEY CAN WORK: ${
59
+ settings.workMode === 'onsite'
60
+ ? `ON SITE, in or near ${profile?.location || 'Ukraine'}. Look for office roles there — commuting distance, not another country. A remote-only posting is NOT a match unless it also offers an office in that area.`
61
+ : settings.workMode === 'hybrid'
62
+ ? `HYBRID — an office in or near ${profile?.location || 'Ukraine'} with some days from home. Fully remote is acceptable only if the company also has an office there.`
63
+ : settings.workMode === 'any'
64
+ ? `EITHER — remote from ${profile?.location || 'Ukraine'} (or B2B contract), or an office role in or near ${profile?.location || 'Ukraine'}. Both count; say which one each posting is.`
65
+ : `REMOTELY from ${profile?.location || 'Ukraine'} (or B2B contract).`
66
+ }
58
67
  ${settings.searchExtra ? `EXTRA INSTRUCTIONS FROM USER: ${settings.searchExtra}\n` : ''}
59
68
  CANDIDATE (for relevance filtering):
60
69
  ${resumeExcerpt(profile, 1200)}
@@ -22,6 +22,11 @@ function defaults() {
22
22
  model: 'sonnet',
23
23
  autoSubmit: false,
24
24
  searchViaBrowser: false,
25
+ /* Where the person can actually work: 'remote' (default, what the agent always
26
+ silently assumed) | 'onsite' | 'hybrid' | 'any'. Not everyone can work from
27
+ home — some need an office in their own city, and until now the product had
28
+ nothing to offer them (owner, 2026-07-26). */
29
+ workMode: 'remote',
25
30
  searchExtra: '',
26
31
  /* Per SWEEP, and a FLOOR, not a ceiling — the search runs sweep after sweep
27
32
  until one turns up nothing new (server.mjs startSearch). It used to be a
@@ -16,7 +16,7 @@ import { spawn } from 'node:child_process';
16
16
  import { load, save, uid, ROOT, DATA_DIR } from './lib/store.mjs';
17
17
  import { runClaude, extractJson } from './lib/agent.mjs';
18
18
  import { searchPrompt, coverLetterPrompt, autoApplyPrompt, importResumePrompt, leadsSearchPrompt, pitchPrompt, buildFromStoryPrompt } from './lib/prompts.mjs';
19
- import { createJob, jobLog, listJobs, getJob, cancelJob, cancelActiveByLabel, jobEvents, setLimitHandler } from './lib/jobs.mjs';
19
+ import { createJob, jobLog, listJobs, getJob, cancelJob, cancelActiveByLabel, jobEvents, setLimitHandler, setConcurrencyForPlan, concurrency } from './lib/jobs.mjs';
20
20
  import { ensureBrowser } from './scripts/launch-browser.mjs';
21
21
  import { PLANS, SELF_SETTABLE_PLANS } from './lib/plans.mjs';
22
22
  import { verifyLicense, licensePlanId, RECHECK_MS } from './lib/license.mjs';
@@ -147,6 +147,7 @@ const resumeQueue = createResumeQueue({
147
147
  },
148
148
  });
149
149
  resumeQueue.start();
150
+ setConcurrencyForPlan(effectivePlanId());
150
151
  setLimitHandler((descriptor, info) => resumeQueue.defer(descriptor, info));
151
152
 
152
153
  function startSearch(position, run = null) {
@@ -658,7 +659,7 @@ export const requestHandler = async (req, res) => {
658
659
 
659
660
  if (req.method === 'GET' && path === '/api/state') {
660
661
  return json(res, 200, {
661
- ...db, browserAlive, jobs: listJobs(), billing: billingInfo(), deferred: resumeQueue.list(),
662
+ ...db, browserAlive, jobs: listJobs(), billing: billingInfo(), deferred: resumeQueue.list(), concurrency: concurrency(),
662
663
  catalog: ROLE_CATALOG.map(({ id, group, title, keywords }) => ({ id, group, title, keywords })),
663
664
  seniority: SENIORITY, locations: LOCATIONS,
664
665
  });
@@ -726,6 +727,7 @@ export const requestHandler = async (req, res) => {
726
727
  delete body.licenseInfo;
727
728
  Object.assign(db.settings, body);
728
729
  save(db);
730
+ setConcurrencyForPlan(effectivePlanId());
729
731
  return json(res, 200, db.settings);
730
732
  }
731
733