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.
- package/CHANGELOG.md +81 -0
- package/package.json +10 -6
- package/src/agent/agent-controller.js +105 -0
- package/src/agent/agent-loop.js +248 -0
- package/src/agent/context-builder.js +307 -0
- package/src/agent/history-condenser.js +202 -0
- package/src/agent/index.js +8 -0
- package/src/agent/llm-client.js +44 -0
- package/src/agent/plan-manager.js +342 -0
- package/src/agent/system-prompt.js +41 -0
- package/src/core/config.js +1 -1
- package/src/core/http-client.js +285 -0
- package/src/core/job-manager.js +192 -0
- package/src/core/logger.js +106 -1
- package/src/core/system-prompt.js +24 -8
- package/src/core/tool-registry.js +65 -6
- package/src/index.js +151 -29
- package/src/mcp/mcp-handler.js +63 -4
- package/src/mcp/mqtt-client.js +195 -12
- package/src/tools/agent.js +80 -0
- package/src/tools/file.js +8 -2
- package/src/tools/git.js +56 -6
- package/src/tools/jobs.js +93 -0
- package/src/tools/network.js +56 -23
- package/src/tools/project.js +38 -4
- package/src/tools/search.js +10 -14
- package/src/tools/shell.js +70 -4
- package/src/tui/completable-input.js +49 -42
- package/src/tui/ink-app.js +358 -19
- package/src/tui/ink-components.js +82 -12
- package/src/tui/ink-entry.js +26 -2
- package/src/tui/renderer.js +80 -39
|
@@ -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
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -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.
|
|
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 };
|