@mjasnikovs/pi-task 0.38.15 → 0.38.17
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/dist/config/config.d.ts +26 -0
- package/dist/config/config.js +68 -17
- package/dist/shared/child-process.js +9 -16
- package/dist/task/accept-debt.d.ts +7 -5
- package/dist/task/accept-debt.js +18 -14
- package/dist/task/artifact-closure.js +18 -63
- package/dist/task/auto-orchestrator.js +211 -218
- package/dist/task/autofix-ledger.d.ts +113 -0
- package/dist/task/autofix-ledger.js +152 -0
- package/dist/task/boot-probe.d.ts +109 -1
- package/dist/task/boot-probe.js +139 -23
- package/dist/task/child-runner.d.ts +50 -6
- package/dist/task/child-runner.js +48 -69
- package/dist/task/command-run.d.ts +49 -6
- package/dist/task/command-run.js +154 -18
- package/dist/task/coverage-loop.d.ts +11 -0
- package/dist/task/coverage-loop.js +16 -0
- package/dist/task/external-context.d.ts +9 -12
- package/dist/task/external-context.js +5 -5
- package/dist/task/failure-classifier.d.ts +9 -1
- package/dist/task/failure-classifier.js +9 -0
- package/dist/task/final-gate-fix.d.ts +22 -26
- package/dist/task/final-gate-fix.js +16 -31
- package/dist/task/final-gate.d.ts +10 -2
- package/dist/task/final-gate.js +55 -89
- package/dist/task/fix-child.d.ts +64 -0
- package/dist/task/fix-child.js +66 -0
- package/dist/task/gate-deps.js +20 -13
- package/dist/task/lint-fix.d.ts +7 -0
- package/dist/task/lint-fix.js +45 -9
- package/dist/task/orchestrator.d.ts +33 -24
- package/dist/task/orchestrator.js +75 -46
- package/dist/task/phases.d.ts +120 -34
- package/dist/task/phases.js +221 -134
- package/dist/task/plan-orchestrator.js +2 -2
- package/dist/task/plan-rounds.d.ts +86 -0
- package/dist/task/plan-rounds.js +105 -0
- package/dist/task/plan-session.d.ts +31 -21
- package/dist/task/plan-session.js +97 -120
- package/dist/task/qa-transcript.d.ts +100 -0
- package/dist/task/qa-transcript.js +99 -0
- package/dist/task/question-source.d.ts +117 -0
- package/dist/task/question-source.js +174 -0
- package/dist/task/repo-health-check.d.ts +21 -21
- package/dist/task/repo-health-check.js +43 -112
- package/dist/task/run-end.d.ts +77 -0
- package/dist/task/run-end.js +37 -0
- package/dist/task/run-final-gate.js +71 -79
- package/dist/task/serve-entry.js +6 -57
- package/dist/task/shipped-source.d.ts +67 -0
- package/dist/task/shipped-source.js +144 -0
- package/dist/task/task-gates.d.ts +9 -1
- package/dist/task/task-gates.js +27 -6
- package/dist/task/terminal-outcome.d.ts +1 -1
- package/dist/task/terminal-outcome.js +12 -0
- package/dist/task/verify-work.d.ts +46 -0
- package/dist/task/verify-work.js +51 -3
- package/dist/workers/brave-search.d.ts +7 -0
- package/dist/workers/brave-search.js +36 -55
- package/dist/workers/ddg-search.d.ts +1 -1
- package/dist/workers/ddg-search.js +27 -47
- package/dist/workers/docs-core.d.ts +71 -1
- package/dist/workers/docs-core.js +131 -71
- package/dist/workers/exa-search.d.ts +2 -2
- package/dist/workers/exa-search.js +53 -68
- package/dist/workers/html-clean.js +67 -88
- package/dist/workers/http-request.d.ts +74 -0
- package/dist/workers/http-request.js +103 -0
- package/dist/workers/npm-version.js +37 -42
- package/dist/workers/pi-worker-core.d.ts +13 -2
- package/dist/workers/pi-worker-core.js +35 -25
- package/dist/workers/pi-worker-docs.d.ts +1 -1
- package/dist/workers/pi-worker-docs.js +49 -68
- package/dist/workers/pi-worker-fetch.d.ts +1 -1
- package/dist/workers/pi-worker-fetch.js +20 -21
- package/dist/workers/pi-worker-search.js +6 -4
- package/dist/workers/pi-worker.js +5 -4
- package/dist/workers/search-core.d.ts +1 -1
- package/dist/workers/search-core.js +36 -42
- package/dist/workers/search-types.d.ts +13 -0
- package/dist/workers/search-types.js +27 -0
- package/dist/workers/shared.d.ts +51 -11
- package/dist/workers/shared.js +0 -0
- package/dist/workers/worker-channels.d.ts +60 -0
- package/dist/workers/worker-channels.js +98 -0
- package/package.json +1 -1
package/dist/task/task-gates.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { verifyFailClass } from './verify-work.js';
|
|
1
2
|
import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
|
|
2
3
|
import { SessionUI } from '../remote/bridge.js';
|
|
3
4
|
import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
|
|
@@ -121,7 +122,8 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
|
|
|
121
122
|
// bounded fix attempt before the picker — smallest tool first. Applied →
|
|
122
123
|
// re-verify and re-enter the loop on the fresh verdict; not applied (guard
|
|
123
124
|
// trip, no convergence) → fall through to the ordinary picker unchanged.
|
|
124
|
-
|
|
125
|
+
const failClass = verifyFailClass(verified);
|
|
126
|
+
if (!lintFixAttempted && deps.lintFix && failClass === 'repo-health') {
|
|
125
127
|
lintFixAttempted = true;
|
|
126
128
|
active.ui.notify(`${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
|
|
127
129
|
const fix = await deps.lintFix(active, p.cwd, p.title, p.taskId, failReason);
|
|
@@ -149,7 +151,7 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
|
|
|
149
151
|
// records the (already-recorded) defect as the human's call. Applies
|
|
150
152
|
// only while the FAIL is still the repo-health one the contradiction
|
|
151
153
|
// explains — a later, different FAIL gets the ordinary resolution path.
|
|
152
|
-
const isFrozenBlocked = frozenContradiction !== null &&
|
|
154
|
+
const isFrozenBlocked = frozenContradiction !== null && failClass === 'repo-health';
|
|
153
155
|
const recOutcome = isUnobserved ? { recommend: 'autofix', rationale: failReason }
|
|
154
156
|
: isFrozenBlocked ?
|
|
155
157
|
{
|
|
@@ -319,12 +321,31 @@ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
|
|
|
319
321
|
fixInstruction
|
|
320
322
|
});
|
|
321
323
|
active = fixRes.ctx ?? active;
|
|
322
|
-
|
|
324
|
+
// The re-run's ending, mapped to this loop's own terminal kinds. A
|
|
325
|
+
// CANCEL lands on `interrupted` — the user stopped it, so the task is
|
|
326
|
+
// left resumable rather than reported as a fault.
|
|
327
|
+
if (fixRes.end.kind === 'no-session') {
|
|
323
328
|
return { stop: { kind: 'session-cancelled', ctx: active } };
|
|
324
|
-
|
|
329
|
+
}
|
|
330
|
+
// A CANCEL and an ESC-interrupt are NOT the same ending. Folding them
|
|
331
|
+
// together sent a cancelled re-run down the `interrupted` row, which
|
|
332
|
+
// demotes the task file — writing `failed` over the `cancelled` the
|
|
333
|
+
// cancel itself wrote.
|
|
334
|
+
if (fixRes.end.kind === 'cancelled') {
|
|
335
|
+
return { stop: { kind: 'cancelled', ctx: active } };
|
|
336
|
+
}
|
|
337
|
+
if (fixRes.end.kind === 'interrupted') {
|
|
325
338
|
return { stop: { kind: 'interrupted', ctx: active } };
|
|
326
|
-
|
|
327
|
-
|
|
339
|
+
}
|
|
340
|
+
if (fixRes.end.kind === 'failed') {
|
|
341
|
+
return {
|
|
342
|
+
stop: {
|
|
343
|
+
kind: 'failed',
|
|
344
|
+
ctx: active,
|
|
345
|
+
...(fixRes.end.reason === undefined ? {} : { reason: fixRes.end.reason })
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
328
349
|
// Resume reuses the same inner task id, so p.taskId is stable.
|
|
329
350
|
verified = await deps.verify(active, p.cwd, p.title, p.taskId);
|
|
330
351
|
await rec(verdictLine(verified));
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* overrides, which is not a simplification.
|
|
26
26
|
*/
|
|
27
27
|
/** The gate outcomes a command has to act on. Mirrors runGatesForTask's union. */
|
|
28
|
-
export type TerminalOutcomeKind = 'done' | 'paused' | 'session-cancelled' | 'interrupted' | 'failed';
|
|
28
|
+
export type TerminalOutcomeKind = 'done' | 'paused' | 'session-cancelled' | 'cancelled' | 'interrupted' | 'failed';
|
|
29
29
|
/** What the message needs to name. */
|
|
30
30
|
export interface TerminalMessageContext {
|
|
31
31
|
/** The task or run id shown to the user (`TASK_0007`, `AUTO_0002`, `Task`). */
|
|
@@ -59,6 +59,18 @@ export const TERMINAL_OUTCOMES = {
|
|
|
59
59
|
message: c => `${c.tag} paused — could not start a session for autofix. `
|
|
60
60
|
+ `Run ${c.resumeCmd} to retry.`
|
|
61
61
|
},
|
|
62
|
+
cancelled: {
|
|
63
|
+
// The USER stopped the re-run. The task file already says `cancelled`, and
|
|
64
|
+
// `markResumable` writes `failed` — that both lies in the ledger and turns
|
|
65
|
+
// a deliberate stop into a red error. RUN_END_POLICY states this for the
|
|
66
|
+
// first implementation run; this row states the same thing for a re-run.
|
|
67
|
+
markResumable: false,
|
|
68
|
+
failParent: false,
|
|
69
|
+
level: 'warning',
|
|
70
|
+
// `cancelled` is already in RESUMABLE_STATES, so the file needs no demotion
|
|
71
|
+
// AND the resume works — naming it costs nothing and is true.
|
|
72
|
+
message: c => `${c.tag} cancelled${c.at} — resume with ${c.resumeCmd}.`
|
|
73
|
+
},
|
|
62
74
|
interrupted: {
|
|
63
75
|
markResumable: true,
|
|
64
76
|
// NOT a failure: the user stopped it. The parent run stays in_progress so
|
|
@@ -34,7 +34,53 @@ export interface VerifyOutcome {
|
|
|
34
34
|
* can record each as a durable debt if the user ACCEPTs anyway — the deletion
|
|
35
35
|
* then ships in the next commit and the final gate must re-check it. */
|
|
36
36
|
crossTaskDeletions?: CrossTaskDeletion[];
|
|
37
|
+
/**
|
|
38
|
+
* WHICH KIND of FAIL this is, as data. Only meaningful when ok === false.
|
|
39
|
+
*
|
|
40
|
+
* `unobserved` was already carried as a typed field and read as one. Its
|
|
41
|
+
* siblings were not: the repo-health class travelled only as the `repo health:`
|
|
42
|
+
* PREFIX of `reason`, and three independent production sites recovered it by
|
|
43
|
+
* re-typing that literal with two different matchers — the graduated lint-fix
|
|
44
|
+
* gate, the frozen-blocked contradiction test, and the ONE auto-closing debt
|
|
45
|
+
* class. A reword of the mint disabled all three, with no compile error and a
|
|
46
|
+
* green suite. This is the `observedFailures` finding one altitude down: the
|
|
47
|
+
* outcome CLASS never travelled with the failure TEXT, so every classifier
|
|
48
|
+
* downstream had to guess.
|
|
49
|
+
*/
|
|
50
|
+
failClass?: VerifyFailClass;
|
|
37
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The kinds of verify FAIL. A new member is a compile error until it declares a
|
|
54
|
+
* display prefix below.
|
|
55
|
+
*
|
|
56
|
+
* `static-checks` is the RUN-level twin of `repo-health`: `final-gate.ts` mints
|
|
57
|
+
* `static checks: …` for the same concept at the other altitude, which is why
|
|
58
|
+
* `isStaticClassDebt` was structurally blind to every run-level static failure
|
|
59
|
+
* that reached the ledger.
|
|
60
|
+
*/
|
|
61
|
+
export type VerifyFailClass = 'repo-health' | 'static-checks' | 'unobserved' | 'model-verdict' | 'harness-fault';
|
|
62
|
+
/**
|
|
63
|
+
* The prefix each class MINTS, stated once.
|
|
64
|
+
*
|
|
65
|
+
* These strings are byte-frozen: the debt ledger stores `reason` verbatim, so a
|
|
66
|
+
* reword would orphan every debt already on disk. The registry exists so minting
|
|
67
|
+
* and matching cannot drift apart, not to make the wording editable.
|
|
68
|
+
*/
|
|
69
|
+
export declare const VERIFY_FAIL_PREFIX: Record<VerifyFailClass, string>;
|
|
70
|
+
/**
|
|
71
|
+
* The class of a FAIL — from the typed field when it is there, else from the
|
|
72
|
+
* prefix the registry above owns.
|
|
73
|
+
*
|
|
74
|
+
* The prefix test survives in exactly ONE place instead of three. It has to
|
|
75
|
+
* survive somewhere: `GateDeps.verify` is a seam, a debt read back off disk is a
|
|
76
|
+
* bare string with no outcome attached, and the run-level gate mints its own
|
|
77
|
+
* `static checks:` line through a different path entirely.
|
|
78
|
+
*/
|
|
79
|
+
export declare function verifyFailClass(o: Pick<VerifyOutcome, 'failClass' | 'reason'>): VerifyFailClass | undefined;
|
|
80
|
+
/** The class a recorded reason STRING belongs to, by its minted prefix. */
|
|
81
|
+
export declare function failClassOfReason(reason: string): VerifyFailClass | undefined;
|
|
82
|
+
/** Does this class name a deterministic whole-repo static check, at either altitude? */
|
|
83
|
+
export declare function isStaticClass(cls: VerifyFailClass | undefined): boolean;
|
|
38
84
|
/**
|
|
39
85
|
* Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
|
|
40
86
|
* task file body. The composed spec lives under a `## spec` header and runs until
|
package/dist/task/verify-work.js
CHANGED
|
@@ -87,6 +87,47 @@ import { crossTaskDeletionVerifyFindings } from './task-provenance.js';
|
|
|
87
87
|
* enforce pass had to drop `write` to stop.
|
|
88
88
|
*/
|
|
89
89
|
const VERIFY_TOOLS = 'read,bash';
|
|
90
|
+
/**
|
|
91
|
+
* The prefix each class MINTS, stated once.
|
|
92
|
+
*
|
|
93
|
+
* These strings are byte-frozen: the debt ledger stores `reason` verbatim, so a
|
|
94
|
+
* reword would orphan every debt already on disk. The registry exists so minting
|
|
95
|
+
* and matching cannot drift apart, not to make the wording editable.
|
|
96
|
+
*/
|
|
97
|
+
export const VERIFY_FAIL_PREFIX = {
|
|
98
|
+
'repo-health': 'repo health:',
|
|
99
|
+
'static-checks': 'static checks:',
|
|
100
|
+
unobserved: 'work unobserved:',
|
|
101
|
+
'model-verdict': 'work did not verify:',
|
|
102
|
+
'harness-fault': 'verification pass could not run:'
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The class of a FAIL — from the typed field when it is there, else from the
|
|
106
|
+
* prefix the registry above owns.
|
|
107
|
+
*
|
|
108
|
+
* The prefix test survives in exactly ONE place instead of three. It has to
|
|
109
|
+
* survive somewhere: `GateDeps.verify` is a seam, a debt read back off disk is a
|
|
110
|
+
* bare string with no outcome attached, and the run-level gate mints its own
|
|
111
|
+
* `static checks:` line through a different path entirely.
|
|
112
|
+
*/
|
|
113
|
+
export function verifyFailClass(o) {
|
|
114
|
+
if (o.failClass)
|
|
115
|
+
return o.failClass;
|
|
116
|
+
return failClassOfReason(o.reason ?? '');
|
|
117
|
+
}
|
|
118
|
+
/** The class a recorded reason STRING belongs to, by its minted prefix. */
|
|
119
|
+
export function failClassOfReason(reason) {
|
|
120
|
+
const head = reason.trimStart().toLowerCase();
|
|
121
|
+
for (const [cls, prefix] of Object.entries(VERIFY_FAIL_PREFIX)) {
|
|
122
|
+
if (head.startsWith(prefix.toLowerCase()))
|
|
123
|
+
return cls;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
/** Does this class name a deterministic whole-repo static check, at either altitude? */
|
|
128
|
+
export function isStaticClass(cls) {
|
|
129
|
+
return cls === 'repo-health' || cls === 'static-checks';
|
|
130
|
+
}
|
|
90
131
|
/**
|
|
91
132
|
* Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
|
|
92
133
|
* task file body. The composed spec lives under a `## spec` header and runs until
|
|
@@ -762,8 +803,9 @@ export async function runWorkVerification(deps) {
|
|
|
762
803
|
if (deps.repoHealth) {
|
|
763
804
|
stage('repo health');
|
|
764
805
|
const h = await deps.repoHealth();
|
|
765
|
-
if (!h.ok)
|
|
766
|
-
return { ok: false, reason: `repo health: ${h.reason}` };
|
|
806
|
+
if (!h.ok) {
|
|
807
|
+
return { ok: false, failClass: 'repo-health', reason: `repo health: ${h.reason}` };
|
|
808
|
+
}
|
|
767
809
|
}
|
|
768
810
|
if (!deps.spec || deps.spec.trim().length === 0) {
|
|
769
811
|
return { ok: true, reason: 'no spec to verify' };
|
|
@@ -818,7 +860,11 @@ export async function runWorkVerification(deps) {
|
|
|
818
860
|
if (err instanceof Error && err.message === USER_CANCELLED)
|
|
819
861
|
throw err;
|
|
820
862
|
const msg = err instanceof Error ? err.message : String(err);
|
|
821
|
-
return {
|
|
863
|
+
return {
|
|
864
|
+
ok: false,
|
|
865
|
+
failClass: 'harness-fault',
|
|
866
|
+
reason: `${VERIFY_FAIL_PREFIX['harness-fault']} ${msg}`
|
|
867
|
+
};
|
|
822
868
|
}
|
|
823
869
|
// Capture the environment facts the child shared — regardless of verdict
|
|
824
870
|
// (a FAIL run's discoveries are just as reusable).
|
|
@@ -860,12 +906,14 @@ export async function runWorkVerification(deps) {
|
|
|
860
906
|
return {
|
|
861
907
|
ok: false,
|
|
862
908
|
unobserved: true,
|
|
909
|
+
failClass: 'unobserved',
|
|
863
910
|
reason: `work unobserved: ${verdict.detail}`,
|
|
864
911
|
...deletions
|
|
865
912
|
};
|
|
866
913
|
}
|
|
867
914
|
return {
|
|
868
915
|
ok: false,
|
|
916
|
+
failClass: 'model-verdict',
|
|
869
917
|
reason: `work did not verify: ${verdict.detail}${verdict.detail === 'no verdict emitted' ? ' (after verify retry)' : ''}`,
|
|
870
918
|
...deletions
|
|
871
919
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FetchLike } from './http-request.js';
|
|
1
2
|
import type { SearchResult } from './search-types.js';
|
|
2
3
|
export type BraveResult = SearchResult;
|
|
3
4
|
export interface BraveSearchOpts {
|
|
@@ -5,6 +6,12 @@ export interface BraveSearchOpts {
|
|
|
5
6
|
count?: number;
|
|
6
7
|
timeoutMs?: number;
|
|
7
8
|
signal?: AbortSignal;
|
|
9
|
+
/**
|
|
10
|
+
* Injectable fetch. Brave was the ONE provider without this: exa and ddg both
|
|
11
|
+
* took a `fetchImpl`, so brave's status ladder — the widest of the three — was
|
|
12
|
+
* the only one no test could drive at the request level.
|
|
13
|
+
*/
|
|
14
|
+
fetchImpl?: FetchLike;
|
|
8
15
|
}
|
|
9
16
|
export declare class BraveSearchError extends Error {
|
|
10
17
|
readonly kind: 'auth' | 'rate-limit' | 'http' | 'network' | 'aborted';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { httpRequest, HttpRequestError } from './http-request.js';
|
|
1
2
|
const BRAVE_ENDPOINT = 'https://api.search.brave.com/res/v1/web/search';
|
|
2
3
|
const DEFAULT_COUNT = 10;
|
|
3
4
|
const MAX_COUNT = 20;
|
|
@@ -14,64 +15,44 @@ export class BraveSearchError extends Error {
|
|
|
14
15
|
}
|
|
15
16
|
export async function braveSearch(query, opts) {
|
|
16
17
|
const count = Math.max(1, Math.min(MAX_COUNT, opts.count ?? DEFAULT_COUNT));
|
|
17
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
18
18
|
const url = `${BRAVE_ENDPOINT}?q=${encodeURIComponent(query)}&count=${count}`;
|
|
19
|
-
const internalController = new AbortController();
|
|
20
|
-
let userAborted = false;
|
|
21
|
-
const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
|
|
22
|
-
const onUserAbort = () => {
|
|
23
|
-
userAborted = true;
|
|
24
|
-
internalController.abort();
|
|
25
|
-
};
|
|
26
|
-
if (opts.signal) {
|
|
27
|
-
if (opts.signal.aborted)
|
|
28
|
-
onUserAbort();
|
|
29
|
-
else
|
|
30
|
-
opts.signal.addEventListener('abort', onUserAbort, { once: true });
|
|
31
|
-
}
|
|
32
19
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
signal: internalController.signal
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
catch (err) {
|
|
45
|
-
if (userAborted) {
|
|
46
|
-
throw new BraveSearchError('Search aborted.', 'aborted');
|
|
20
|
+
return await httpRequest(url, {
|
|
21
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
22
|
+
...(opts.signal === undefined ? {} : { signal: opts.signal }),
|
|
23
|
+
...(opts.fetchImpl === undefined ? {} : { fetchImpl: opts.fetchImpl }),
|
|
24
|
+
method: 'GET',
|
|
25
|
+
headers: {
|
|
26
|
+
accept: 'application/json',
|
|
27
|
+
'x-subscription-token': opts.apiKey
|
|
47
28
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
29
|
+
}, async (response) => {
|
|
30
|
+
// Brave's own status policy: a rejected key and a rate limit are
|
|
31
|
+
// different problems for the user, and neither is a plain HTTP fault.
|
|
32
|
+
if (response.status === 401 || response.status === 403) {
|
|
33
|
+
throw new BraveSearchError(`Brave Search rejected the key (HTTP ${response.status}). Check BRAVE_SEARCH_API_KEY.`, 'auth', response.status);
|
|
34
|
+
}
|
|
35
|
+
if (response.status === 429) {
|
|
36
|
+
throw new BraveSearchError('Brave Search rate limit hit (HTTP 429). Try again in a moment.', 'rate-limit', 429);
|
|
37
|
+
}
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new BraveSearchError(`Brave Search HTTP ${response.status} ${response.statusText}`, 'http', response.status);
|
|
40
|
+
}
|
|
41
|
+
const body = (await response.json());
|
|
42
|
+
const rawResults = body.web?.results ?? [];
|
|
43
|
+
return rawResults
|
|
44
|
+
.filter((r) => typeof r.title === 'string'
|
|
45
|
+
&& typeof r.url === 'string'
|
|
46
|
+
&& typeof r.description === 'string')
|
|
47
|
+
.map(r => ({ title: r.title, url: r.url, description: r.description }));
|
|
48
|
+
});
|
|
66
49
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err instanceof HttpRequestError) {
|
|
52
|
+
throw err.kind === 'aborted' ?
|
|
53
|
+
new BraveSearchError('Search aborted.', 'aborted')
|
|
54
|
+
: new BraveSearchError(`Brave Search request failed: ${err.detail}`, 'network');
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
71
57
|
}
|
|
72
58
|
}
|
|
73
|
-
function describeError(err) {
|
|
74
|
-
if (err instanceof Error)
|
|
75
|
-
return err.message;
|
|
76
|
-
return String(err);
|
|
77
|
-
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* get the destination URL. Ad rows redirect through duckduckgo.com itself and
|
|
8
8
|
* are dropped.
|
|
9
9
|
*/
|
|
10
|
-
import type
|
|
10
|
+
import { type FetchLike } from './http-request.js';
|
|
11
11
|
import type { SearchResult } from './search-types.js';
|
|
12
12
|
export interface DdgSearchOpts {
|
|
13
13
|
count?: number;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* are dropped.
|
|
9
9
|
*/
|
|
10
10
|
import { parseHTML } from 'linkedom';
|
|
11
|
+
import { httpRequest, HttpRequestError } from './http-request.js';
|
|
11
12
|
const DDG_ENDPOINT = 'https://html.duckduckgo.com/html/';
|
|
12
13
|
const DEFAULT_COUNT = 10;
|
|
13
14
|
const MAX_COUNT = 20;
|
|
@@ -26,51 +27,35 @@ export class DdgSearchError extends Error {
|
|
|
26
27
|
}
|
|
27
28
|
export async function ddgSearch(query, opts = {}) {
|
|
28
29
|
const count = Math.max(1, Math.min(MAX_COUNT, opts.count ?? DEFAULT_COUNT));
|
|
29
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
30
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
31
30
|
const url = `${DDG_ENDPOINT}?q=${encodeURIComponent(query)}`;
|
|
32
|
-
const internalController = new AbortController();
|
|
33
|
-
let userAborted = false;
|
|
34
|
-
const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
|
|
35
|
-
const onUserAbort = () => {
|
|
36
|
-
userAborted = true;
|
|
37
|
-
internalController.abort();
|
|
38
|
-
};
|
|
39
|
-
if (opts.signal) {
|
|
40
|
-
if (opts.signal.aborted)
|
|
41
|
-
onUserAbort();
|
|
42
|
-
else
|
|
43
|
-
opts.signal.addEventListener('abort', onUserAbort, { once: true });
|
|
44
|
-
}
|
|
45
31
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
throw new DdgSearchError(`DuckDuckGo HTTP ${response.status} ${response.statusText}`, 'http', response.status);
|
|
67
|
-
}
|
|
68
|
-
return parseDdgHtml(await response.text()).slice(0, count);
|
|
32
|
+
return await httpRequest(url, {
|
|
33
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
34
|
+
...(opts.signal === undefined ? {} : { signal: opts.signal }),
|
|
35
|
+
...(opts.fetchImpl === undefined ? {} : { fetchImpl: opts.fetchImpl }),
|
|
36
|
+
method: 'GET',
|
|
37
|
+
headers: {
|
|
38
|
+
'user-agent': USER_AGENT,
|
|
39
|
+
accept: 'text/html'
|
|
40
|
+
}
|
|
41
|
+
}, async (response) => {
|
|
42
|
+
// DDG's own status policy: 429/403 is throttling, not a plain HTTP fault.
|
|
43
|
+
if (response.status === 429 || response.status === 403) {
|
|
44
|
+
throw new DdgSearchError(`DuckDuckGo is rate-limiting this client (HTTP ${response.status}). Try again in a moment.`, 'rate-limit', response.status);
|
|
45
|
+
}
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new DdgSearchError(`DuckDuckGo HTTP ${response.status} ${response.statusText}`, 'http', response.status);
|
|
48
|
+
}
|
|
49
|
+
return parseDdgHtml(await response.text()).slice(0, count);
|
|
50
|
+
});
|
|
69
51
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err instanceof HttpRequestError) {
|
|
54
|
+
throw err.kind === 'aborted' ?
|
|
55
|
+
new DdgSearchError('Search aborted.', 'aborted')
|
|
56
|
+
: new DdgSearchError(`DuckDuckGo request failed: ${err.detail}`, 'network');
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
74
59
|
}
|
|
75
60
|
}
|
|
76
61
|
export function parseDdgHtml(html) {
|
|
@@ -123,8 +108,3 @@ function unwrapDdgRedirect(href) {
|
|
|
123
108
|
function collapse(text) {
|
|
124
109
|
return text.replace(/\s+/g, ' ').trim();
|
|
125
110
|
}
|
|
126
|
-
function describeError(err) {
|
|
127
|
-
if (err instanceof Error)
|
|
128
|
-
return err.message;
|
|
129
|
-
return String(err);
|
|
130
|
-
}
|
|
@@ -167,11 +167,81 @@ export declare function findDeclaredRange(parentPkg: string, cwd: string): strin
|
|
|
167
167
|
export declare function buildVersionBanner(pin: AutoInstallPin | undefined, resolved: string, version: string, cwd: string): string;
|
|
168
168
|
export declare function getDocsModulesDir(): string;
|
|
169
169
|
export declare function ensureDocsModulesDir(dir: string): void;
|
|
170
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Options rather than a positional tail: `signal` and `versionRange` sat adjacent,
|
|
172
|
+
* and reaching the range meant writing `undefined` into the signal slot — which is
|
|
173
|
+
* exactly how the primary acquisition path lost its abort signal while the hop path
|
|
174
|
+
* kept it.
|
|
175
|
+
*/
|
|
176
|
+
export interface AutoInstallOptions {
|
|
177
|
+
signal?: AbortSignal | undefined;
|
|
178
|
+
versionRange?: string | undefined;
|
|
179
|
+
}
|
|
180
|
+
export declare function runAutoInstall(spawn: SpawnFn, packageName: string, opts?: AutoInstallOptions): Promise<{
|
|
171
181
|
success: boolean;
|
|
172
182
|
installDir: string;
|
|
173
183
|
stderr: string;
|
|
174
184
|
}>;
|
|
185
|
+
/** What acquiring one package produced, or the stage at which it failed. */
|
|
186
|
+
export type AcquireOutcome = {
|
|
187
|
+
ok: true;
|
|
188
|
+
pkg: ResolvedPackage;
|
|
189
|
+
autoInstalled: boolean;
|
|
190
|
+
pin?: AutoInstallPin;
|
|
191
|
+
} | {
|
|
192
|
+
ok: false;
|
|
193
|
+
stage: 'resolve';
|
|
194
|
+
err: unknown;
|
|
195
|
+
} | {
|
|
196
|
+
ok: false;
|
|
197
|
+
stage: 'install';
|
|
198
|
+
stderr: string;
|
|
199
|
+
pin: AutoInstallPin;
|
|
200
|
+
asked: string;
|
|
201
|
+
} | {
|
|
202
|
+
ok: false;
|
|
203
|
+
stage: 'reresolve';
|
|
204
|
+
err: unknown;
|
|
205
|
+
pin: AutoInstallPin;
|
|
206
|
+
};
|
|
207
|
+
export interface AcquireInput {
|
|
208
|
+
/** The specifier to resolve. May be a subpath (`pkg/sub`) — the INSTALL always
|
|
209
|
+
* targets its parent package. */
|
|
210
|
+
name: string;
|
|
211
|
+
cwd: string;
|
|
212
|
+
spawn: SpawnFn;
|
|
213
|
+
resolvePackage: typeof defaultResolvePackage;
|
|
214
|
+
signal: AbortSignal | undefined;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Get a package onto disk and resolved: resolve from `cwd`, and on
|
|
218
|
+
* `not_installed` install it — at the range the PROJECT declares when it declares
|
|
219
|
+
* one — then resolve again from the install dir.
|
|
220
|
+
*
|
|
221
|
+
* One statement of the ladder, for both callers. It was written twice: inline in
|
|
222
|
+
* `docsRaw` for the requested package, and in `tryResolveOrInstall` for each hop
|
|
223
|
+
* of the type-redirect chain — and the copies had drifted on all three things
|
|
224
|
+
* that matter.
|
|
225
|
+
*
|
|
226
|
+
* - The abort signal. The hop copy passed it; the primary copy passed a bare
|
|
227
|
+
* `undefined` placeholder to reach the fourth positional, while
|
|
228
|
+
* `DocsRawInput.signal` was honoured on either side of that call. So a user
|
|
229
|
+
* cancel during the MAIN `npm install` of a model-chosen package was not
|
|
230
|
+
* delivered. `runAutoInstall` takes an options object now, so a hole like that
|
|
231
|
+
* cannot be typed.
|
|
232
|
+
* - The version pin. `findDeclaredRange` had exactly one call site — the primary
|
|
233
|
+
* copy. The hop copy installed `latest` unconditionally, on the hop most likely
|
|
234
|
+
* to be the declared one: `declarationChain` exists precisely because "a
|
|
235
|
+
* project that uses Bun declares `@types/bun`, not `bun`".
|
|
236
|
+
* - The provenance. `autoInstalled`/`autoInstallPin` were locals of `docsRaw`, so
|
|
237
|
+
* a package acquired only through a hop reported neither and got no version
|
|
238
|
+
* banner.
|
|
239
|
+
*
|
|
240
|
+
* CONTEXT.md records the redirect WALK as unified (`resolveTypeSource`) and its
|
|
241
|
+
* `resolveHop` seam as "the one thing its two call sites disagree about". They
|
|
242
|
+
* should disagree only about WHETHER to install, never about HOW.
|
|
243
|
+
*/
|
|
244
|
+
export declare function acquirePackage(input: AcquireInput): Promise<AcquireOutcome>;
|
|
175
245
|
export declare function docsRaw(input: DocsRawInput): Promise<DocsRawResult>;
|
|
176
246
|
export declare function docsFocused(input: DocsFocusedInput): Promise<DocsFocusedResult>;
|
|
177
247
|
export declare function buildPrompt(pkg: ResolvedPackage, query: string, content: string): string;
|