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.
- package/ARCHITECTURE.md +78 -0
- package/INSTALL-PROMPT.md +53 -0
- package/bin/qiksy.mjs +302 -0
- package/package.json +32 -0
- package/tools/client-finder/vendor/README.md +70 -0
- package/tools/client-finder/vendor/find.mjs +121 -0
- package/tools/client-finder/vendor/icp/assist-pl-shops.json +30 -0
- package/tools/client-finder/vendor/icp/assist-ua-shops.json +30 -0
- package/tools/client-finder/vendor/knowledge/assist.md +68 -0
- package/tools/client-finder/vendor/knowledge/custom.md +36 -0
- package/tools/client-finder/vendor/knowledge/multilogin.md +29 -0
- package/tools/client-finder/vendor/knowledge/pay.md +26 -0
- package/tools/client-finder/vendor/knowledge/qa-copilot.md +43 -0
- package/tools/client-finder/vendor/knowledge/travel.md +43 -0
- package/tools/client-finder/vendor/knowledge/work-finder.md +52 -0
- package/tools/client-finder/vendor/lib/access.mjs +108 -0
- package/tools/client-finder/vendor/lib/agent.mjs +204 -0
- package/tools/client-finder/vendor/lib/approver-mcp.mjs +78 -0
- package/tools/client-finder/vendor/lib/browser.mjs +108 -0
- package/tools/client-finder/vendor/lib/catalog.mjs +86 -0
- package/tools/client-finder/vendor/lib/channels.mjs +54 -0
- package/tools/client-finder/vendor/lib/deliver.mjs +89 -0
- package/tools/client-finder/vendor/lib/engine.mjs +283 -0
- package/tools/client-finder/vendor/lib/formats.mjs +108 -0
- package/tools/client-finder/vendor/lib/jobs.mjs +75 -0
- package/tools/client-finder/vendor/lib/knowledge.mjs +26 -0
- package/tools/client-finder/vendor/lib/prompts.mjs +183 -0
- package/tools/client-finder/vendor/lib/report.mjs +75 -0
- package/tools/client-finder/vendor/lib/scope.mjs +162 -0
- package/tools/client-finder/vendor/lib/spawn-claude.mjs +44 -0
- package/tools/client-finder/vendor/lib/verify.mjs +96 -0
- package/tools/client-finder/vendor/package.json +8 -0
- package/tools/client-finder/vendor/server.mjs +390 -0
- package/tools/travel/vendor/README.md +58 -0
- package/tools/travel/vendor/docs/CONTRACT.md +174 -0
- package/tools/travel/vendor/docs/ENGINE-MIGRATION.md +33 -0
- package/tools/travel/vendor/lib/agent.mjs +190 -0
- package/tools/travel/vendor/lib/engine.mjs +283 -0
- package/tools/travel/vendor/lib/jobs.mjs +125 -0
- package/tools/travel/vendor/lib/license.mjs +41 -0
- package/tools/travel/vendor/lib/plans.mjs +28 -0
- package/tools/travel/vendor/lib/prompts.mjs +394 -0
- package/tools/travel/vendor/lib/proposal.mjs +207 -0
- package/tools/travel/vendor/lib/spawn-claude.mjs +44 -0
- package/tools/travel/vendor/lib/store.mjs +75 -0
- package/tools/travel/vendor/package.json +12 -0
- package/tools/travel/vendor/public/app.js +996 -0
- package/tools/travel/vendor/public/index.html +112 -0
- package/tools/travel/vendor/public/styles.css +760 -0
- package/tools/travel/vendor/scripts/approver-mcp.mjs +80 -0
- package/tools/travel/vendor/scripts/launch-browser.mjs +77 -0
- package/tools/travel/vendor/server.mjs +755 -0
- package/tools/work-finder/vendor/.claude/settings.local.json +27 -0
- package/tools/work-finder/vendor/.mcp.json +9 -0
- package/tools/work-finder/vendor/CLAUDE.md +24 -0
- package/tools/work-finder/vendor/README.md +44 -0
- package/tools/work-finder/vendor/bin/qiksy-work-finder.mjs +127 -0
- package/tools/work-finder/vendor/lib/agent.mjs +159 -0
- package/tools/work-finder/vendor/lib/catalog.mjs +41 -0
- package/tools/work-finder/vendor/lib/channels.mjs +73 -0
- package/tools/work-finder/vendor/lib/engine.mjs +283 -0
- package/tools/work-finder/vendor/lib/jobs.mjs +127 -0
- package/tools/work-finder/vendor/lib/license.mjs +60 -0
- package/tools/work-finder/vendor/lib/linkcheck.mjs +72 -0
- package/tools/work-finder/vendor/lib/linkedin.mjs +22 -0
- package/tools/work-finder/vendor/lib/plans.mjs +33 -0
- package/tools/work-finder/vendor/lib/prompts.mjs +217 -0
- package/tools/work-finder/vendor/lib/spawn-claude.mjs +44 -0
- package/tools/work-finder/vendor/lib/store.mjs +125 -0
- package/tools/work-finder/vendor/package.json +41 -0
- package/tools/work-finder/vendor/public/app.js +1863 -0
- package/tools/work-finder/vendor/public/index.html +518 -0
- package/tools/work-finder/vendor/public/styles.css +930 -0
- package/tools/work-finder/vendor/scripts/approver-mcp.mjs +69 -0
- package/tools/work-finder/vendor/scripts/extract-linkedin.mjs +81 -0
- package/tools/work-finder/vendor/scripts/launch-browser.mjs +78 -0
- package/tools/work-finder/vendor/scripts/screenshot.mjs +17 -0
- 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
|
+
}
|