pelulu-cli 1.0.5 → 1.2.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.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * PlanManager — Task decomposition and execution tracking (OpenHands-style)
3
+ *
4
+ * Breaks complex tasks into steps, tracks progress, handles failures.
5
+ * The plan is a living document that evolves as the agent works.
6
+ */
7
+ import { bus } from '../core/event-bus.js';
8
+ import { debug } from '../core/logger.js';
9
+
10
+ export const StepStatus = {
11
+ PENDING: 'pending',
12
+ IN_PROGRESS: 'in_progress',
13
+ COMPLETED: 'completed',
14
+ FAILED: 'failed',
15
+ SKIPPED: 'skipped',
16
+ };
17
+
18
+ export class PlanStep {
19
+ constructor({ id, description, status = StepStatus.PENDING, result = null, error = null }) {
20
+ this.id = id;
21
+ this.description = description;
22
+ this.status = status;
23
+ this.result = result;
24
+ this.error = error;
25
+ this.startedAt = null;
26
+ this.completedAt = null;
27
+ }
28
+
29
+ start() {
30
+ this.status = StepStatus.IN_PROGRESS;
31
+ this.startedAt = Date.now();
32
+ }
33
+
34
+ complete(result) {
35
+ this.status = StepStatus.COMPLETED;
36
+ this.result = result;
37
+ this.completedAt = Date.now();
38
+ }
39
+
40
+ fail(error) {
41
+ this.status = StepStatus.FAILED;
42
+ this.error = error;
43
+ this.completedAt = Date.now();
44
+ }
45
+
46
+ skip(reason) {
47
+ this.status = StepStatus.SKIPPED;
48
+ this.result = reason;
49
+ this.completedAt = Date.now();
50
+ }
51
+
52
+ toJSON() {
53
+ return {
54
+ id: this.id,
55
+ description: this.description,
56
+ status: this.status,
57
+ result: this.result,
58
+ error: this.error,
59
+ duration: this.completedAt && this.startedAt ? this.completedAt - this.startedAt : null,
60
+ };
61
+ }
62
+ }
63
+
64
+ export class Plan {
65
+ constructor({ goal, steps = [] }) {
66
+ this.goal = goal;
67
+ this.steps = steps.map((s, i) => new PlanStep({ id: i + 1, ...s }));
68
+ this.createdAt = Date.now();
69
+ this.updatedAt = Date.now();
70
+ }
71
+
72
+ get currentStep() {
73
+ return this.steps.find(s => s.status === StepStatus.IN_PROGRESS);
74
+ }
75
+
76
+ get nextPending() {
77
+ return this.steps.find(s => s.status === StepStatus.PENDING);
78
+ }
79
+
80
+ get progress() {
81
+ const total = this.steps.length;
82
+ const completed = this.steps.filter(s => s.status === StepStatus.COMPLETED).length;
83
+ const failed = this.steps.filter(s => s.status === StepStatus.FAILED).length;
84
+ return { total, completed, failed, percent: total ? Math.round((completed / total) * 100) : 0 };
85
+ }
86
+
87
+ get isComplete() {
88
+ return this.steps.every(s =>
89
+ s.status === StepStatus.COMPLETED ||
90
+ s.status === StepStatus.FAILED ||
91
+ s.status === StepStatus.SKIPPED
92
+ );
93
+ }
94
+
95
+ get hasFailures() {
96
+ return this.steps.some(s => s.status === StepStatus.FAILED);
97
+ }
98
+
99
+ startStep(id) {
100
+ const step = this.steps.find(s => s.id === id);
101
+ if (step) {
102
+ step.start();
103
+ this.updatedAt = Date.now();
104
+ bus.emit('plan:step:start', { step: step.toJSON() });
105
+ }
106
+ return step;
107
+ }
108
+
109
+ completeStep(id, result) {
110
+ const step = this.steps.find(s => s.id === id);
111
+ if (step) {
112
+ step.complete(result);
113
+ this.updatedAt = Date.now();
114
+ bus.emit('plan:step:complete', { step: step.toJSON(), progress: this.progress });
115
+ }
116
+ return step;
117
+ }
118
+
119
+ failStep(id, error) {
120
+ const step = this.steps.find(s => s.id === id);
121
+ if (step) {
122
+ step.fail(error);
123
+ this.updatedAt = Date.now();
124
+ bus.emit('plan:step:fail', { step: step.toJSON(), progress: this.progress });
125
+ }
126
+ return step;
127
+ }
128
+
129
+ skipStep(id, reason) {
130
+ const step = this.steps.find(s => s.id === id);
131
+ if (step) {
132
+ step.skip(reason);
133
+ this.updatedAt = Date.now();
134
+ }
135
+ return step;
136
+ }
137
+
138
+ /**
139
+ * Add a new step (plan can be modified during execution)
140
+ */
141
+ addStep(description, afterId = null) {
142
+ const id = this.steps.length + 1;
143
+ const step = new PlanStep({ id, description });
144
+ if (afterId) {
145
+ const idx = this.steps.findIndex(s => s.id === afterId);
146
+ this.steps.splice(idx + 1, 0, step);
147
+ } else {
148
+ this.steps.push(step);
149
+ }
150
+ // Re-number steps
151
+ this.steps.forEach((s, i) => s.id = i + 1);
152
+ this.updatedAt = Date.now();
153
+ bus.emit('plan:updated', { plan: this.toJSON() });
154
+ return step;
155
+ }
156
+
157
+ /**
158
+ * Remove a pending step
159
+ */
160
+ removeStep(id) {
161
+ const idx = this.steps.findIndex(s => s.id === id && s.status === StepStatus.PENDING);
162
+ if (idx >= 0) {
163
+ this.steps.splice(idx, 1);
164
+ this.steps.forEach((s, i) => s.id = i + 1);
165
+ this.updatedAt = Date.now();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Format plan as prompt for LLM
171
+ */
172
+ toPrompt() {
173
+ const lines = [`Goal: ${this.goal}`, ''];
174
+ for (const step of this.steps) {
175
+ const icon = {
176
+ [StepStatus.PENDING]: '⬜',
177
+ [StepStatus.IN_PROGRESS]: '🔄',
178
+ [StepStatus.COMPLETED]: '✅',
179
+ [StepStatus.FAILED]: '❌',
180
+ [StepStatus.SKIPPED]: '⏭️',
181
+ }[step.status];
182
+
183
+ lines.push(`${icon} Step ${step.id}: ${step.description}`);
184
+ if (step.result && step.status === StepStatus.COMPLETED) {
185
+ lines.push(` Result: ${step.result}`);
186
+ }
187
+ if (step.error && step.status === StepStatus.FAILED) {
188
+ lines.push(` Error: ${step.error}`);
189
+ }
190
+ }
191
+ lines.push('');
192
+ lines.push(`Progress: ${this.progress.percent}% (${this.progress.completed}/${this.progress.total})`);
193
+ return lines.join('\n');
194
+ }
195
+
196
+ /**
197
+ * Format plan for display
198
+ */
199
+ toDisplay() {
200
+ const lines = [`📋 Plan: ${this.goal}`, '─'.repeat(40)];
201
+ for (const step of this.steps) {
202
+ const icon = {
203
+ [StepStatus.PENDING]: '⬜',
204
+ [StepStatus.IN_PROGRESS]: '🔄',
205
+ [StepStatus.COMPLETED]: '✅',
206
+ [StepStatus.FAILED]: '❌',
207
+ [StepStatus.SKIPPED]: '⏭️',
208
+ }[step.status];
209
+ lines.push(`${icon} ${step.id}. ${step.description}`);
210
+ }
211
+ lines.push('─'.repeat(40));
212
+ lines.push(`Progress: ${this.progress.percent}%`);
213
+ return lines.join('\n');
214
+ }
215
+
216
+ toJSON() {
217
+ return {
218
+ goal: this.goal,
219
+ steps: this.steps.map(s => s.toJSON()),
220
+ progress: this.progress,
221
+ createdAt: this.createdAt,
222
+ updatedAt: this.updatedAt,
223
+ };
224
+ }
225
+ }
226
+
227
+ export class PlanManager {
228
+ #currentPlan = null;
229
+ #history = [];
230
+
231
+ get currentPlan() { return this.#currentPlan; }
232
+ get history() { return [...this.#history]; }
233
+
234
+ /**
235
+ * Create a new plan from a goal
236
+ */
237
+ create(goal, steps = []) {
238
+ if (this.#currentPlan && !this.#currentPlan.isComplete) {
239
+ this.#history.push(this.#currentPlan);
240
+ }
241
+ this.#currentPlan = new Plan({ goal, steps });
242
+ bus.emit('plan:created', { plan: this.#currentPlan.toJSON() });
243
+ debug('plan', `Created plan: ${goal} (${steps.length} steps)`);
244
+ return this.#currentPlan;
245
+ }
246
+
247
+ /**
248
+ * Let the LLM generate a plan for a task
249
+ */
250
+ async generatePlan(task, llm, context) {
251
+ const prompt = `You are a task planner. Break this task into clear, actionable steps.
252
+
253
+ Task: ${task}
254
+
255
+ ${context ? `Context:\n${context}` : ''}
256
+
257
+ Respond with a JSON object:
258
+ {
259
+ "goal": "Brief description of the overall goal",
260
+ "steps": [
261
+ { "description": "Step 1 description" },
262
+ { "description": "Step 2 description" }
263
+ ]
264
+ }
265
+
266
+ Rules:
267
+ - Each step should be a single, clear action
268
+ - Steps should be in logical order
269
+ - Maximum 10 steps
270
+ - Be specific and actionable`;
271
+
272
+ try {
273
+ const response = await llm.chat([
274
+ { role: 'system', content: 'You are a task planning assistant. Respond only with valid JSON.' },
275
+ { role: 'user', content: prompt },
276
+ ]);
277
+
278
+ let content = response.content;
279
+ if (Array.isArray(content)) {
280
+ content = content.filter(c => c.type === 'text').map(c => c.text).join('');
281
+ }
282
+
283
+ // Parse JSON from response
284
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
285
+ if (jsonMatch) {
286
+ const planData = JSON.parse(jsonMatch[0]);
287
+ return this.create(planData.goal || task, planData.steps || []);
288
+ }
289
+ } catch (err) {
290
+ debug('plan', `Plan generation failed: ${err.message}`);
291
+ }
292
+
293
+ // Fallback: create simple plan
294
+ return this.create(task, [{ description: task }]);
295
+ }
296
+
297
+ /**
298
+ * Mark current step as complete and move to next
299
+ */
300
+ advanceCurrent(result) {
301
+ if (!this.#currentPlan) return null;
302
+ const current = this.#currentPlan.currentStep;
303
+ if (current) {
304
+ this.#currentPlan.completeStep(current.id, result);
305
+ }
306
+ const next = this.#currentPlan.nextPending;
307
+ if (next) {
308
+ this.#currentPlan.startStep(next.id);
309
+ }
310
+ return next;
311
+ }
312
+
313
+ /**
314
+ * Mark current step as failed
315
+ */
316
+ failCurrent(error) {
317
+ if (!this.#currentPlan) return null;
318
+ const current = this.#currentPlan.currentStep;
319
+ if (current) {
320
+ this.#currentPlan.failStep(current.id, error);
321
+ }
322
+ return current;
323
+ }
324
+
325
+ /**
326
+ * Get plan status for display
327
+ */
328
+ getStatus() {
329
+ if (!this.#currentPlan) return 'No active plan';
330
+ return this.#currentPlan.toDisplay();
331
+ }
332
+
333
+ /**
334
+ * Clear current plan
335
+ */
336
+ clear() {
337
+ if (this.#currentPlan) {
338
+ this.#history.push(this.#currentPlan);
339
+ this.#currentPlan = null;
340
+ }
341
+ }
342
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * SystemPrompt — Minimal system prompt for XiaoZhi
3
+ *
4
+ * XiaoZhi doesn't support system prompts, so this is used
5
+ * only for agent-internal context tracking.
6
+ */
7
+
8
+ /**
9
+ * Build minimal system prompt (for internal use only, NOT sent to XiaoZhi)
10
+ */
11
+ export function buildSystemPrompt({ config, context, plan }) {
12
+ const name = config.agent?.name || 'Pelulu';
13
+ const parts = [`You are ${name}, a coding agent.`];
14
+
15
+ if (context) {
16
+ // Extract only git branch and project type
17
+ const branch = context.match(/Branch:\s*(\S+)/)?.[1];
18
+ const type = context.match(/Type:\s*(\S+)/)?.[1];
19
+ if (branch || type) {
20
+ parts.push([branch && `git:${branch}`, type].filter(Boolean).join(' | '));
21
+ }
22
+ }
23
+
24
+ if (plan) {
25
+ parts.push(`Plan: ${plan.goal} (${plan.progress?.percent || 0}%)`);
26
+ }
27
+
28
+ return parts.join(' ');
29
+ }
30
+
31
+ /**
32
+ * Match microagents based on user input
33
+ */
34
+ export function matchMicroagents(userInput, allMicroagents) {
35
+ if (!allMicroagents?.length) return [];
36
+ const input = userInput.toLowerCase();
37
+ return allMicroagents.filter(agent => {
38
+ if (!agent.trigger?.length) return true;
39
+ return agent.trigger.some(kw => input.includes(kw.toLowerCase()));
40
+ });
41
+ }
@@ -30,7 +30,7 @@ let _config = null;
30
30
  export async function loadConfig(root) {
31
31
  const path = join(root, CONFIG_FILE);
32
32
  const defaults = {
33
- agent: { name: 'Pelulu CLI', version: '1.0.0', workspace: '~/Pelulu-CLI' },
33
+ agent: { name: 'Pelulu CLI', version: '1.1.0', workspace: '~/Pelulu-CLI' },
34
34
  mqtt: { ota_url: 'https://api.tenclass.net/xiaozhi/ota/', keepalive: 240 },
35
35
  mcp: { endpoint_url: '' },
36
36
  tools: { shell_timeout: 30000, max_output: 10000, auto_format: true },
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Shared HTTP engine for all tools.
3
+ *
4
+ * Why this exists: previously every tool re-implemented raw http.request with
5
+ * subtle differences and NONE of them decompressed responses. Real-world
6
+ * servers (Cloudflare, nginx gzip, brotli via CDNs) return compressed bodies,
7
+ * so body-based detection silently failed everywhere. This module centralizes:
8
+ * - transparent gzip / deflate / br decompression
9
+ * - accurate wall-clock timing (duration_ms) for time-based detection
10
+ * - real redirect following with a recorded chain + loop protection
11
+ * - bounded retries with backoff for transient network errors
12
+ * - a global concurrency limiter so batch scans don't self-DoS the target
13
+ * - a hard body-size cap to stay memory-safe on huge responses
14
+ */
15
+ import https from 'https';
16
+ import http from 'http';
17
+ import zlib from 'zlib';
18
+ import { URL } from 'url';
19
+
20
+ export const DEFAULT_UA =
21
+ 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
22
+
23
+ const DEFAULT_TIMEOUT = 15000;
24
+ const DEFAULT_MAX_BODY = 2 * 1024 * 1024; // 2 MB
25
+ const DEFAULT_MAX_REDIRECTS = 5;
26
+ const DEFAULT_RETRIES = 1;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Global concurrency limiter (shared across every tool in the process).
30
+ // Keeps aggressive batch scanning from opening hundreds of sockets at once.
31
+ // ---------------------------------------------------------------------------
32
+ let MAX_CONCURRENCY = 8;
33
+ let active = 0;
34
+ const queue = [];
35
+
36
+ export function setMaxConcurrency(n) {
37
+ MAX_CONCURRENCY = Math.max(1, n | 0);
38
+ }
39
+
40
+ function acquire() {
41
+ if (active < MAX_CONCURRENCY) {
42
+ active++;
43
+ return Promise.resolve();
44
+ }
45
+ return new Promise((resolve) => queue.push(resolve));
46
+ }
47
+
48
+ function release() {
49
+ active--;
50
+ const next = queue.shift();
51
+ if (next) {
52
+ active++;
53
+ next();
54
+ }
55
+ }
56
+
57
+ function decompress(buffer, encoding) {
58
+ if (!buffer || buffer.length === 0) return '';
59
+ try {
60
+ const enc = (encoding || '').toLowerCase();
61
+ if (enc.includes('br')) return zlib.brotliDecompressSync(buffer).toString('utf8');
62
+ if (enc.includes('gzip')) return zlib.gunzipSync(buffer).toString('utf8');
63
+ if (enc.includes('deflate')) {
64
+ try {
65
+ return zlib.inflateSync(buffer).toString('utf8');
66
+ } catch {
67
+ return zlib.inflateRawSync(buffer).toString('utf8');
68
+ }
69
+ }
70
+ } catch {
71
+ // Corrupt / partial compressed stream — fall back to raw bytes.
72
+ }
73
+ return buffer.toString('utf8');
74
+ }
75
+
76
+ function normalizeHeaders(raw) {
77
+ const out = {};
78
+ for (const [k, v] of Object.entries(raw || {})) {
79
+ out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /** Return set-cookie always as an array (Node gives array or string). */
85
+ function rawSetCookies(headers) {
86
+ const sc = headers['set-cookie'];
87
+ if (!sc) return [];
88
+ return Array.isArray(sc) ? sc : [sc];
89
+ }
90
+
91
+ function singleRequest(rawUrl, opts) {
92
+ const {
93
+ method = 'GET',
94
+ headers = {},
95
+ body = null,
96
+ timeout = DEFAULT_TIMEOUT,
97
+ maxBody = DEFAULT_MAX_BODY,
98
+ } = opts;
99
+
100
+ return new Promise((resolve, reject) => {
101
+ let parsed;
102
+ try {
103
+ parsed = new URL(rawUrl);
104
+ } catch {
105
+ return reject(new Error(`Invalid URL: ${rawUrl}`));
106
+ }
107
+ const lib = parsed.protocol === 'https:' ? https : http;
108
+
109
+ const outHeaders = {
110
+ 'User-Agent': DEFAULT_UA,
111
+ 'Accept': '*/*',
112
+ 'Accept-Encoding': 'gzip, deflate, br',
113
+ 'Connection': 'close',
114
+ ...headers,
115
+ };
116
+ if (body && !outHeaders['Content-Length'] && !outHeaders['content-length']) {
117
+ outHeaders['Content-Length'] = Buffer.byteLength(body);
118
+ }
119
+
120
+ const reqOpts = {
121
+ protocol: parsed.protocol,
122
+ hostname: parsed.hostname,
123
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
124
+ path: parsed.pathname + parsed.search,
125
+ method: method.toUpperCase(),
126
+ headers: outHeaders,
127
+ timeout,
128
+ rejectUnauthorized: false,
129
+ };
130
+
131
+ const start = Date.now();
132
+ const req = lib.request(reqOpts, (res) => {
133
+ const chunks = [];
134
+ let size = 0;
135
+ let truncated = false;
136
+ res.on('data', (c) => {
137
+ size += c.length;
138
+ if (size <= maxBody) chunks.push(c);
139
+ else truncated = true;
140
+ });
141
+ res.on('end', () => {
142
+ const headersLower = normalizeHeaders(res.headers);
143
+ const buf = Buffer.concat(chunks);
144
+ const decoded = decompress(buf, headersLower['content-encoding']);
145
+ resolve({
146
+ status: res.statusCode,
147
+ headers: headersLower,
148
+ setCookies: rawSetCookies(headersLower),
149
+ body: decoded,
150
+ bytes: size,
151
+ truncated,
152
+ duration_ms: Date.now() - start,
153
+ url: rawUrl,
154
+ finalUrl: rawUrl,
155
+ location: headersLower['location'] || null,
156
+ });
157
+ });
158
+ res.on('error', reject);
159
+ });
160
+ req.on('error', reject);
161
+ req.on('timeout', () => {
162
+ req.destroy();
163
+ reject(new Error(`Timeout after ${timeout}ms`));
164
+ });
165
+ if (body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(reqOpts.method)) req.write(body);
166
+ req.end();
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Perform an HTTP request with retries, redirect following and decompression.
172
+ *
173
+ * @param {string} url
174
+ * @param {object} [options]
175
+ * @param {string} [options.method='GET']
176
+ * @param {object} [options.headers]
177
+ * @param {string} [options.body]
178
+ * @param {number} [options.timeout=15000]
179
+ * @param {boolean} [options.followRedirects=false]
180
+ * @param {number} [options.maxRedirects=5]
181
+ * @param {number} [options.retries=1]
182
+ * @param {number} [options.maxBody]
183
+ * @returns {Promise<object>} normalized response with duration_ms, redirectChain, etc.
184
+ */
185
+ export async function request(url, options = {}) {
186
+ const {
187
+ followRedirects = false,
188
+ maxRedirects = DEFAULT_MAX_REDIRECTS,
189
+ retries = DEFAULT_RETRIES,
190
+ } = options;
191
+
192
+ await acquire();
193
+ try {
194
+ let attempt = 0;
195
+ let lastErr;
196
+ let res;
197
+ // Retry loop for transient failures (ECONNRESET / timeouts / etc).
198
+ while (attempt <= retries) {
199
+ try {
200
+ res = await singleRequest(url, options);
201
+ break;
202
+ } catch (err) {
203
+ lastErr = err;
204
+ attempt++;
205
+ if (attempt > retries) throw lastErr;
206
+ await new Promise((r) => setTimeout(r, 150 * attempt));
207
+ }
208
+ }
209
+
210
+ const redirectChain = [];
211
+ const visited = new Set([url]);
212
+ let current = res;
213
+ let hops = 0;
214
+ let currentUrl = url;
215
+ while (
216
+ followRedirects &&
217
+ current.status >= 300 &&
218
+ current.status < 400 &&
219
+ current.location &&
220
+ hops < maxRedirects
221
+ ) {
222
+ let nextUrl;
223
+ try {
224
+ nextUrl = new URL(current.location, currentUrl).href;
225
+ } catch {
226
+ break;
227
+ }
228
+ redirectChain.push({ status: current.status, from: currentUrl, to: nextUrl });
229
+ if (visited.has(nextUrl)) break; // loop guard: already fetched this absolute URL
230
+ visited.add(nextUrl);
231
+ // Per RFC, 303 (and commonly 301/302) downgrade to GET without a body.
232
+ const nextMethod = [301, 302, 303].includes(current.status) ? 'GET' : options.method || 'GET';
233
+ const nextBody = nextMethod === 'GET' ? null : options.body;
234
+ current = await singleRequest(nextUrl, { ...options, method: nextMethod, body: nextBody });
235
+ currentUrl = nextUrl;
236
+ hops++;
237
+ }
238
+
239
+ current.redirectChain = redirectChain;
240
+ current.finalUrl = currentUrl;
241
+ current.requestedUrl = url;
242
+ return current;
243
+ } finally {
244
+ release();
245
+ }
246
+ }
247
+
248
+ /** Convenience GET. */
249
+ export function get(url, options = {}) {
250
+ return request(url, { ...options, method: 'GET' });
251
+ }
252
+
253
+ /**
254
+ * Run an async mapper over items with bounded parallelism.
255
+ * (The global limiter also applies, but this keeps result ordering + batching.)
256
+ */
257
+ export async function mapLimit(items, limit, mapper) {
258
+ const results = new Array(items.length);
259
+ let idx = 0;
260
+ const workers = new Array(Math.min(limit, items.length || 1)).fill(0).map(async () => {
261
+ while (idx < items.length) {
262
+ const i = idx++;
263
+ try {
264
+ results[i] = await mapper(items[i], i);
265
+ } catch (err) {
266
+ results[i] = { __error: err?.message || String(err) };
267
+ }
268
+ }
269
+ });
270
+ await Promise.all(workers);
271
+ return results;
272
+ }
273
+
274
+ /** Parse a "key:value|key:value" header string into an object. */
275
+ export function parseHeaderString(headerStr) {
276
+ const headers = {};
277
+ if (!headerStr) return headers;
278
+ for (const pair of headerStr.split('|')) {
279
+ const idx = pair.indexOf(':');
280
+ if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
281
+ }
282
+ return headers;
283
+ }
284
+
285
+ export default { request, get, mapLimit, setMaxConcurrency, parseHeaderString, DEFAULT_UA };