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,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Claude runner for Client Finder — spawns `claude -p` with stream-json
|
|
3
|
+
* output, forwards tool events to the caller's log, and returns the final text.
|
|
4
|
+
*
|
|
5
|
+
* Lifted from products/work-finder-backend/lib/agent.mjs (same mechanism, same
|
|
6
|
+
* traps) with one deliberate difference: **no browser**. Stage 0 finds and proves
|
|
7
|
+
* prospects from public search and company sites, which WebSearch + WebFetch cover
|
|
8
|
+
* on their own. Adding Playwright would mean logged-in sessions and the ToS
|
|
9
|
+
* questions that come with them, for research that does not need either.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnClaude } from './spawn-claude.mjs';
|
|
12
|
+
import { resolve, dirname } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
|
|
17
|
+
// Kill live children when the process ends, so an orphaned `claude` can't keep
|
|
18
|
+
// burning tokens after we're gone.
|
|
19
|
+
const activeChildren = new Set();
|
|
20
|
+
function killAllChildren() {
|
|
21
|
+
for (const child of activeChildren) {
|
|
22
|
+
try {
|
|
23
|
+
child.kill('SIGKILL');
|
|
24
|
+
} catch {
|
|
25
|
+
/* already dead */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
activeChildren.clear();
|
|
29
|
+
}
|
|
30
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
31
|
+
process.on(sig, () => {
|
|
32
|
+
killAllChildren();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
process.on('exit', killAllChildren);
|
|
37
|
+
|
|
38
|
+
const APPROVER_SERVER = {
|
|
39
|
+
type: 'stdio',
|
|
40
|
+
command: process.execPath,
|
|
41
|
+
args: [resolve(ROOT, 'lib/approver-mcp.mjs')],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} o
|
|
46
|
+
* @param {string} o.prompt
|
|
47
|
+
* @param {string[]} [o.allowedTools] built-in tools the agent may use
|
|
48
|
+
* @param {string} [o.model] 'sonnet' | 'opus' | …
|
|
49
|
+
* @param {number} [o.timeoutMs]
|
|
50
|
+
* @param {(line: string) => void} [o.onEvent] tool-call log
|
|
51
|
+
* @param {(obj: unknown) => void} [o.onPartial] each `PROSPECT: {…}` line, as it lands
|
|
52
|
+
* @param {AbortSignal|null} [o.signal]
|
|
53
|
+
*/
|
|
54
|
+
export function runClaude({
|
|
55
|
+
prompt,
|
|
56
|
+
allowedTools = ['WebSearch', 'WebFetch'],
|
|
57
|
+
model = 'sonnet',
|
|
58
|
+
timeoutMs = 20 * 60_000,
|
|
59
|
+
onEvent = () => {},
|
|
60
|
+
onPartial = null,
|
|
61
|
+
signal = null,
|
|
62
|
+
apiKey = '',
|
|
63
|
+
}) {
|
|
64
|
+
return new Promise((resolvePromise) => {
|
|
65
|
+
// The user's global settings put WebSearch/WebFetch in the "ask" list, which
|
|
66
|
+
// auto-DENIES in -p mode and overrides --allowedTools (and even
|
|
67
|
+
// --dangerously-skip-permissions). So permissions go through our own approver
|
|
68
|
+
// MCP server via --permission-prompt-tool, with a whitelist.
|
|
69
|
+
const args = [
|
|
70
|
+
'-p', prompt,
|
|
71
|
+
'--output-format', 'stream-json', '--verbose',
|
|
72
|
+
'--model', model,
|
|
73
|
+
'--tools', [...allowedTools, 'ToolSearch'].join(','),
|
|
74
|
+
'--mcp-config', JSON.stringify({ mcpServers: { approver: APPROVER_SERVER } }),
|
|
75
|
+
'--strict-mcp-config',
|
|
76
|
+
'--permission-prompt-tool', 'mcp__approver__approve',
|
|
77
|
+
'--allowedTools', [...allowedTools, 'ToolSearch'].join(','),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
// A nested interactive session is refused; `-p` is fine, but strip the markers
|
|
81
|
+
// so the child never thinks it is running inside another session.
|
|
82
|
+
const env = { ...process.env };
|
|
83
|
+
delete env.CLAUDECODE;
|
|
84
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
85
|
+
// The seller's own key, handed to the child process and nowhere else: not
|
|
86
|
+
// stored, not logged, not written to any report. Absent, `claude` falls back
|
|
87
|
+
// to whatever this machine is already logged in with.
|
|
88
|
+
if (apiKey) env.ANTHROPIC_API_KEY = apiKey;
|
|
89
|
+
|
|
90
|
+
const child = spawnClaude(args, { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
91
|
+
activeChildren.add(child);
|
|
92
|
+
child.on('close', () => activeChildren.delete(child));
|
|
93
|
+
|
|
94
|
+
let resultText = '';
|
|
95
|
+
let errText = '';
|
|
96
|
+
let costUsd = null;
|
|
97
|
+
let settled = false;
|
|
98
|
+
let partialBuf = '';
|
|
99
|
+
let partialsEmitted = 0;
|
|
100
|
+
const t0 = Date.now();
|
|
101
|
+
|
|
102
|
+
const finish = (out) => {
|
|
103
|
+
if (settled) return;
|
|
104
|
+
settled = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
resolvePromise({ durationMs: Date.now() - t0, costUsd, ...out });
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
try {
|
|
111
|
+
child.kill('SIGKILL');
|
|
112
|
+
} catch {
|
|
113
|
+
/* already dead */
|
|
114
|
+
}
|
|
115
|
+
finish({ ok: false, error: `timeout after ${Math.round(timeoutMs / 1000)}s` });
|
|
116
|
+
}, timeoutMs);
|
|
117
|
+
|
|
118
|
+
if (signal) {
|
|
119
|
+
const onAbort = () => {
|
|
120
|
+
try {
|
|
121
|
+
child.kill('SIGKILL');
|
|
122
|
+
} catch {
|
|
123
|
+
/* already dead */
|
|
124
|
+
}
|
|
125
|
+
finish({ ok: false, error: 'cancelled' });
|
|
126
|
+
};
|
|
127
|
+
if (signal.aborted) onAbort();
|
|
128
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let buf = '';
|
|
132
|
+
child.stdout.on('data', (chunk) => {
|
|
133
|
+
buf += chunk;
|
|
134
|
+
let nl;
|
|
135
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
136
|
+
const line = buf.slice(0, nl).trim();
|
|
137
|
+
buf = buf.slice(nl + 1);
|
|
138
|
+
if (!line) continue;
|
|
139
|
+
let ev;
|
|
140
|
+
try {
|
|
141
|
+
ev = JSON.parse(line);
|
|
142
|
+
} catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (ev.type === 'assistant') {
|
|
146
|
+
for (const block of ev.message?.content || []) {
|
|
147
|
+
if (block.type === 'tool_use') {
|
|
148
|
+
const input = JSON.stringify(block.input || {});
|
|
149
|
+
onEvent(`⚙ ${block.name} ${input.length > 140 ? input.slice(0, 140) + '…' : input}`);
|
|
150
|
+
} else if (block.type === 'text' && block.text && onPartial) {
|
|
151
|
+
// Prospects stream as they are found, so a long run shows progress
|
|
152
|
+
// instead of twenty silent minutes.
|
|
153
|
+
partialBuf += block.text + '\n';
|
|
154
|
+
const matches = [...partialBuf.matchAll(/^PROSPECT:\s*(\{.*\})\s*$/gm)];
|
|
155
|
+
for (let i = partialsEmitted; i < matches.length; i++) {
|
|
156
|
+
try {
|
|
157
|
+
onPartial(JSON.parse(matches[i][1]));
|
|
158
|
+
} catch {
|
|
159
|
+
/* malformed partial — it will still arrive in the final array */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
partialsEmitted = Math.max(partialsEmitted, matches.length);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} else if (ev.type === 'result') {
|
|
166
|
+
resultText = ev.result || '';
|
|
167
|
+
costUsd = ev.total_cost_usd ?? null;
|
|
168
|
+
if (ev.is_error) errText = resultText || ev.subtype || 'agent error';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
child.stderr.on('data', (chunk) => {
|
|
173
|
+
errText += chunk;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
child.on('error', (err) => finish({ ok: false, error: `spawn failed: ${err.message}` }));
|
|
177
|
+
child.on('close', (code) => {
|
|
178
|
+
if (code === 0 && resultText) finish({ ok: true, result: resultText });
|
|
179
|
+
else finish({ ok: false, error: (errText || `claude exited with code ${code}`).slice(0, 2000) });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Pull a JSON value out of agent prose: direct parse → fenced block → bracket slice. */
|
|
185
|
+
export function extractJson(text) {
|
|
186
|
+
if (!text) return null;
|
|
187
|
+
const attempts = [text.trim()];
|
|
188
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
189
|
+
if (fence) attempts.push(fence[1].trim());
|
|
190
|
+
const firstArr = text.indexOf('[');
|
|
191
|
+
const lastArr = text.lastIndexOf(']');
|
|
192
|
+
if (firstArr >= 0 && lastArr > firstArr) attempts.push(text.slice(firstArr, lastArr + 1));
|
|
193
|
+
const firstObj = text.indexOf('{');
|
|
194
|
+
const lastObj = text.lastIndexOf('}');
|
|
195
|
+
if (firstObj >= 0 && lastObj > firstObj) attempts.push(text.slice(firstObj, lastObj + 1));
|
|
196
|
+
for (const candidate of attempts) {
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(candidate);
|
|
199
|
+
} catch {
|
|
200
|
+
/* next */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Minimal MCP stdio server: the permission-prompt handler for headless agents.
|
|
4
|
+
*
|
|
5
|
+
* The user's global ~/.claude/settings.json puts WebSearch/WebFetch in the "ask"
|
|
6
|
+
* list, and in `-p` mode "ask" resolves to "deny" — overriding --allowedTools.
|
|
7
|
+
* This server is passed as `--permission-prompt-tool mcp__approver__approve` and
|
|
8
|
+
* auto-approves ONLY the tools below. Everything else stays denied, which is what
|
|
9
|
+
* keeps a research agent from reaching for Bash or the filesystem.
|
|
10
|
+
*/
|
|
11
|
+
const ALLOW = [/^WebSearch$/, /^WebFetch$/, /^ToolSearch$/];
|
|
12
|
+
|
|
13
|
+
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
14
|
+
|
|
15
|
+
function handle(msg) {
|
|
16
|
+
const { id, method, params } = msg;
|
|
17
|
+
if (method === 'initialize') {
|
|
18
|
+
return send({
|
|
19
|
+
jsonrpc: '2.0',
|
|
20
|
+
id,
|
|
21
|
+
result: {
|
|
22
|
+
protocolVersion: params?.protocolVersion || '2024-11-05',
|
|
23
|
+
capabilities: { tools: {} },
|
|
24
|
+
serverInfo: { name: 'approver', version: '1.0.0' },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (method === 'tools/list') {
|
|
29
|
+
return send({
|
|
30
|
+
jsonrpc: '2.0',
|
|
31
|
+
id,
|
|
32
|
+
result: {
|
|
33
|
+
tools: [
|
|
34
|
+
{
|
|
35
|
+
name: 'approve',
|
|
36
|
+
description: 'Auto-approve permission prompts for whitelisted tools',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
tool_name: { type: 'string' },
|
|
41
|
+
input: { type: 'object' },
|
|
42
|
+
tool_use_id: { type: 'string' },
|
|
43
|
+
},
|
|
44
|
+
required: ['tool_name', 'input'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (method === 'tools/call') {
|
|
52
|
+
const { name, arguments: args = {} } = params || {};
|
|
53
|
+
if (name !== 'approve') return send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown tool' } });
|
|
54
|
+
const ok = ALLOW.some((re) => re.test(args.tool_name || ''));
|
|
55
|
+
const payload = ok
|
|
56
|
+
? { behavior: 'allow', updatedInput: args.input || {} }
|
|
57
|
+
: { behavior: 'deny', message: `tool ${args.tool_name} is not whitelisted for client-finder agents` };
|
|
58
|
+
return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(payload) }] } });
|
|
59
|
+
}
|
|
60
|
+
if (id !== undefined) send({ jsonrpc: '2.0', id, result: {} });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let buf = '';
|
|
64
|
+
process.stdin.setEncoding('utf8');
|
|
65
|
+
process.stdin.on('data', (chunk) => {
|
|
66
|
+
buf += chunk;
|
|
67
|
+
let nl;
|
|
68
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
69
|
+
const line = buf.slice(0, nl).trim();
|
|
70
|
+
buf = buf.slice(nl + 1);
|
|
71
|
+
if (!line) continue;
|
|
72
|
+
try {
|
|
73
|
+
handle(JSON.parse(line));
|
|
74
|
+
} catch {
|
|
75
|
+
/* ignore malformed line */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Выход в браузер" — the switch that turns copy-paste into one click.
|
|
3
|
+
*
|
|
4
|
+
* OFF (default): drafts land in the panel, you copy and paste them yourself.
|
|
5
|
+
* ON: we launch (or attach to) a Chrome you are logged into, and each draft can be
|
|
6
|
+
* opened straight in the right place — a Telegram chat, a Gmail compose window
|
|
7
|
+
* with subject and body already filled, a LinkedIn profile with the text on your
|
|
8
|
+
* clipboard. The last action, pressing send, stays yours.
|
|
9
|
+
*
|
|
10
|
+
* That last sentence is the product, not a limitation. Tools that pressed send for
|
|
11
|
+
* the user are the ones whose customers lost their accounts in the 2026 platform
|
|
12
|
+
* bans — and a browser we drive is YOUR session: it carries your cookies, your
|
|
13
|
+
* name and your reputation.
|
|
14
|
+
*
|
|
15
|
+
* Adapted from products/work-finder-backend/scripts/launch-browser.mjs, which
|
|
16
|
+
* already solved the awkward parts: probe before launching (never fight a Chrome
|
|
17
|
+
* that is already serving CDP), keep a persistent profile so logins survive, and
|
|
18
|
+
* fail with a sentence rather than a stack trace when Chrome isn't where we
|
|
19
|
+
* guessed.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { resolve, dirname } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const PORT = Number(process.env.CF_CDP_PORT) || 9223; // not 9222: leave the WF/QA browser alone
|
|
28
|
+
const PROFILE_DIR = resolve(ROOT, '.cdp-browser-profile');
|
|
29
|
+
|
|
30
|
+
const CHROME_CANDIDATES = {
|
|
31
|
+
darwin: [
|
|
32
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
33
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
34
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
35
|
+
],
|
|
36
|
+
linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium'],
|
|
37
|
+
win32: [
|
|
38
|
+
process.env.LOCALAPPDATA ? `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe` : null,
|
|
39
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
40
|
+
].filter(Boolean),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
async function probe() {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`, { signal: AbortSignal.timeout(1200) });
|
|
46
|
+
if (!res.ok) return null;
|
|
47
|
+
return await res.json();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function status() {
|
|
54
|
+
const v = await probe();
|
|
55
|
+
return v ? { connected: true, port: PORT, browser: v.Browser || 'Chrome' } : { connected: false, port: PORT };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Launch or attach. Returns the same shape as `status`, plus a reason on failure. */
|
|
59
|
+
export async function connect() {
|
|
60
|
+
const already = await probe();
|
|
61
|
+
if (already) return { connected: true, port: PORT, browser: already.Browser, reused: true };
|
|
62
|
+
|
|
63
|
+
const chromePath =
|
|
64
|
+
process.env.CHROME_PATH || (CHROME_CANDIDATES[process.platform] || []).find((p) => existsSync(p));
|
|
65
|
+
if (!chromePath) {
|
|
66
|
+
return { connected: false, port: PORT, error: 'Chrome не найден — укажите путь в переменной CHROME_PATH.' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const child = spawn(
|
|
70
|
+
chromePath,
|
|
71
|
+
[
|
|
72
|
+
`--remote-debugging-port=${PORT}`,
|
|
73
|
+
// Its own profile dir: the first time you will log into Telegram/LinkedIn
|
|
74
|
+
// here once, and those logins persist across runs. Deliberately NOT your
|
|
75
|
+
// everyday Chrome profile — Chrome refuses a second instance on a profile
|
|
76
|
+
// that is already open, and we are not touching your main session.
|
|
77
|
+
`--user-data-dir=${PROFILE_DIR}`,
|
|
78
|
+
'--no-first-run',
|
|
79
|
+
'--no-default-browser-check',
|
|
80
|
+
],
|
|
81
|
+
{ detached: true, stdio: 'ignore' },
|
|
82
|
+
);
|
|
83
|
+
child.unref();
|
|
84
|
+
|
|
85
|
+
const t0 = Date.now();
|
|
86
|
+
while (Date.now() - t0 < 15000) {
|
|
87
|
+
const v = await probe();
|
|
88
|
+
if (v) return { connected: true, port: PORT, browser: v.Browser, launched: true };
|
|
89
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
90
|
+
}
|
|
91
|
+
return { connected: false, port: PORT, error: 'Chrome не отозвался на порту за 15 секунд.' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Open a URL in the connected browser, in a new tab, and bring it to the front. */
|
|
95
|
+
export async function openTab(url) {
|
|
96
|
+
const v = await probe();
|
|
97
|
+
if (!v) return { ok: false, error: 'Браузер не подключён' };
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(url)}`, {
|
|
100
|
+
method: 'PUT', // Chrome ≥ 111 requires PUT here; GET returns 405
|
|
101
|
+
signal: AbortSignal.timeout(5000),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) return { ok: false, error: `CDP ответил ${res.status}` };
|
|
104
|
+
return { ok: true };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { ok: false, error: e.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What we sell, described the way a SELLER needs it — a pitch, a price, a buyer.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not parsed out of apps/products/src/app/config.ts: that file holds
|
|
5
|
+
* the storefront's ids, icons and i18n dict keys, and the sales pitch itself lives
|
|
6
|
+
* in the translation files as UI copy. An agent needs one paragraph that says what
|
|
7
|
+
* this is and who wants it, which is a different artefact. Kept short and in one
|
|
8
|
+
* place so it stays true; when a price or a positioning changes, it changes here.
|
|
9
|
+
*/
|
|
10
|
+
export const PRODUCTS = [
|
|
11
|
+
{
|
|
12
|
+
id: 'assist',
|
|
13
|
+
name: 'Qiksy Assist',
|
|
14
|
+
url: 'https://qiksy.app/assist/',
|
|
15
|
+
price: 'Free · Start $8/мес ($96/год) · Pro $15/мес ($180/год) · Founding $49/год',
|
|
16
|
+
pitch:
|
|
17
|
+
'Чат для сайта, который живёт в Telegram владельца: посетитель пишет в виджет — владелец отвечает свайпом из своей Телеги. Клиенту Telegram не нужен. Одна строка кода на любой сайт (Тильда, WordPress, самопис).',
|
|
18
|
+
buyer:
|
|
19
|
+
'Небольшой бизнес с сайтом, который продаёт через переписку: магазин, услуги, студия. Владелец сам отвечает клиентам и живёт в мессенджерах.',
|
|
20
|
+
hook: 'На сайте нет чата — посетителю некуда написать, кроме формы или телефона.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'qa-copilot',
|
|
24
|
+
name: 'Qiksy QA Copilot',
|
|
25
|
+
url: 'https://qiksy.app/qa-copilot/',
|
|
26
|
+
price: '$20 разово = Pro на год',
|
|
27
|
+
pitch:
|
|
28
|
+
'Расширение Chrome для ручного QA: умное заполнение форм, живой аудит страницы, готовые к вставке находки. Экономит часы на регрессе и оформлении багов.',
|
|
29
|
+
buyer: 'QA-инженеры, тест-лиды и небольшие продуктовые команды без автотестов.',
|
|
30
|
+
hook: 'Ручной регресс и оформление багов руками — там, где это всё ещё делают вручную.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'work-finder',
|
|
34
|
+
name: 'Qiksy Work Finder',
|
|
35
|
+
url: 'https://qiksy.app/work-finder/',
|
|
36
|
+
price: 'Free (1 профиль) · Pro $9/мес, без ограничения по людям',
|
|
37
|
+
pitch:
|
|
38
|
+
'Агенты Claude сканируют рынок вакансий, подбирают их под профиль и готовят персональные отклики. Работает локально, на своём ключе Anthropic.',
|
|
39
|
+
buyer: 'Специалисты в IT, которые ищут работу: разработка, QA, PM, дизайн, DevOps.',
|
|
40
|
+
hook: 'Ищет работу и тратит вечера на отклики.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'travel',
|
|
44
|
+
name: 'Qiksy Travel Assistant',
|
|
45
|
+
url: 'https://qiksy.app/travel/',
|
|
46
|
+
price: 'Free (до 3 путешественников) · Pro $9/мес, без ограничения по людям',
|
|
47
|
+
pitch:
|
|
48
|
+
'Авиабилеты, отели и трансферы для командировок: агент собирает варианты и готовит маршрут. Локально, на своём ключе.',
|
|
49
|
+
buyer: 'Компании и агентства, которые часто отправляют людей в командировки.',
|
|
50
|
+
hook: 'Командировки собираются руками в двадцати вкладках.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'multilogin',
|
|
54
|
+
name: 'MultiLogin Tabs',
|
|
55
|
+
url: 'https://qiksy.app/multilogin/',
|
|
56
|
+
price: 'Бесплатно',
|
|
57
|
+
pitch:
|
|
58
|
+
'Расширение Chrome: изоляция сессий по вкладкам — несколько аккаунтов одного сервиса открыты одновременно, без окон инкогнито и без выхода из аккаунта.',
|
|
59
|
+
buyer: 'Все, кто ведёт несколько аккаунтов: SMM, арбитраж, агентства, поддержка.',
|
|
60
|
+
hook: 'Постоянно перелогинивается между аккаунтами.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'pay',
|
|
64
|
+
name: 'Qiksy Pay',
|
|
65
|
+
url: 'https://qiksy.app/pay/',
|
|
66
|
+
price: 'Ранний доступ',
|
|
67
|
+
pitch: 'Подписки и платежи под ключ поверх собственного эквайринга — биллинг, который не надо писать.',
|
|
68
|
+
buyer: 'Сервисы с регулярными платежами, которым нужен биллинг, а не банк.',
|
|
69
|
+
hook: 'Собирает подписки руками или пишет биллинг сам.',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'custom',
|
|
73
|
+
name: 'Qiksy Build / Ops',
|
|
74
|
+
url: 'https://qiksy.app/business/',
|
|
75
|
+
price: 'от проекта',
|
|
76
|
+
pitch:
|
|
77
|
+
'Студия делает продукты и автоматизирует то, как бизнес уже работает: от лендинга и интеграций до собственного инструмента.',
|
|
78
|
+
buyer: 'Бизнес, которому нужно построить или автоматизировать, а своей команды разработки нет.',
|
|
79
|
+
hook: 'Процесс держится на людях и таблицах.',
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** Knowledge file per product — read on demand, see lib/knowledge.mjs. */
|
|
84
|
+
export const knowledgeFile = (id) => `${id}.md`;
|
|
85
|
+
|
|
86
|
+
export const byId = (id) => PRODUCTS.find((p) => p.id === id) || null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where to look for buyers — and what each place costs you in risk.
|
|
3
|
+
*
|
|
4
|
+
* `browser: true` means the channel is only reachable from a logged-in session, so
|
|
5
|
+
* the run drives YOUR Chrome over CDP instead of the public web. That is also
|
|
6
|
+
* where every platform's ToS starts to matter, which is why each channel carries
|
|
7
|
+
* its own `caution` shown in the panel: the person choosing it should see the
|
|
8
|
+
* trade-off at the moment of choosing, not in a footnote.
|
|
9
|
+
*
|
|
10
|
+
* The rule that does not bend, on any channel: the agent finds, proves and drafts.
|
|
11
|
+
* A message is sent by a human, from their own session, at human pace. Tools that
|
|
12
|
+
* automated the sending are the ones whose users lost their accounts in the 2026
|
|
13
|
+
* bans (see docs/CLIENT-FINDER-BUSINESS.md).
|
|
14
|
+
*/
|
|
15
|
+
export const CHANNELS = [
|
|
16
|
+
{
|
|
17
|
+
id: 'web',
|
|
18
|
+
label: 'Открытый веб',
|
|
19
|
+
hint: 'Поиск, сайты компаний, каталоги и подборки',
|
|
20
|
+
browser: false,
|
|
21
|
+
ready: true,
|
|
22
|
+
caution: null,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'linkedin',
|
|
26
|
+
label: 'LinkedIn',
|
|
27
|
+
hint: 'Компании и люди в вашей залогиненной сессии',
|
|
28
|
+
browser: true,
|
|
29
|
+
ready: true,
|
|
30
|
+
caution:
|
|
31
|
+
'LinkedIn запрещает автоматизацию в ToS. Агент только читает и готовит — отправляете вы сами, из своей сессии. Дневной потолок касаний держите в пределах 80–100.',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'instagram',
|
|
35
|
+
label: 'Instagram',
|
|
36
|
+
hint: 'Профили бизнесов и их контакты',
|
|
37
|
+
browser: true,
|
|
38
|
+
ready: false,
|
|
39
|
+
caution: 'Канал ещё не обкатан — включится после LinkedIn.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'tiktok',
|
|
43
|
+
label: 'TikTok',
|
|
44
|
+
hint: 'Бизнес-аккаунты и их ссылки',
|
|
45
|
+
browser: true,
|
|
46
|
+
ready: false,
|
|
47
|
+
caution: 'Канал ещё не обкатан — включится после LinkedIn.',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const channelById = (id) => CHANNELS.find((c) => c.id === id) || null;
|
|
52
|
+
|
|
53
|
+
/** True when any picked channel needs the logged-in browser. */
|
|
54
|
+
export const needsBrowser = (ids = []) => ids.some((id) => channelById(id)?.browser);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a draft into an open window.
|
|
3
|
+
*
|
|
4
|
+
* Some platforms let a URL carry the text (Gmail's compose, wa.me, mailto) — there
|
|
5
|
+
* the message arrives already typed and the human only reads it and presses send.
|
|
6
|
+
* Others (Telegram Web, LinkedIn, Instagram) accept no text in the URL, so the best
|
|
7
|
+
* honest move is: open the exact right place and put the text on the clipboard, so
|
|
8
|
+
* it is one paste rather than a hunt.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here sends. Every path ends with a composed message and a human deciding.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Best-effort contact extraction — the agent stored whatever the site published. */
|
|
14
|
+
function contactValue(draft) {
|
|
15
|
+
return String(draft?.contact?.value || '').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
|
|
19
|
+
const asTgHandle = (v) => {
|
|
20
|
+
const m = String(v).match(/(?:t\.me\/|@)([A-Za-z0-9_]{4,32})/);
|
|
21
|
+
return m ? m[1] : null;
|
|
22
|
+
};
|
|
23
|
+
const asPhone = (v) => {
|
|
24
|
+
const digits = String(v).replace(/[^\d+]/g, '');
|
|
25
|
+
return /^\+?\d{9,15}$/.test(digits) ? digits.replace(/^\+/, '') : null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @returns {{ url: string, prefilled: boolean, note: string }}
|
|
30
|
+
* `prefilled` says whether the text travels in the URL; when false the panel
|
|
31
|
+
* copies it to the clipboard instead and says so.
|
|
32
|
+
*/
|
|
33
|
+
export function deliveryFor(formatId, draft) {
|
|
34
|
+
const to = contactValue(draft);
|
|
35
|
+
const subject = draft.subject || '';
|
|
36
|
+
const body = draft.body || '';
|
|
37
|
+
const site = draft.url || '';
|
|
38
|
+
|
|
39
|
+
if (formatId === 'email') {
|
|
40
|
+
if (isEmail(to)) {
|
|
41
|
+
// Gmail's compose URL carries both fields; it opens in the account already
|
|
42
|
+
// logged into this browser.
|
|
43
|
+
const url =
|
|
44
|
+
'https://mail.google.com/mail/?view=cm&fs=1' +
|
|
45
|
+
`&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
46
|
+
return { url, prefilled: true, note: 'Gmail откроется с готовым письмом — проверьте и отправьте.' };
|
|
47
|
+
}
|
|
48
|
+
return { url: site, prefilled: false, note: 'Почты нет — открываю сайт, текст в буфере.' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (formatId === 'telegram') {
|
|
52
|
+
const handle = asTgHandle(to);
|
|
53
|
+
if (handle) {
|
|
54
|
+
return {
|
|
55
|
+
url: `https://web.telegram.org/k/#@${handle}`,
|
|
56
|
+
prefilled: false,
|
|
57
|
+
note: 'Telegram Web не принимает текст в ссылке — он в буфере, вставьте и отправьте.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const phone = asPhone(to);
|
|
61
|
+
if (phone) {
|
|
62
|
+
return {
|
|
63
|
+
url: `https://web.telegram.org/k/#+${phone}`,
|
|
64
|
+
prefilled: false,
|
|
65
|
+
note: 'Открываю чат по номеру — текст в буфере.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { url: site, prefilled: false, note: 'Telegram не указан — открываю сайт, текст в буфере.' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (formatId === 'contact-form') {
|
|
72
|
+
return { url: site, prefilled: false, note: 'Открываю их форму — текст в буфере, вставьте в поле сообщения.' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// LinkedIn and Instagram: no text in the URL by design, and we are not going to
|
|
76
|
+
// type into their DOM on the user's behalf — that is the line between a tool and
|
|
77
|
+
// the kind of automation that gets accounts restricted.
|
|
78
|
+
if (formatId.startsWith('linkedin')) {
|
|
79
|
+
const profile = /linkedin\.com/i.test(to) ? to : `https://www.linkedin.com/search/results/all/?keywords=${encodeURIComponent(draft.company)}`;
|
|
80
|
+
return { url: profile, prefilled: false, note: 'Текст в буфере — вставьте в заявку или сообщение и отправьте сами.' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (formatId === 'instagram-dm') {
|
|
84
|
+
const profile = /instagram\.com/i.test(to) ? to : `https://www.instagram.com/explore/search/keyword/?q=${encodeURIComponent(draft.company)}`;
|
|
85
|
+
return { url: profile, prefilled: false, note: 'Текст в буфере — вставьте в директ и отправьте сами.' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { url: site, prefilled: false, note: 'Текст в буфере.' };
|
|
89
|
+
}
|