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,283 @@
1
+ /**
2
+ * Can this machine actually run the agent — and how is it paying?
3
+ *
4
+ * The tools are local-first: the work is done by the `claude` CLI on the user's own
5
+ * machine. That CLI authenticates in one of two ways — a Claude subscription it was
6
+ * signed into once, or an Anthropic API key. Both work, and they are BILLED FROM
7
+ * DIFFERENT WALLETS: a claude.ai subscription grants no API credit, and an API key
8
+ * gives no subscription.
9
+ *
10
+ * This matters because the gate used to demand an API key from everyone, and two of
11
+ * the three tools never even forwarded it — they spawn `claude` with the ambient
12
+ * environment. So a Max subscriber with a working setup was sent to buy credits for a
13
+ * credential the engine ignored. The question is not "do you have a key" but "can this
14
+ * machine run it", and only the machine can answer.
15
+ *
16
+ * `claude auth status` answers it for free and instantly — no tokens, no round trip to
17
+ * Anthropic's inference API. It is also the only way to see the SUBSCRIPTION behind an
18
+ * API key: with ANTHROPIC_API_KEY set the CLI reports `apiKeySource` and hides the
19
+ * account, so the probe deliberately runs without it.
20
+ */
21
+
22
+ import { spawnClaude } from './spawn-claude.mjs';
23
+
24
+ const TTL_MS = 60 * 1000;
25
+
26
+ let cached = null;
27
+ let inflight = null;
28
+
29
+ function cleanEnv() {
30
+ const env = { ...process.env };
31
+ // The same strip the agent runner does: a CLI spawned from inside Claude Code
32
+ // otherwise inherits markers that change how it behaves.
33
+ delete env.CLAUDECODE;
34
+ delete env.CLAUDE_CODE_ENTRYPOINT;
35
+ // Deliberately removed: the machine's own auth is exactly what we are asking
36
+ // about. With a key in the environment the CLI reports the key and nothing about
37
+ // the subscription underneath it.
38
+ delete env.ANTHROPIC_API_KEY;
39
+ return env;
40
+ }
41
+
42
+ function run(args, { timeoutMs = 20_000, detached = false } = {}) {
43
+ return new Promise((resolve) => {
44
+ const env = cleanEnv();
45
+
46
+ let out = '';
47
+ let err = '';
48
+ let done = false;
49
+ let child;
50
+ try {
51
+ child = spawnClaude(args, {
52
+ env,
53
+ detached,
54
+ stdio: detached ? 'ignore' : ['ignore', 'pipe', 'pipe'],
55
+ });
56
+ } catch (e) {
57
+ return resolve({ code: null, out, err: e.code === 'ENOENT' ? 'not-installed' : e.message });
58
+ }
59
+ if (detached) {
60
+ child.unref();
61
+ return resolve({ code: 0, out, err });
62
+ }
63
+
64
+ const timer = setTimeout(() => {
65
+ if (done) return;
66
+ done = true;
67
+ child.kill('SIGKILL');
68
+ resolve({ code: null, out, err: 'timeout' });
69
+ }, timeoutMs);
70
+
71
+ child.stdout.on('data', (d) => (out += d));
72
+ child.stderr.on('data', (d) => (err += d));
73
+ child.on('error', (e) => {
74
+ if (done) return;
75
+ done = true;
76
+ clearTimeout(timer);
77
+ resolve({ code: null, out, err: e.code === 'ENOENT' ? 'not-installed' : e.message });
78
+ });
79
+ child.on('close', (code) => {
80
+ if (done) return;
81
+ done = true;
82
+ clearTimeout(timer);
83
+ resolve({ code, out, err });
84
+ });
85
+ });
86
+ }
87
+
88
+ function parse(out) {
89
+ try {
90
+ const i = out.indexOf('{');
91
+ const j = out.lastIndexOf('}');
92
+ return i >= 0 && j > i ? JSON.parse(out.slice(i, j + 1)) : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ async function measure() {
99
+ const r = await run(['auth', 'status']);
100
+ if (r.err === 'not-installed') {
101
+ return { ok: false, mode: 'none', reason: 'cli-missing', account: null, plan: null };
102
+ }
103
+ const st = parse(r.out);
104
+ if (!st) {
105
+ return {
106
+ ok: false,
107
+ mode: 'none',
108
+ reason: 'cli-broken',
109
+ account: null,
110
+ plan: null,
111
+ detail: (r.err || r.out || '').slice(0, 300),
112
+ };
113
+ }
114
+ if (!st.loggedIn) {
115
+ return { ok: false, mode: 'none', reason: 'not-authenticated', account: null, plan: null };
116
+ }
117
+ // Signed in with a claude.ai account → the subscription pays, no key needed.
118
+ // Anything else means the CLI is leaning on API billing, which needs credit.
119
+ const bySubscription = st.authMethod === 'claude.ai' && !st.apiKeySource;
120
+ return {
121
+ ok: true,
122
+ mode: bySubscription ? 'subscription' : 'key',
123
+ reason: null,
124
+ account: st.email ?? null,
125
+ plan: st.subscriptionType ?? null,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * @param {{force?: boolean}} [opts]
131
+ * @returns {Promise<{ok:boolean, mode:'subscription'|'key'|'none', reason:string|null,
132
+ * account:string|null, plan:string|null, checkedAt:number}>}
133
+ */
134
+ export async function probeEngine({ force = false } = {}) {
135
+ if (cached && !force && Date.now() - cached.checkedAt < TTL_MS) return cached;
136
+ // One probe at a time: three panels opening at once must not spawn three CLIs.
137
+ if (inflight) return inflight;
138
+ inflight = measure()
139
+ .then((r) => {
140
+ cached = { ...r, checkedAt: Date.now() };
141
+ return cached;
142
+ })
143
+ .finally(() => {
144
+ inflight = null;
145
+ });
146
+ return inflight;
147
+ }
148
+
149
+ /* ── sign-in ─────────────────────────────────────────────────────────────────
150
+ * We do not build a "connect with Claude" of our own — there is no such thing for a
151
+ * third-party app, and faking one would be exactly the wrong instinct. What we do is
152
+ * run Anthropic's own `claude auth login` here and HAND THE USER ITS LINK.
153
+ *
154
+ * The CLI prints the authorize URL on its first line of output and then waits for the
155
+ * flow to land either way: its loopback callback fires (the browser is on this machine
156
+ * and can reach it), or the person pastes back the code the callback page shows them.
157
+ * Both endings are kept open, which is why stdout is READ instead of thrown away and
158
+ * stdin stays a live pipe — the old version spawned this detached with `stdio:'ignore'`
159
+ * and so could neither show the link nor accept the code, leaving the user with a
160
+ * browser tab and a button that had visibly done nothing.
161
+ */
162
+
163
+ /** The one login in flight: { child, url, done, ok, startedAt }. */
164
+ let login = null;
165
+ /** An abandoned sign-in must not leave a CLI sitting on the machine forever. */
166
+ const LOGIN_TTL_MS = 10 * 60 * 1000;
167
+ const URL_RE = /(https?:\/\/[^\s"'<>]+)/;
168
+
169
+ function endLogin() {
170
+ if (!login || login.done) return;
171
+ login.done = true;
172
+ try {
173
+ login.child.kill('SIGKILL');
174
+ } catch {
175
+ /* already gone */
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Launch the sign-in and return the URL to put in front of the user.
181
+ *
182
+ * @returns {Promise<{ok:boolean, url?:string, reason?:string}>}
183
+ */
184
+ export async function startLogin() {
185
+ /* Idempotent while one is running. Every spawn mints a FRESH PKCE challenge and
186
+ invalidates the previous one, so re-opening the dialog (or a second panel asking)
187
+ must hand back the SAME link — otherwise the URL the user already copied dies in
188
+ their clipboard and the sign-in fails with nothing on screen to explain why. */
189
+ if (login && !login.done && login.url && Date.now() - login.startedAt < LOGIN_TTL_MS) {
190
+ return { ok: true, url: login.url };
191
+ }
192
+ endLogin();
193
+
194
+ const probe = await run(['--version'], { timeoutMs: 10_000 });
195
+ if (probe.err === 'not-installed') return { ok: false, reason: 'cli-missing' };
196
+
197
+ let child;
198
+ try {
199
+ child = spawnClaude(['auth', 'login', '--claudeai'], {
200
+ env: cleanEnv(),
201
+ stdio: ['pipe', 'pipe', 'pipe'],
202
+ });
203
+ } catch {
204
+ return { ok: false, reason: 'login-failed' };
205
+ }
206
+
207
+ const state = { child, url: null, done: false, ok: false, startedAt: Date.now() };
208
+ login = state;
209
+ cached = null; // the answer is about to change
210
+
211
+ const ttl = setTimeout(endLogin, LOGIN_TTL_MS);
212
+ ttl.unref?.();
213
+
214
+ return new Promise((resolve) => {
215
+ let out = '';
216
+ let settled = false;
217
+ const give = (r) => {
218
+ if (settled) return;
219
+ settled = true;
220
+ clearTimeout(wait);
221
+ resolve(r);
222
+ };
223
+ /* The URL is printed within a second; if it somehow is not, say so rather than
224
+ hang the dialog — the CLI may still be opening the browser by itself, which is
225
+ the pre-existing behaviour and a survivable outcome. */
226
+ const wait = setTimeout(() => give({ ok: true, url: null }), 12_000);
227
+
228
+ const read = (chunk) => {
229
+ out += chunk;
230
+ if (!state.url) {
231
+ // "If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?…"
232
+ const line = out.split('\n').find((l) => /visit:/i.test(l) && URL_RE.test(l));
233
+ const m = line?.match(URL_RE);
234
+ if (m) {
235
+ state.url = m[1];
236
+ give({ ok: true, url: m[1] });
237
+ }
238
+ }
239
+ /* The CLI says so out loud when the loopback callback lands. Busting the cache
240
+ here means the panel's next poll turns green immediately instead of serving a
241
+ 60-second-old "not signed in". */
242
+ if (/login successful/i.test(out)) {
243
+ state.ok = true;
244
+ cached = null;
245
+ }
246
+ };
247
+ child.stdout.on('data', read);
248
+ child.stderr.on('data', read);
249
+ child.on('error', () => {
250
+ state.done = true;
251
+ give({ ok: false, reason: 'login-failed' });
252
+ });
253
+ child.on('close', () => {
254
+ state.done = true;
255
+ clearTimeout(ttl);
256
+ cached = null;
257
+ give({ ok: !!state.url, url: state.url ?? undefined, reason: state.url ? undefined : 'login-failed' });
258
+ });
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Hand the CLI the code from the callback page.
264
+ *
265
+ * Only needed when the browser could not reach the loopback callback — a different
266
+ * machine, or a browser that refuses to open localhost. Then Anthropic's page shows a
267
+ * code instead of redirecting, and this is where it goes.
268
+ *
269
+ * @param {string} code
270
+ * @returns {{ok:boolean, reason?:string}}
271
+ */
272
+ export function submitLoginCode(code) {
273
+ const value = String(code ?? '').trim();
274
+ if (!value) return { ok: false, reason: 'empty-code' };
275
+ if (!login || login.done || !login.child.stdin.writable) return { ok: false, reason: 'no-login' };
276
+ try {
277
+ login.child.stdin.write(value + '\n');
278
+ } catch {
279
+ return { ok: false, reason: 'no-login' };
280
+ }
281
+ cached = null;
282
+ return { ok: true };
283
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Where the message will be pasted decides what the message may be.
3
+ *
4
+ * A Gmail letter, a LinkedIn connection note and an Instagram DM are not the same
5
+ * text at three lengths — they have different openings, different etiquette, and
6
+ * one of them is hard-capped at 300 characters by the platform itself. Asking an
7
+ * agent for "a message" and pasting it wherever produces text that reads wrong in
8
+ * every box. So the platform is a choice, its rules go into the prompt, and its
9
+ * limit is checked in code afterwards: the model is told the cap, and then we
10
+ * measure. A draft that does not fit is flagged, not silently pasted and rejected
11
+ * by LinkedIn in front of the customer.
12
+ */
13
+ export const FORMATS = [
14
+ {
15
+ id: 'email',
16
+ label: 'Письмо (Gmail)',
17
+ hint: 'Тема + тело, можно ссылки и подпись',
18
+ parts: ['subject', 'body'],
19
+ limit: 1400,
20
+ rules: [
21
+ 'Тема — до 60 символов, конкретная, без «Предложение о сотрудничестве».',
22
+ 'Тело — 90–150 слов. Первая фраза о НИХ и о том, что вы у них увидели, а не о нас.',
23
+ 'Одна ссылка максимум, в конце. Подпись в две строки.',
24
+ 'Один вопрос в конце — на него легко ответить одной строкой.',
25
+ ],
26
+ },
27
+ {
28
+ id: 'linkedin-note',
29
+ label: 'LinkedIn — заявка в контакты',
30
+ hint: 'Жёсткий лимит 300 символов, без темы и ссылок',
31
+ parts: ['body'],
32
+ limit: 300,
33
+ rules: [
34
+ 'МАКСИМУМ 300 символов вместе с пробелами — платформа обрежет остальное.',
35
+ 'Без ссылок: заявки со ссылками принимают заметно хуже.',
36
+ 'Без темы, без подписи, без «Здравствуйте, меня зовут…» — сразу зацепка и одна фраза о том, зачем.',
37
+ ],
38
+ },
39
+ {
40
+ id: 'linkedin-message',
41
+ label: 'LinkedIn — сообщение',
42
+ hint: 'После принятия контакта: 60–110 слов, без темы',
43
+ parts: ['body'],
44
+ limit: 900,
45
+ rules: [
46
+ '60–110 слов. Без темы и без формальной подписи.',
47
+ 'Первая строка — конкретная деталь из их профиля или сайта.',
48
+ 'Ссылка допустима одна, и только если она уместна.',
49
+ ],
50
+ },
51
+ {
52
+ id: 'instagram-dm',
53
+ label: 'Instagram — директ',
54
+ hint: 'Очень коротко, разговорно, без ссылок',
55
+ parts: ['body'],
56
+ limit: 400,
57
+ rules: [
58
+ '2–4 предложения, разговорным тоном, на «вы» но без канцелярита.',
59
+ 'Без ссылок в первом сообщении — Instagram занижает такие в доставке.',
60
+ 'Заканчивать вопросом, на который отвечают в одну строку.',
61
+ ],
62
+ },
63
+ {
64
+ id: 'telegram',
65
+ label: 'Telegram',
66
+ hint: 'Коротко и по делу, ссылки можно',
67
+ parts: ['body'],
68
+ limit: 700,
69
+ rules: [
70
+ '40–80 слов. Прямо, без вступлений на три строки.',
71
+ 'Ссылка допустима одна.',
72
+ 'Тон — как пишут коллеге, а не как рассылка.',
73
+ ],
74
+ },
75
+ {
76
+ id: 'contact-form',
77
+ label: 'Форма на сайте',
78
+ hint: 'Одно поле, надо оставить контакт для ответа',
79
+ parts: ['body'],
80
+ limit: 1000,
81
+ rules: [
82
+ '80–120 слов. Тема отдельным полем не предусмотрена — первая строка играет её роль.',
83
+ 'Обязательно оставить, как с вами связаться: почта или Telegram.',
84
+ 'Без вложений и без обещаний «перезвоним» — писать будет человек, а не бот.',
85
+ ],
86
+ },
87
+ ];
88
+
89
+ export const formatById = (id) => FORMATS.find((f) => f.id === id) || FORMATS[0];
90
+
91
+ /**
92
+ * Measure what the model produced. The platform limit is a fact about the box the
93
+ * text will be pasted into, so trusting the prompt with it would be trusting the
94
+ * wrong thing — LinkedIn truncating a note at 300 characters is a broken first
95
+ * impression in front of a customer.
96
+ */
97
+ export function checkDraft(format, draft) {
98
+ const body = String(draft?.body || '');
99
+ const subject = String(draft?.subject || '');
100
+ const chars = body.length + subject.length;
101
+ const problems = [];
102
+ if (!body.trim()) problems.push('пустой текст');
103
+ if (chars > format.limit) problems.push(`${chars} символов при лимите ${format.limit}`);
104
+ if (format.id === 'linkedin-note' && /https?:\/\//i.test(body)) problems.push('ссылка в заявке — так принимают хуже');
105
+ if (format.id === 'instagram-dm' && /https?:\/\//i.test(body)) problems.push('ссылка в первом директе — Instagram занижает доставку');
106
+ if (format.parts.includes('subject') && !subject.trim()) problems.push('нет темы');
107
+ return { chars, limit: format.limit, ok: problems.length === 0, problems };
108
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * In-memory run store. A run is a long agent job (minutes), so the panel polls it
3
+ * rather than holding a socket: the same shape Work Finder uses, and it survives a
4
+ * page reload — the run keeps going and the panel picks it back up by id.
5
+ *
6
+ * Not persisted on purpose at this stage: results ARE written to disk by the run
7
+ * itself (runs/*.json), and keeping a second copy in a database we have not
8
+ * designed yet would be the wrong thing to be careful about first.
9
+ */
10
+ import { randomUUID } from 'node:crypto';
11
+
12
+ const jobs = new Map();
13
+ const MAX_LOG = 400;
14
+
15
+ export function createJob(input) {
16
+ const id = randomUUID().slice(0, 8);
17
+ const job = {
18
+ id,
19
+ status: 'running', // running | done | failed | cancelled
20
+ /* WHERE the run is, as a fact rather than as prose in the log. The header froze
21
+ at «Ищу… 10 из 10» for the whole link-verification phase — a counter stuck at
22
+ its maximum while a slow phase ran — because the count was the only signal it
23
+ had. Parsing log lines instead would leave the header one rewording away from
24
+ lying again. */
25
+ phase: 'starting', // starting | searching | verifying | done
26
+ input, // { productId, channels, brief, count, model }
27
+ log: [],
28
+ prospects: [],
29
+ error: null,
30
+ costUsd: null,
31
+ startedAt: Date.now(),
32
+ finishedAt: null,
33
+ controller: new AbortController(),
34
+ };
35
+ jobs.set(id, job);
36
+ return job;
37
+ }
38
+
39
+ export const getJob = (id) => jobs.get(id) || null;
40
+
41
+ /** Public view — the controller is not serialisable and nobody outside needs it. */
42
+ export function view(job) {
43
+ if (!job) return null;
44
+ const { controller, ...rest } = job;
45
+ return { ...rest, durationMs: (job.finishedAt || Date.now()) - job.startedAt };
46
+ }
47
+
48
+ export const listJobs = () =>
49
+ [...jobs.values()]
50
+ .sort((a, b) => b.startedAt - a.startedAt)
51
+ .slice(0, 20)
52
+ .map((j) => ({
53
+ id: j.id,
54
+ status: j.status,
55
+ brief: j.input.brief,
56
+ productId: j.input.productId,
57
+ found: j.prospects.length,
58
+ startedAt: j.startedAt,
59
+ }));
60
+
61
+ export function log(job, line) {
62
+ job.log.push({ t: Date.now(), line });
63
+ // A twenty-minute run can emit thousands of tool calls; the panel only ever
64
+ // shows the tail, and an unbounded array is how a local server starts leaking.
65
+ if (job.log.length > MAX_LOG) job.log.splice(0, job.log.length - MAX_LOG);
66
+ }
67
+
68
+ export function cancel(id) {
69
+ const job = jobs.get(id);
70
+ if (!job || job.status !== 'running') return false;
71
+ job.controller.abort();
72
+ job.status = 'cancelled';
73
+ job.finishedAt = Date.now();
74
+ return true;
75
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The tool's knowledge base — one markdown file per product in `knowledge/`.
3
+ *
4
+ * A seller's agent has to answer "а чем вы лучше Tidio?" and "а клиенту нужен
5
+ * Telegram?" in one line, correctly, without inventing a price. That means the
6
+ * facts have to be written down once, from the repo's own payment docs, and read
7
+ * on demand — not squeezed into a one-line pitch and not recalled from training.
8
+ *
9
+ * Read at call time rather than cached: these files are edited by hand when a price
10
+ * or a positioning changes, and a stale answer about money is the expensive kind.
11
+ */
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { resolve, dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../knowledge');
17
+
18
+ export function knowledgeFor(productId) {
19
+ const file = resolve(DIR, `${productId}.md`);
20
+ if (!existsSync(file)) return '';
21
+ try {
22
+ return readFileSync(file, 'utf8');
23
+ } catch {
24
+ return '';
25
+ }
26
+ }
@@ -0,0 +1,183 @@
1
+ import { SCOPE_LOCK } from './scope.mjs';
2
+ import { knowledgeFor } from './knowledge.mjs';
3
+
4
+ /**
5
+ * The prompt is the product.
6
+ *
7
+ * "Find me buyers for Assist" produces a list of plausible-sounding companies and
8
+ * nothing you can check. What makes the output worth anything is the EVIDENCE
9
+ * contract below: every claim that qualifies a prospect must carry the URL the
10
+ * agent actually opened and what on that page shows it. Unverifiable means
11
+ * excluded — a list you cannot check is a list you cannot use.
12
+ */
13
+
14
+ /** @typedef {{ id:string, product:{name:string,pitch:string,url?:string},
15
+ * who:string, must:string[], mustNot?:string[], signals?:string[],
16
+ * geo?:string, language?:string, contact?:string, avoid?:string[] }} Icp */
17
+
18
+ /**
19
+ * The panel's input is one line of free text, not a file. Turn it into the same
20
+ * spec the file-driven CLI uses, so both paths run the identical prompt: the panel
21
+ * supplies the product (chosen from OUR catalog, never typed) and the brief
22
+ * supplies the audience.
23
+ *
24
+ * The brief is NOT split into `must` rules here. Guessing which half of a sentence
25
+ * is a hard criterion is how a tool starts quietly searching for something the
26
+ * person did not ask for; the agent reads the brief as written and is told that
27
+ * every part of it is a requirement.
28
+ */
29
+ export function briefToIcp({ product, brief, channels = [], language }) {
30
+ return {
31
+ id: 'panel',
32
+ product: { name: product.name, pitch: product.pitch, url: product.url },
33
+ who: brief,
34
+ must: [
35
+ 'Совпадает с описанием выше во ВСЕХ его частях — каждая названная деталь является требованием, а не пожеланием',
36
+ 'Это реальная действующая компания или человек, а не каталог, агрегатор или заглушка',
37
+ `Похоже, что им может быть нужен продукт: ${product.hook || product.buyer}`,
38
+ ],
39
+ mustNot: ['Заброшенный сайт или недействующий бизнес', 'Крупная корпорация с собственным отделом закупок и колл-центром'],
40
+ signals: [product.buyer ? `Совпадает с типичным покупателем: ${product.buyer}` : null].filter(Boolean),
41
+ language,
42
+ channels,
43
+ };
44
+ }
45
+
46
+ const SCHEMA = `{
47
+ "company": "legal or trading name",
48
+ "url": "the company's own site (homepage)",
49
+ "country": "ISO-2, e.g. PL",
50
+ "language": "the language to write to them in, ISO-2",
51
+ "what_they_do": "one line, concrete",
52
+ "why_match": ["the ICP criteria this one satisfies, one per line, specific"],
53
+ "evidence": [
54
+ { "claim": "what you are asserting", "source": "URL you opened", "how": "what on that page shows it" }
55
+ ],
56
+ "disqualifiers_checked": ["the must-not criteria you checked and did not find"],
57
+ "contact": { "type": "form | email | profile", "value": "…", "source": "URL where you found it" },
58
+ "decision_maker": { "name": "if visible on the site, else null", "role": "…", "source": "URL" },
59
+ "hook": "the one specific detail to open with — a page, a product, a recent change",
60
+ "confidence": "high | medium | low"
61
+ }`;
62
+
63
+ export function researchPrompt(/** @type {Icp} */ icp, count) {
64
+ const line = (label, v) => (v ? `${label}: ${v}` : '');
65
+ return `${SCOPE_LOCK}
66
+
67
+ You are a research agent finding BUYERS for a specific product. You are not writing to anyone — you find and you prove.
68
+
69
+ # The product you are finding buyers for
70
+ ${icp.product.name} — ${icp.product.pitch}${icp.product.url ? `\nProduct page: ${icp.product.url}` : ''}
71
+
72
+ # Who counts as a prospect
73
+ ${icp.who}
74
+
75
+ Must be true (every one of these):
76
+ ${icp.must.map((m) => `- ${m}`).join('\n')}
77
+ ${icp.mustNot?.length ? `\nMust NOT be true (check each and say you checked):\n${icp.mustNot.map((m) => `- ${m}`).join('\n')}` : ''}
78
+ ${icp.signals?.length ? `\nGood signs (not required, raise confidence):\n${icp.signals.map((s) => `- ${s}`).join('\n')}` : ''}
79
+ ${line('Geography', icp.geo)}
80
+ ${line('Write to them in', icp.language)}
81
+ ${line('Contact preference', icp.contact)}
82
+ ${icp.avoid?.length ? `Do not include: ${icp.avoid.join(', ')}` : ''}
83
+
84
+ # How to work
85
+ 1. Search the public web for candidates. Directories, marketplaces, "best X in Y" lists, industry catalogues, local business media — whatever surfaces real companies.
86
+ 2. OPEN each candidate's own site with WebFetch before judging it. A search snippet is not evidence.
87
+ 3. Check every "must" criterion against something you actually saw on a page. If you cannot verify a criterion, the candidate does not qualify — drop it and move on.
88
+ 4. For "must not" criteria, say explicitly what you checked (e.g. "no chat widget: fetched the homepage and the contact page, no live-chat script or bubble in the markup").
89
+ 5. Find a contact route and a decision-maker only if the site shows them. Never guess an email address; an inferred pattern is not a contact.
90
+ 6. Prefer a company whose site gives you a concrete hook — a product, a page, a recent post — over a bigger company you can say nothing specific about.
91
+
92
+ # Hard rules
93
+ - Every entry in "evidence" needs a real URL you opened in this session. No URL, no claim.
94
+ - Use the URL you actually fetched, character for character. Do not reconstruct a product or page URL from a title — on the first run an agent guessed a product path that 404s, and a guessed URL is a fabricated source.
95
+ - Where different claims live on different pages (contacts, about, a product), cite the page each one is on rather than the homepage for all of them.
96
+ - Never invent a company, a person, a number or a URL. If the web does not give you ${count}, return fewer and say why at the end.
97
+ - Do not include companies that only match on the geography or the industry. Matching the ICP means matching the criteria.
98
+ - Do not visit, scrape or log into social networks. Public web pages only.
99
+
100
+ # Output
101
+ As soon as a prospect is fully verified, emit ONE line, nothing else on it:
102
+ PROSPECT: {json}
103
+ using exactly this shape:
104
+ ${SCHEMA}
105
+
106
+ Keep going until you have ${count} verified prospects or you have genuinely exhausted the search.
107
+
108
+ When done, output a final JSON array of all the prospects you emitted, in a \`\`\`json fence, followed by two or three sentences on where you looked, what you had to drop and why.`;
109
+ }
110
+
111
+ /**
112
+ * Drafting is a SEPARATE pass, and a cheap one: the research is already done, so
113
+ * this call needs no web tools at all — it reads the prospect we verified and
114
+ * writes the message. Splitting it also means you can re-draft the same list for
115
+ * another platform without paying to find it again.
116
+ *
117
+ * The platform's rules and its hard limit go in, because "a message" is not a
118
+ * thing: a 300-character LinkedIn note and a Gmail letter share no shape.
119
+ */
120
+ export function draftPrompt({ product, prospects, format, sender, language, platform }) {
121
+ // The platform decides the shape of the message, so it is never guessed. If the
122
+ // panel did not settle it, the agent asks — one line, no searching, no drafting.
123
+ const platformLine = platform
124
+ ? `Куда это вставят: ${platform}`
125
+ : format
126
+ ? `Куда это вставят: ${format.label}`
127
+ : 'ПЛОЩАДКА НЕ УКАЗАНА';
128
+ const kb = knowledgeFor(product.id);
129
+ const who = prospects
130
+ .map((p, i) =>
131
+ [
132
+ `## ${i + 1}. ${p.company} — ${p.url}`,
133
+ p.what_they_do ? `Чем занимаются: ${p.what_they_do}` : '',
134
+ p.hook ? `Зацепка (использовать в первой фразе): ${p.hook}` : '',
135
+ p.why_match?.length ? `Почему подходят: ${p.why_match.join('; ')}` : '',
136
+ p.decision_maker?.name ? `Кому пишем: ${p.decision_maker.name}${p.decision_maker.role ? `, ${p.decision_maker.role}` : ''}` : '',
137
+ p.language ? `Язык письма: ${p.language}` : '',
138
+ ]
139
+ .filter(Boolean)
140
+ .join('\n'),
141
+ )
142
+ .join('\n\n');
143
+
144
+ return `${SCOPE_LOCK}
145
+
146
+ Ты пишешь ПЕРВОЕ касание от лица продавца. Не рассылку — письмо конкретному человеку, который увидит его среди двадцати других.
147
+
148
+ # Что продаём
149
+ ${product.name} — ${product.pitch}
150
+ Кто обычно покупает: ${product.buyer}
151
+ Цена: ${product.price}
152
+ Страница продукта: ${product.url}
153
+
154
+ # От кого
155
+ ${sender || 'Qiksy Studio'}
156
+
157
+ # ${platformLine}
158
+ ${format ? format.rules.map((r) => `- ${r}`).join('\n') : '- Правила площадки не заданы — спроси, прежде чем писать.'}
159
+ ${format ? `- Жёсткий предел: ${format.limit} символов${format.parts.includes('subject') ? ' (тема + тело вместе)' : ''}. Выйдешь за него — текст обрежут при отправке.` : ''}
160
+
161
+ # Если площадка не названа
162
+ Разные площадки — разные тексты: письмо в Gmail, заявка в LinkedIn на 300 символов и директ в Instagram не совпадают ни длиной, ни этикетом. Если выше сказано «ПЛОЩАДКА НЕ УКАЗАНА» или из запроса непонятно, куда пойдёт сообщение, НЕ пиши черновики. Верни одну строку и остановись:
163
+ NEED_PLATFORM: {"ask":"Для какой площадки готовить — письмо (Gmail), заявка или сообщение в LinkedIn, директ в Instagram, Telegram, форма на сайте?"}
164
+
165
+ # База знаний по продукту — отвечать по ней, а не по памяти
166
+ ${kb || '(нет файла знаний — опирайся только на описание продукта выше и ничего не выдумывай)'}
167
+
168
+ # Как писать
169
+ - Первая фраза — про НИХ и про то, что реально видно у них на сайте (зацепка выше). Не про нас, не «надеюсь, у вас всё хорошо».
170
+ - Не обещай результатов, которых не знаешь. Никаких «увеличим конверсию на 30%».
171
+ - Не выдумывай фактов о компании: можно опираться только на то, что дано выше.
172
+ - Один вопрос в конце, на который можно ответить одной строкой.
173
+ - Язык письма — как указано у кандидата${language ? `, иначе ${language}` : ''}.
174
+ - Тон: коллега, который заметил конкретную вещь, а не отдел продаж.
175
+
176
+ # Формат ответа
177
+ Верни ТОЛЬКО JSON-массив в \`\`\`json fence, по объекту на кандидата, в том же порядке:
178
+ [{"company":"…"${format.parts.includes('subject') ? ',"subject":"…"' : ''},"body":"…"}]
179
+ Без пояснений до или после.
180
+
181
+ # Кандидаты
182
+ ${who}`;
183
+ }