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.
|
|
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
|
-
|
|
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();
|