qiksy 1.2.0 → 1.2.1

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.2.0",
3
+ "version": "1.2.1",
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": {
@@ -20,7 +20,12 @@ const MAX_CONCURRENT = 2;
20
20
  let running = 0;
21
21
  const queue = [];
22
22
 
23
- export function createJob(type, label, work) {
23
+ /** Set by the server: called when a job dies because Claude's limit is spent. */
24
+ let onLimit = null;
25
+ export function setLimitHandler(fn) { onLimit = fn; }
26
+
27
+ /** @param {object} [opts] `retry` describes how to run this again (see resume-later). */
28
+ export function createJob(type, label, work, opts = {}) {
24
29
  const job = {
25
30
  id: uid(),
26
31
  type,
@@ -31,6 +36,7 @@ export function createJob(type, label, work) {
31
36
  error: null,
32
37
  createdAt: Date.now(),
33
38
  finishedAt: null,
39
+ retry: opts.retry || null,
34
40
  };
35
41
  jobs.set(job.id, job);
36
42
  queue.push({ job, work });
@@ -70,6 +76,13 @@ function pump() {
70
76
  if (err?.claudeCode) job.claudeCode = err.claudeCode;
71
77
  if (err?.resetAt) job.resetAt = err.resetAt;
72
78
  jobLog(job, `✗ ${job.error}`);
79
+ /* A usage limit is the one failure worth picking up later — the owner of the
80
+ machine may be asleep. jobs.mjs does not know HOW to re-run anything, so it
81
+ hands the job's own descriptor to whoever registered a handler. */
82
+ if (job.claudeCode === 'limit-reached' && job.retry && onLimit) {
83
+ try { onLimit(job.retry, { code: job.claudeCode, resetAt: job.resetAt }); }
84
+ catch { /* never let bookkeeping break the job's own failure path */ }
85
+ }
73
86
  }
74
87
  })
75
88
  .finally(() => {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pick the work back up when Claude's limit lifts.
3
+ *
4
+ * Hitting a usage limit is the ONE failure where the right answer is not "tell the
5
+ * person to try again" — they may be asleep, and the run was already paid for in
6
+ * attention. So a search stopped by a limit is remembered and re-run on its own; the
7
+ * person comes back to finished results instead of a red line (owner ask 2026-07-26).
8
+ *
9
+ * Deliberately conservative:
10
+ * · only 'limit-reached' is retried. A wrong model or an empty résumé will fail again
11
+ * at any hour, and a retry loop on those is just noise.
12
+ * · at most MAX_TRIES attempts, spaced out — a limit that keeps re-hitting means the
13
+ * account is genuinely spent, not that we should hammer it.
14
+ * · the queue lives in the db, so closing the laptop does not lose it.
15
+ * · the person can cancel it, and cancelling means cancelled.
16
+ */
17
+
18
+ const MAX_TRIES = 3;
19
+ const MIN_WAIT_MS = 10 * 60 * 1000; // never sooner than 10 min, even if reset says now
20
+ const FALLBACK_WAIT_MS = 60 * 60 * 1000; // no parseable reset time → try in an hour
21
+ const TICK_MS = 60 * 1000;
22
+
23
+ /** ISO / unix / "3pm (Europe/Kyiv)" → epoch ms, or null when it cannot be trusted. */
24
+ function resetToEpoch(resetAt) {
25
+ if (!resetAt) return null;
26
+ const s = String(resetAt).trim();
27
+ const asDate = Date.parse(s);
28
+ if (!Number.isNaN(asDate)) return asDate;
29
+ // "3pm", "3:30 pm (Europe/Kyiv)" — a wall-clock hint with no date. Read it as the
30
+ // NEXT occurrence of that hour in this machine's own timezone, which is where the
31
+ // person is sitting.
32
+ const m = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
33
+ if (!m) return null;
34
+ let h = Number(m[1]);
35
+ const min = Number(m[2] || 0);
36
+ const ap = (m[3] || '').toLowerCase();
37
+ if (ap === 'pm' && h < 12) h += 12;
38
+ if (ap === 'am' && h === 12) h = 0;
39
+ const d = new Date();
40
+ d.setHours(h, min, 0, 0);
41
+ if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
42
+ return d.getTime();
43
+ }
44
+
45
+ /**
46
+ * @param {object} opts
47
+ * @param {() => object} opts.getDb current db (we persist into db.deferred)
48
+ * @param {(db: object) => void} opts.save
49
+ * @param {(entry: object) => void} opts.run re-runs one entry; throws if it cannot
50
+ * @param {(entry: object, msg: string) => void} [opts.note]
51
+ */
52
+ export function createResumeQueue({ getDb, save, run, note }) {
53
+ let timer = null;
54
+
55
+ const list = () => {
56
+ const db = getDb();
57
+ if (!Array.isArray(db.deferred)) db.deferred = [];
58
+ return db.deferred;
59
+ };
60
+
61
+ /** Remember a run to pick up later. Returns the entry, or null if not retryable. */
62
+ function defer(descriptor, { code, resetAt } = {}) {
63
+ if (code !== 'limit-reached' || !descriptor) return null;
64
+ const db = getDb();
65
+ const q = list();
66
+ // One pending entry per thing — a limit hit during a 5-role sweep must not queue
67
+ // the same role five times.
68
+ const key = JSON.stringify(descriptor);
69
+ const existing = q.find((e) => JSON.stringify(e.descriptor) === key);
70
+ const tries = (existing?.tries || 0) + 1;
71
+ if (tries > MAX_TRIES) {
72
+ if (existing) q.splice(q.indexOf(existing), 1);
73
+ save(db);
74
+ return null;
75
+ }
76
+ const base = resetToEpoch(resetAt);
77
+ const wait = base ? Math.max(base - Date.now(), MIN_WAIT_MS) : FALLBACK_WAIT_MS * tries;
78
+ const entry = existing || { id: 'df_' + Math.random().toString(36).slice(2, 10), descriptor, createdAt: Date.now() };
79
+ entry.tries = tries;
80
+ entry.resumeAt = Date.now() + wait;
81
+ entry.reason = 'limit-reached';
82
+ if (!existing) q.push(entry);
83
+ save(db);
84
+ note?.(entry, `отложено до ${new Date(entry.resumeAt).toLocaleString()}`);
85
+ return entry;
86
+ }
87
+
88
+ function cancel(id) {
89
+ const db = getDb();
90
+ const q = list();
91
+ const i = q.findIndex((e) => e.id === id);
92
+ if (i < 0) return false;
93
+ q.splice(i, 1);
94
+ save(db);
95
+ return true;
96
+ }
97
+
98
+ function due(now = Date.now()) {
99
+ return list().filter((e) => e.resumeAt <= now);
100
+ }
101
+
102
+ async function tick() {
103
+ const ready = due();
104
+ if (!ready.length) return;
105
+ const db = getDb();
106
+ for (const entry of ready) {
107
+ // Take it out FIRST: a re-run that hits the limit again will re-add itself with a
108
+ // longer wait, and a crash must not leave an entry that fires every minute.
109
+ const q = list();
110
+ const i = q.findIndex((e) => e.id === entry.id);
111
+ if (i >= 0) q.splice(i, 1);
112
+ save(db);
113
+ try {
114
+ await run(entry);
115
+ note?.(entry, 'возобновлено');
116
+ } catch (e) {
117
+ note?.(entry, `не удалось возобновить: ${e?.message || e}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ function start() {
123
+ if (timer) return;
124
+ timer = setInterval(() => void tick(), TICK_MS);
125
+ timer.unref?.();
126
+ }
127
+ function stop() {
128
+ if (timer) clearInterval(timer);
129
+ timer = null;
130
+ }
131
+
132
+ return { defer, cancel, list, due, tick, start, stop };
133
+ }
@@ -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 } from './lib/jobs.mjs';
19
+ import { createJob, jobLog, listJobs, getJob, cancelJob, cancelActiveByLabel, jobEvents, setLimitHandler } 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';
@@ -24,6 +24,7 @@ import { ROLE_CATALOG, SENIORITY, LOCATIONS, detectRoles } from './lib/catalog.m
24
24
  import { checkMany, checkVacancyUrl } from './lib/linkcheck.mjs';
25
25
  import { probeEngine, startLogin, submitLoginCode } from './lib/engine.mjs';
26
26
  import { normalizeLinkedin } from './lib/linkedin.mjs';
27
+ import { createResumeQueue } from './lib/resume-later.mjs';
27
28
 
28
29
  const PORT = Number(process.env.WF_PORT) || 5544;
29
30
  const PUBLIC_DIR = resolve(ROOT, 'public');
@@ -131,6 +132,23 @@ function createSearchRun(targets) {
131
132
  // (та же логика, что в purgeHistory). Остальное — нетронутая находка, шум, уходит.
132
133
  const isTouched = (v) => v.status !== 'new' || !!v.coverLetter || !!v.applyResult || !!v.pinned;
133
134
 
135
+ /* A search stopped by Claude's usage limit comes back on its own — see
136
+ lib/resume-later.mjs for why only this one failure is retried. */
137
+ const resumeQueue = createResumeQueue({
138
+ getDb: () => db,
139
+ save: (d) => save(d),
140
+ note: (entry, msg) => console.log(`[resume] ${entry.descriptor?.kind || '?'} ${msg}`),
141
+ run: (entry) => {
142
+ const d = entry.descriptor || {};
143
+ if (d.kind !== 'search') throw new Error('unknown descriptor');
144
+ const position = db.positions.find((p) => p.id === d.positionId);
145
+ if (!position) throw new Error('роль удалена');
146
+ startSearch(position, null);
147
+ },
148
+ });
149
+ resumeQueue.start();
150
+ setLimitHandler((descriptor, info) => resumeQueue.defer(descriptor, info));
151
+
134
152
  function startSearch(position, run = null) {
135
153
  const profile = db.profiles.find(p => p.id === position.profileId) || db.profiles[0];
136
154
  return createJob('search', `Поиск: ${position.title}`, async (job, signal) => {
@@ -253,7 +271,7 @@ function startSearch(position, run = null) {
253
271
  }
254
272
  jobLog(job, `✓ добавлено: ${added}${dead ? `, отброшено битых ссылок: ${dead}` : ''} — волн: ${wave}`);
255
273
  return { found: added, added, dead, sweeps: wave, costUsd: cost };
256
- });
274
+ }, { retry: { kind: 'search', positionId: position.id } });
257
275
  }
258
276
 
259
277
  function startCoverLetter(vacancy) {
@@ -627,7 +645,7 @@ export const requestHandler = async (req, res) => {
627
645
 
628
646
  if (req.method === 'GET' && path === '/api/state') {
629
647
  return json(res, 200, {
630
- ...db, browserAlive, jobs: listJobs(), billing: billingInfo(),
648
+ ...db, browserAlive, jobs: listJobs(), billing: billingInfo(), deferred: resumeQueue.list(),
631
649
  catalog: ROLE_CATALOG.map(({ id, group, title, keywords }) => ({ id, group, title, keywords })),
632
650
  seniority: SENIORITY, locations: LOCATIONS,
633
651
  });
@@ -844,6 +862,12 @@ export const requestHandler = async (req, res) => {
844
862
  localStorage. That key cannot exist any more — API_KEY_LOGIN is off and there is
845
863
  no field to enter one — so all three were dead for every user while the fourth,
846
864
  which comes through here, worked. Same route for all of them now. */
865
+ /* "Do not bother, I will run it myself" — a promise the product makes must be
866
+ cancellable, or it is just something happening to you. */
867
+ if (req.method === 'DELETE' && seg[1] === 'deferred' && seg[2]) {
868
+ const ok = resumeQueue.cancel(seg[2]);
869
+ return json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'no such entry' });
870
+ }
847
871
  if (req.method === 'POST' && path === '/api/builder/text') {
848
872
  const b = await readBody(req);
849
873
  const prompt = (b.prompt || '').trim();