@mjasnikovs/pi-task 0.18.49 → 0.18.51
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/remote/register.js +4 -2
- package/dist/remote/server.d.ts +14 -0
- package/dist/remote/server.js +61 -18
- package/dist/shared/child-output.d.ts +21 -7
- package/dist/shared/child-output.js +11 -0
- package/dist/task/apis-contract.d.ts +67 -0
- package/dist/task/apis-contract.js +77 -0
- package/dist/task/context-attribution.d.ts +123 -0
- package/dist/task/context-attribution.js +285 -0
- package/dist/task/context-silence.d.ts +53 -0
- package/dist/task/context-silence.js +93 -0
- package/dist/task/phases.js +187 -5
- package/dist/task/prompts.js +13 -0
- package/dist/task/spec-urls.d.ts +117 -0
- package/dist/task/spec-urls.js +214 -0
- package/dist/task/type-only-answer.d.ts +95 -0
- package/dist/task/type-only-answer.js +249 -0
- package/dist/workers/fetch-core.d.ts +50 -0
- package/dist/workers/fetch-core.js +118 -16
- package/dist/workers/pi-worker-core.d.ts +11 -0
- package/dist/workers/pi-worker-core.js +30 -0
- package/dist/workers/pi-worker-docs.d.ts +9 -0
- package/dist/workers/pi-worker-docs.js +119 -7
- package/dist/workers/pi-worker-fetch.js +3 -1
- package/dist/workers/typeonly-log.d.ts +45 -0
- package/dist/workers/typeonly-log.js +88 -0
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic detector for the F-2 shape: a pi-worker-docs answer that RESTATES a type
|
|
3
|
+
* signature or declaration for a USAGE question, without ever saying what the API DOES.
|
|
4
|
+
*
|
|
5
|
+
* THE FATAL CASE, from mx5 run 15 (research-cache.json, verbatim):
|
|
6
|
+
*
|
|
7
|
+
* query : "hc factory function signature base url parameter types exported from hono/client"
|
|
8
|
+
* answer: "The `hc` factory function exported from `hono/client` accepts a generic type
|
|
9
|
+
* `T` extending `Hono`, an optional string prefix type `Prefix` (defaulting to
|
|
10
|
+
* `string`), and takes two parameters: `baseUrl` of type `Prefix` and an optional
|
|
11
|
+
* `options` of type `ClientRequestOptions`. It returns a
|
|
12
|
+
* `UnionToIntersection<Client<T, Prefix>>`."
|
|
13
|
+
*
|
|
14
|
+
* Every clause is type-level. "`baseUrl` of type `Prefix`" names the parameter's TYPE — and
|
|
15
|
+
* `Prefix` is itself an opaque type variable — while saying nothing about what `baseUrl`
|
|
16
|
+
* MEANS (is it an origin, or a mount prefix?). Escalation to search/fetch never fired
|
|
17
|
+
* because the answer looked complete: it named the very parameter asked about. worker:context
|
|
18
|
+
* then filled the semantic gap from memory and shipped `hc<AppType>('/api')`, so every request
|
|
19
|
+
* went to `/api/api/...` ⇒ 404 ⇒ the product's entire API surface was dead (F-2 → F-1).
|
|
20
|
+
*
|
|
21
|
+
* The lever (PROMPT 2 DO item 1): when a docs answer for a usage question is ONLY a
|
|
22
|
+
* signature/declaration restatement with no behavioural statement, treat it as UNANSWERED so
|
|
23
|
+
* the caller escalates (follow the `@see` pointer / fetch the spec-cited URL) instead of
|
|
24
|
+
* accepting a type as the answer.
|
|
25
|
+
*
|
|
26
|
+
* ── PRECISION/RECALL TRADEOFF (chosen deliberately; documented per the task) ────────────────
|
|
27
|
+
* A FALSE POSITIVE here (flagging a real answer as type-only) forces a needless escalation:
|
|
28
|
+
* wall-clock cost against PROMPT 2 invariant 1, which caps added child spawns. A false
|
|
29
|
+
* NEGATIVE (missing a type-only answer) merely leaves the pre-existing bug unfixed for that
|
|
30
|
+
* one borderline case. So this detector is tuned for HIGH PRECISION on real answers, accepting
|
|
31
|
+
* low recall — "better to miss a borderline type-only answer than to flag a real one." Three
|
|
32
|
+
* independent gates must ALL hold before an answer is called type-only; any one failing clears
|
|
33
|
+
* it. Calibrated against the run-15 corpus: of the 149 valid (non-"unclear") pi-worker-docs
|
|
34
|
+
* answers, this rule flags EXACTLY ONE — the recorded `hc` case — and clears the other 148,
|
|
35
|
+
* including every legitimate signature answer to an explicit "give me the type/signature"
|
|
36
|
+
* question (bun.password.hash, toBuffer, BuildOutput, …). See type-only-answer.test.ts.
|
|
37
|
+
*
|
|
38
|
+
* THE THREE GATES (all required):
|
|
39
|
+
* 1. USAGE QUESTION. The question must seek usage/semantics — "how", "use", "work", "chain",
|
|
40
|
+
* "example", "call", "rpc", "mean", or it names a usage concept whose meaning is being
|
|
41
|
+
* sought ("base url"). A question that asks only for a TYPE / SIGNATURE / DEFINITION /
|
|
42
|
+
* FIELDS is legitimately answered by a signature, so it is NOT gated in. (The recorded hc
|
|
43
|
+
* query is signature-shaped but names "base url", the concept whose meaning was needed.)
|
|
44
|
+
* 2. NO BEHAVIOURAL CONTENT IN PROSE. The answer must contain no statement of what the API
|
|
45
|
+
* DOES: no usage verb ("use/call/pass/import"), no runtime-effect verb ("executes/
|
|
46
|
+
* prepends/enables/wraps"), no concrete usage example, no semantic meaning ("means/
|
|
47
|
+
* represents/so pass"), no concrete default VALUE or value RANGE ("default of 80", "from
|
|
48
|
+
* 1 to 100"). Any one clears. This scan runs over `proseOf(answer)`, NOT the raw answer:
|
|
49
|
+
* a verb that is really a method name inside a declaration ("methods route(url, handler)
|
|
50
|
+
* and use(...handlers)") is an identifier, not behaviour, and must not clear the answer.
|
|
51
|
+
* 3. SIGNATURE RESTATEMENT. The answer must actually be a declaration/signature restatement —
|
|
52
|
+
* "of type", "takes N parameters", "accepts", "returns a…", "extends", "interface",
|
|
53
|
+
* "declare const/function". Without this it is not type-only, it is just terse prose.
|
|
54
|
+
*
|
|
55
|
+
* An explicit "unclear from this package/page" is NOT type-only — it is the HONEST non-answer,
|
|
56
|
+
* already handled by the escalation path (PROMPT 2 DO item 2 escalates BOTH). It is cleared
|
|
57
|
+
* here with a distinct reason so the caller can route it through the existing unclear channel.
|
|
58
|
+
*
|
|
59
|
+
* Pure and side-effect free; unit-tested in type-only-answer.test.ts against real run-15 text.
|
|
60
|
+
*/
|
|
61
|
+
/** The verdict, with a human-readable reason for logging and for the escalation channel. */
|
|
62
|
+
export interface TypeOnlyVerdict {
|
|
63
|
+
/** True iff the answer restates a signature for a usage question with no behaviour. */
|
|
64
|
+
typeOnly: boolean;
|
|
65
|
+
/** Why — names the gate that decided it, quoting the matched marker where useful. */
|
|
66
|
+
reason: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Strip signature/declaration fragments so the behavioural scan sees only PROSE.
|
|
70
|
+
*
|
|
71
|
+
* A behavioural verb is a signal ONLY when it is a verb in prose ("the base URL is
|
|
72
|
+
* prepended", "you use X to Y"), NEVER when it is a method/identifier token inside a
|
|
73
|
+
* declaration. Without this, a bare type-only answer like
|
|
74
|
+
* "It has methods route(url, handler) and use(...handlers)."
|
|
75
|
+
* is wrongly cleared, because `use(...handlers)` matches /\buse\b/ and `handler` matches
|
|
76
|
+
* /handle/ — the F-3(a) router-fixture shape. So before scanning for behaviour we remove:
|
|
77
|
+
* - `name(args)` call fragments, whose args are identifiers, not prose (this is what
|
|
78
|
+
* carries the method-name verbs);
|
|
79
|
+
* - `declare const|function|module|class|namespace …` declaration lines.
|
|
80
|
+
* The SIGNATURE gate still runs against the ORIGINAL answer, so stripping never weakens
|
|
81
|
+
* gate 3 — it only prevents an identifier inside a signature from masquerading as prose.
|
|
82
|
+
* Deliberately conservative: it does NOT strip `: Type` annotations or backtick spans,
|
|
83
|
+
* because a colon or backtick often precedes real behavioural prose ("Usage: call …",
|
|
84
|
+
* "`sql` is prepended …"), and removing those would silently drop behaviour and MANUFACTURE
|
|
85
|
+
* false positives — worse than the recall gap this fixes.
|
|
86
|
+
*/
|
|
87
|
+
export declare function proseOf(answer: string): string;
|
|
88
|
+
/**
|
|
89
|
+
* Decide whether a pi-worker-docs `answer` to `question` is type-only — a signature/declaration
|
|
90
|
+
* restatement with no behavioural statement, for a question that needed usage semantics.
|
|
91
|
+
*
|
|
92
|
+
* @param answer the child's <answer> prose (NOT the version banner or the source excerpt).
|
|
93
|
+
* @param question the query the answer was produced for.
|
|
94
|
+
*/
|
|
95
|
+
export declare function isTypeOnlyAnswer(answer: string, question: string): TypeOnlyVerdict;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic detector for the F-2 shape: a pi-worker-docs answer that RESTATES a type
|
|
3
|
+
* signature or declaration for a USAGE question, without ever saying what the API DOES.
|
|
4
|
+
*
|
|
5
|
+
* THE FATAL CASE, from mx5 run 15 (research-cache.json, verbatim):
|
|
6
|
+
*
|
|
7
|
+
* query : "hc factory function signature base url parameter types exported from hono/client"
|
|
8
|
+
* answer: "The `hc` factory function exported from `hono/client` accepts a generic type
|
|
9
|
+
* `T` extending `Hono`, an optional string prefix type `Prefix` (defaulting to
|
|
10
|
+
* `string`), and takes two parameters: `baseUrl` of type `Prefix` and an optional
|
|
11
|
+
* `options` of type `ClientRequestOptions`. It returns a
|
|
12
|
+
* `UnionToIntersection<Client<T, Prefix>>`."
|
|
13
|
+
*
|
|
14
|
+
* Every clause is type-level. "`baseUrl` of type `Prefix`" names the parameter's TYPE — and
|
|
15
|
+
* `Prefix` is itself an opaque type variable — while saying nothing about what `baseUrl`
|
|
16
|
+
* MEANS (is it an origin, or a mount prefix?). Escalation to search/fetch never fired
|
|
17
|
+
* because the answer looked complete: it named the very parameter asked about. worker:context
|
|
18
|
+
* then filled the semantic gap from memory and shipped `hc<AppType>('/api')`, so every request
|
|
19
|
+
* went to `/api/api/...` ⇒ 404 ⇒ the product's entire API surface was dead (F-2 → F-1).
|
|
20
|
+
*
|
|
21
|
+
* The lever (PROMPT 2 DO item 1): when a docs answer for a usage question is ONLY a
|
|
22
|
+
* signature/declaration restatement with no behavioural statement, treat it as UNANSWERED so
|
|
23
|
+
* the caller escalates (follow the `@see` pointer / fetch the spec-cited URL) instead of
|
|
24
|
+
* accepting a type as the answer.
|
|
25
|
+
*
|
|
26
|
+
* ── PRECISION/RECALL TRADEOFF (chosen deliberately; documented per the task) ────────────────
|
|
27
|
+
* A FALSE POSITIVE here (flagging a real answer as type-only) forces a needless escalation:
|
|
28
|
+
* wall-clock cost against PROMPT 2 invariant 1, which caps added child spawns. A false
|
|
29
|
+
* NEGATIVE (missing a type-only answer) merely leaves the pre-existing bug unfixed for that
|
|
30
|
+
* one borderline case. So this detector is tuned for HIGH PRECISION on real answers, accepting
|
|
31
|
+
* low recall — "better to miss a borderline type-only answer than to flag a real one." Three
|
|
32
|
+
* independent gates must ALL hold before an answer is called type-only; any one failing clears
|
|
33
|
+
* it. Calibrated against the run-15 corpus: of the 149 valid (non-"unclear") pi-worker-docs
|
|
34
|
+
* answers, this rule flags EXACTLY ONE — the recorded `hc` case — and clears the other 148,
|
|
35
|
+
* including every legitimate signature answer to an explicit "give me the type/signature"
|
|
36
|
+
* question (bun.password.hash, toBuffer, BuildOutput, …). See type-only-answer.test.ts.
|
|
37
|
+
*
|
|
38
|
+
* THE THREE GATES (all required):
|
|
39
|
+
* 1. USAGE QUESTION. The question must seek usage/semantics — "how", "use", "work", "chain",
|
|
40
|
+
* "example", "call", "rpc", "mean", or it names a usage concept whose meaning is being
|
|
41
|
+
* sought ("base url"). A question that asks only for a TYPE / SIGNATURE / DEFINITION /
|
|
42
|
+
* FIELDS is legitimately answered by a signature, so it is NOT gated in. (The recorded hc
|
|
43
|
+
* query is signature-shaped but names "base url", the concept whose meaning was needed.)
|
|
44
|
+
* 2. NO BEHAVIOURAL CONTENT IN PROSE. The answer must contain no statement of what the API
|
|
45
|
+
* DOES: no usage verb ("use/call/pass/import"), no runtime-effect verb ("executes/
|
|
46
|
+
* prepends/enables/wraps"), no concrete usage example, no semantic meaning ("means/
|
|
47
|
+
* represents/so pass"), no concrete default VALUE or value RANGE ("default of 80", "from
|
|
48
|
+
* 1 to 100"). Any one clears. This scan runs over `proseOf(answer)`, NOT the raw answer:
|
|
49
|
+
* a verb that is really a method name inside a declaration ("methods route(url, handler)
|
|
50
|
+
* and use(...handlers)") is an identifier, not behaviour, and must not clear the answer.
|
|
51
|
+
* 3. SIGNATURE RESTATEMENT. The answer must actually be a declaration/signature restatement —
|
|
52
|
+
* "of type", "takes N parameters", "accepts", "returns a…", "extends", "interface",
|
|
53
|
+
* "declare const/function". Without this it is not type-only, it is just terse prose.
|
|
54
|
+
*
|
|
55
|
+
* An explicit "unclear from this package/page" is NOT type-only — it is the HONEST non-answer,
|
|
56
|
+
* already handled by the escalation path (PROMPT 2 DO item 2 escalates BOTH). It is cleared
|
|
57
|
+
* here with a distinct reason so the caller can route it through the existing unclear channel.
|
|
58
|
+
*
|
|
59
|
+
* Pure and side-effect free; unit-tested in type-only-answer.test.ts against real run-15 text.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* Question seeks usage/semantics rather than a bare type. A usage concept whose MEANING is
|
|
63
|
+
* being asked about ("base url") counts, because the F-2 defect is exactly a semantics need
|
|
64
|
+
* dressed in signature words. A question that asks only for the "type"/"signature"/"definition"
|
|
65
|
+
* is deliberately NOT here — a signature answer is responsive to it.
|
|
66
|
+
*/
|
|
67
|
+
const USAGE_INTENT = [
|
|
68
|
+
/\bhow\b/i,
|
|
69
|
+
/\buse\b/i,
|
|
70
|
+
/\busing\b/i,
|
|
71
|
+
/\busage\b/i,
|
|
72
|
+
/\bworks?\b/i,
|
|
73
|
+
/\bworking\b/i,
|
|
74
|
+
/\bchain/i,
|
|
75
|
+
/base\s?url/i,
|
|
76
|
+
/\bconnect/i,
|
|
77
|
+
/\brpc\b/i,
|
|
78
|
+
/\bexample/i,
|
|
79
|
+
/\bcall\b/i,
|
|
80
|
+
/\binvoke/i,
|
|
81
|
+
/\bmean/i,
|
|
82
|
+
/\bbehav/i,
|
|
83
|
+
/what\s+does/i,
|
|
84
|
+
/how\s+does/i
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Markers that the answer says what the API DOES — a usage instruction, a runtime effect, a
|
|
88
|
+
* concrete example, a meaning, a concrete default value or range. Deliberately GENEROUS: every
|
|
89
|
+
* extra marker here can only SUPPRESS a flag, which is the safe direction for precision.
|
|
90
|
+
*/
|
|
91
|
+
const BEHAVIOURAL = [
|
|
92
|
+
/\buse\b/i,
|
|
93
|
+
/\busing\b/i,
|
|
94
|
+
/\bcall/i,
|
|
95
|
+
/\bpass(es|ed|ing)?\b/i,
|
|
96
|
+
/\bimport/i,
|
|
97
|
+
/\binstantiate/i,
|
|
98
|
+
/\bprovide[sd]?\b/i,
|
|
99
|
+
/\binvoke/i,
|
|
100
|
+
/\bread\b/i,
|
|
101
|
+
/\bwrite\b/i,
|
|
102
|
+
/\bexecut/i,
|
|
103
|
+
/\bsends?\b/i,
|
|
104
|
+
/\bprepend/i,
|
|
105
|
+
/\bappend/i,
|
|
106
|
+
/\bconnect/i,
|
|
107
|
+
/\bhandle/i,
|
|
108
|
+
/\bresolves?\s+to\b/i,
|
|
109
|
+
/\bstarts?\b/i,
|
|
110
|
+
/\bspread/i,
|
|
111
|
+
/\bautomatically\b/i,
|
|
112
|
+
/\breplac/i,
|
|
113
|
+
/\benabl/i,
|
|
114
|
+
/\bdisabl/i,
|
|
115
|
+
/\bwrap/i,
|
|
116
|
+
/\bperform/i,
|
|
117
|
+
/\bmanag/i,
|
|
118
|
+
/\bcontrol/i,
|
|
119
|
+
/\baccess/i,
|
|
120
|
+
/\bcreat/i,
|
|
121
|
+
/\ballow/i,
|
|
122
|
+
/\bcontain/i,
|
|
123
|
+
/\bregister/i,
|
|
124
|
+
/\bmount/i,
|
|
125
|
+
/\bnavigat/i,
|
|
126
|
+
/\btrigger/i,
|
|
127
|
+
/\bappl(y|ies)/i,
|
|
128
|
+
/\bmeans\b/i,
|
|
129
|
+
/\brepresents/i,
|
|
130
|
+
/so\s+pass/i,
|
|
131
|
+
/for\s+setup/i,
|
|
132
|
+
/used\s+to/i,
|
|
133
|
+
/used\s+for/i,
|
|
134
|
+
/in\s+order\s+to/i,
|
|
135
|
+
/for\s+\w+ing\b/i,
|
|
136
|
+
/e\.g\./i,
|
|
137
|
+
/for\s+example/i,
|
|
138
|
+
/default\s+of/i,
|
|
139
|
+
/defaults?\s+to/i,
|
|
140
|
+
/with\s+a\s+default/i,
|
|
141
|
+
/from\s+\d+\s+to\s+\d+/i,
|
|
142
|
+
/when\s+using/i,
|
|
143
|
+
/you\s+can/i,
|
|
144
|
+
/you\s+provide/i,
|
|
145
|
+
/you\s+get/i,
|
|
146
|
+
/gives?\s+access/i,
|
|
147
|
+
/to\s+(enable|perform|run|execute|create|manage|handle)\b/i,
|
|
148
|
+
/\bconfigur/i,
|
|
149
|
+
/\bschema/i,
|
|
150
|
+
/\bvalidat/i
|
|
151
|
+
];
|
|
152
|
+
/** Markers that the answer is a declaration/signature restatement. */
|
|
153
|
+
const SIGNATURE = [
|
|
154
|
+
/\bof\s+type\b/i,
|
|
155
|
+
/\btakes?\s+(a|an|two|three|one|four)\b/i,
|
|
156
|
+
/\baccepts?\b/i,
|
|
157
|
+
/\breturns?\s+(a|an|`|the|void|Promise|<|\w+<)/i,
|
|
158
|
+
/\bsignature\s+(is|:)/i,
|
|
159
|
+
/\bextends\b/i,
|
|
160
|
+
/\binterface\b/i,
|
|
161
|
+
/\bgeneric\s+type\b/i,
|
|
162
|
+
/\boverload/i,
|
|
163
|
+
/\bparameters?:/i,
|
|
164
|
+
/\bconstructor\s+accepts/i,
|
|
165
|
+
/declare\s+(const|function)/i
|
|
166
|
+
];
|
|
167
|
+
/** Explicit non-answer the docs child emits when the package cannot answer. */
|
|
168
|
+
const UNCLEAR = /\bunclear\s+from\s+this\s+(package|page)\b/i;
|
|
169
|
+
function firstMatch(text, patterns) {
|
|
170
|
+
for (const p of patterns) {
|
|
171
|
+
const m = p.exec(text);
|
|
172
|
+
if (m)
|
|
173
|
+
return m[0];
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Strip signature/declaration fragments so the behavioural scan sees only PROSE.
|
|
179
|
+
*
|
|
180
|
+
* A behavioural verb is a signal ONLY when it is a verb in prose ("the base URL is
|
|
181
|
+
* prepended", "you use X to Y"), NEVER when it is a method/identifier token inside a
|
|
182
|
+
* declaration. Without this, a bare type-only answer like
|
|
183
|
+
* "It has methods route(url, handler) and use(...handlers)."
|
|
184
|
+
* is wrongly cleared, because `use(...handlers)` matches /\buse\b/ and `handler` matches
|
|
185
|
+
* /handle/ — the F-3(a) router-fixture shape. So before scanning for behaviour we remove:
|
|
186
|
+
* - `name(args)` call fragments, whose args are identifiers, not prose (this is what
|
|
187
|
+
* carries the method-name verbs);
|
|
188
|
+
* - `declare const|function|module|class|namespace …` declaration lines.
|
|
189
|
+
* The SIGNATURE gate still runs against the ORIGINAL answer, so stripping never weakens
|
|
190
|
+
* gate 3 — it only prevents an identifier inside a signature from masquerading as prose.
|
|
191
|
+
* Deliberately conservative: it does NOT strip `: Type` annotations or backtick spans,
|
|
192
|
+
* because a colon or backtick often precedes real behavioural prose ("Usage: call …",
|
|
193
|
+
* "`sql` is prepended …"), and removing those would silently drop behaviour and MANUFACTURE
|
|
194
|
+
* false positives — worse than the recall gap this fixes.
|
|
195
|
+
*/
|
|
196
|
+
export function proseOf(answer) {
|
|
197
|
+
return answer
|
|
198
|
+
.replace(/[\w$][\w$.]*\s*\([^)]*\)/g, ' ')
|
|
199
|
+
.replace(/\bdeclare\s+(const|function|module|class|namespace)\b[^.\n]*/gi, ' ')
|
|
200
|
+
.replace(/\s{2,}/g, ' ')
|
|
201
|
+
.trim();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Decide whether a pi-worker-docs `answer` to `question` is type-only — a signature/declaration
|
|
205
|
+
* restatement with no behavioural statement, for a question that needed usage semantics.
|
|
206
|
+
*
|
|
207
|
+
* @param answer the child's <answer> prose (NOT the version banner or the source excerpt).
|
|
208
|
+
* @param question the query the answer was produced for.
|
|
209
|
+
*/
|
|
210
|
+
export function isTypeOnlyAnswer(answer, question) {
|
|
211
|
+
const a = answer.trim();
|
|
212
|
+
const q = question.trim();
|
|
213
|
+
if (a.length === 0) {
|
|
214
|
+
return { typeOnly: false, reason: 'empty answer' };
|
|
215
|
+
}
|
|
216
|
+
if (UNCLEAR.test(a)) {
|
|
217
|
+
return {
|
|
218
|
+
typeOnly: false,
|
|
219
|
+
reason: 'explicit "unclear" non-answer — routed through the existing unclear/escalation path, not type-only'
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const usage = firstMatch(q, USAGE_INTENT);
|
|
223
|
+
if (usage === null) {
|
|
224
|
+
return {
|
|
225
|
+
typeOnly: false,
|
|
226
|
+
reason: 'question requests a type/signature/definition, not usage semantics — a signature answer is responsive'
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// Scan for behavioural verbs over the PROSE only — a verb that is really a method name
|
|
230
|
+
// inside a signature (`use(...handlers)`) must not read as a statement of behaviour.
|
|
231
|
+
const behavioural = firstMatch(proseOf(a), BEHAVIOURAL);
|
|
232
|
+
if (behavioural !== null) {
|
|
233
|
+
return {
|
|
234
|
+
typeOnly: false,
|
|
235
|
+
reason: `answer states behaviour/usage (matched "${behavioural}") — not type-only`
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const signature = firstMatch(a, SIGNATURE);
|
|
239
|
+
if (signature === null) {
|
|
240
|
+
return {
|
|
241
|
+
typeOnly: false,
|
|
242
|
+
reason: 'answer is not a signature/declaration restatement'
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
typeOnly: true,
|
|
247
|
+
reason: `usage question answered only with a signature/declaration restatement (matched "${signature}") and no behavioural statement`
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
|
|
2
2
|
import { type SpawnFn } from '../shared/child-process.js';
|
|
3
|
+
import { type ExcerptVerification } from '../shared/child-output.js';
|
|
4
|
+
/** The exact non-answers the child is instructed to emit, matched at the tool layer. */
|
|
5
|
+
export declare const UNCLEAR_ANSWER = "unclear from this page";
|
|
6
|
+
export declare const NOT_COVERED_ANSWER = "not covered by this page";
|
|
3
7
|
export interface FetchRawInput {
|
|
4
8
|
url: string;
|
|
5
9
|
signal?: AbortSignal;
|
|
@@ -11,6 +15,22 @@ export interface FetchRawResult {
|
|
|
11
15
|
title: string;
|
|
12
16
|
}
|
|
13
17
|
export declare function fetchRaw(input: FetchRawInput): Promise<FetchRawResult>;
|
|
18
|
+
/**
|
|
19
|
+
* How the page content is turned into the child's prompt. Injectable so an A/B harness can
|
|
20
|
+
* run the shipped strategy against a frozen legacy one in the SAME process, and assert per
|
|
21
|
+
* rep that the two arms differ by exactly the lever (see scripts/live-fetch-abstention-ab.ts).
|
|
22
|
+
* Production always uses {@link shippedStrategy}.
|
|
23
|
+
*/
|
|
24
|
+
export interface PromptStrategy {
|
|
25
|
+
selectContent(markdown: string, requestedUrl: string): SelectedContent;
|
|
26
|
+
buildPrompt(args: {
|
|
27
|
+
query: string;
|
|
28
|
+
url: string;
|
|
29
|
+
title: string;
|
|
30
|
+
content: string;
|
|
31
|
+
section?: string;
|
|
32
|
+
}): string;
|
|
33
|
+
}
|
|
14
34
|
export interface FetchFocusedInput {
|
|
15
35
|
url: string;
|
|
16
36
|
query: string;
|
|
@@ -18,17 +38,47 @@ export interface FetchFocusedInput {
|
|
|
18
38
|
signal?: AbortSignal;
|
|
19
39
|
fetchAndClean?: typeof defaultFetchAndClean;
|
|
20
40
|
spawn?: SpawnFn;
|
|
41
|
+
/** Defaults to {@link shippedStrategy}; overridden only by the A/B harness. */
|
|
42
|
+
strategy?: PromptStrategy;
|
|
21
43
|
}
|
|
22
44
|
export interface FetchFocusedResult {
|
|
23
45
|
answer: string;
|
|
24
46
|
excerpt?: string;
|
|
25
47
|
excerptVerified?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The page did not cover the asked-about version/topic — a DISTINCT outcome from
|
|
50
|
+
* `unclear from this page`, so the caller can pick a different URL instead of treating
|
|
51
|
+
* "page has no answer" and "answer is ambiguous" as the same thing (PROMPT-3 item 3).
|
|
52
|
+
*/
|
|
53
|
+
coverageMiss: boolean;
|
|
54
|
+
/** The #fragment slug that was anchored, when the URL carried one and it was located. */
|
|
55
|
+
anchoredSection?: string;
|
|
56
|
+
/** Retained evidence for a false `excerptVerified`, so it is diagnosable without re-fetch. */
|
|
57
|
+
excerptCheck?: ExcerptVerification;
|
|
58
|
+
/** The prompt actually handed to the child — the A/B asserts surgery against this per rep. */
|
|
59
|
+
assembledPrompt: string;
|
|
26
60
|
childExitCode: number;
|
|
27
61
|
aborted: boolean;
|
|
28
62
|
stderr: string;
|
|
29
63
|
stdout: string;
|
|
30
64
|
}
|
|
31
65
|
export declare function fetchFocused(input: FetchFocusedInput): Promise<FetchFocusedResult>;
|
|
66
|
+
export interface SelectedContent {
|
|
67
|
+
content: string;
|
|
68
|
+
/** The #fragment slug that was anchored, if the URL carried one AND it was located. */
|
|
69
|
+
section?: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Slice a markdown page down to the section its heading-anchor #fragment names — the deep
|
|
73
|
+
* link the caller actually asked for — instead of head-truncating the whole page and losing
|
|
74
|
+
* a section that sits past the head window (PROMPT-3 item 2). Falls back to {@link truncate}
|
|
75
|
+
* when the URL has no fragment, or the fragment names no heading in THIS content (e.g. the
|
|
76
|
+
* page changed since the link was captured), so a fragment we cannot honour never degrades a
|
|
77
|
+
* page we could otherwise read.
|
|
78
|
+
*/
|
|
79
|
+
export declare function selectContent(markdown: string, requestedUrl: string): SelectedContent;
|
|
80
|
+
/** The shipped strategy: fragment-aware selection + the recalibrated prompt. */
|
|
81
|
+
export declare const shippedStrategy: PromptStrategy;
|
|
32
82
|
export declare function formatResultText(parsed: {
|
|
33
83
|
answer: string;
|
|
34
84
|
excerpt?: string;
|
|
@@ -3,11 +3,15 @@ import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
|
|
|
3
3
|
import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
4
4
|
import { runChild } from '../shared/child-process.js';
|
|
5
5
|
import { childBaseArgs } from '../shared/child-extensions.js';
|
|
6
|
-
import { parseChildOutput,
|
|
6
|
+
import { parseChildOutput, verifyExcerpt, formatResultText as formatResultTextShared } from '../shared/child-output.js';
|
|
7
7
|
const CONTENT_BUDGET = 30_000;
|
|
8
8
|
const HEAD_CHARS = 25_000;
|
|
9
9
|
const TAIL_CHARS = 5_000;
|
|
10
10
|
const TRUNCATION_MARKER = '\n\n[...page continues, truncated...]\n\n';
|
|
11
|
+
/** The exact non-answers the child is instructed to emit, matched at the tool layer. */
|
|
12
|
+
export const UNCLEAR_ANSWER = 'unclear from this page';
|
|
13
|
+
export const NOT_COVERED_ANSWER = 'not covered by this page';
|
|
14
|
+
const NOT_COVERED_RE = /not covered by this page/i;
|
|
11
15
|
const childArgs = () => [...childBaseArgs(), '--no-tools'];
|
|
12
16
|
export async function fetchRaw(input) {
|
|
13
17
|
const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
|
|
@@ -17,52 +21,135 @@ export async function fetchRaw(input) {
|
|
|
17
21
|
export async function fetchFocused(input) {
|
|
18
22
|
const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
|
|
19
23
|
const spawnFn = input.spawn ?? defaultSpawn;
|
|
24
|
+
const strategy = input.strategy ?? shippedStrategy;
|
|
20
25
|
const cleaned = await fetchAndCleanFn(input.url, { signal: input.signal });
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
// The #fragment is a client-side concern the server never sees, so anchor from the
|
|
27
|
+
// ORIGINALLY REQUESTED url, not the post-redirect finalUrl (which will have dropped it).
|
|
28
|
+
const selected = strategy.selectContent(cleaned.markdown, input.url);
|
|
29
|
+
const prompt = strategy.buildPrompt({
|
|
23
30
|
query: input.query,
|
|
24
31
|
url: cleaned.finalUrl,
|
|
25
32
|
title: cleaned.title,
|
|
26
|
-
content:
|
|
33
|
+
content: selected.content,
|
|
34
|
+
section: selected.section
|
|
27
35
|
});
|
|
28
36
|
const invocation = getPiInvocation(childArgs(), prompt);
|
|
29
37
|
const childResult = await runChild(spawnFn, invocation, input.cwd, input.signal);
|
|
38
|
+
const base = {
|
|
39
|
+
anchoredSection: selected.section,
|
|
40
|
+
assembledPrompt: prompt,
|
|
41
|
+
stderr: childResult.stderr,
|
|
42
|
+
stdout: childResult.stdout
|
|
43
|
+
};
|
|
30
44
|
if (childResult.aborted) {
|
|
31
45
|
return {
|
|
32
46
|
answer: '',
|
|
47
|
+
coverageMiss: false,
|
|
33
48
|
childExitCode: childResult.exitCode,
|
|
34
49
|
aborted: true,
|
|
35
|
-
|
|
36
|
-
stdout: childResult.stdout
|
|
50
|
+
...base
|
|
37
51
|
};
|
|
38
52
|
}
|
|
39
53
|
if (childResult.exitCode !== 0) {
|
|
40
54
|
return {
|
|
41
55
|
answer: '',
|
|
56
|
+
coverageMiss: false,
|
|
42
57
|
childExitCode: childResult.exitCode,
|
|
43
58
|
aborted: false,
|
|
44
|
-
|
|
45
|
-
stdout: childResult.stdout
|
|
59
|
+
...base
|
|
46
60
|
};
|
|
47
61
|
}
|
|
48
62
|
const parsed = parseChildOutput(childResult.stdout);
|
|
49
|
-
|
|
63
|
+
// Verify against the FULL page, not the anchored slice: the slice is a substring of it,
|
|
64
|
+
// so a genuine excerpt still verifies, and an excerpt the child pulled from memory still
|
|
65
|
+
// fails — the detector's discrimination is unchanged by fragment anchoring.
|
|
66
|
+
const check = parsed.excerpt ? verifyExcerpt(parsed.excerpt, cleaned.markdown) : undefined;
|
|
50
67
|
return {
|
|
51
68
|
answer: parsed.answer,
|
|
52
69
|
excerpt: parsed.excerpt,
|
|
53
|
-
excerptVerified,
|
|
70
|
+
excerptVerified: check?.verified,
|
|
71
|
+
excerptCheck: check,
|
|
72
|
+
coverageMiss: NOT_COVERED_RE.test(parsed.answer),
|
|
54
73
|
childExitCode: 0,
|
|
55
74
|
aborted: false,
|
|
56
|
-
|
|
57
|
-
stdout: childResult.stdout
|
|
75
|
+
...base
|
|
58
76
|
};
|
|
59
77
|
}
|
|
78
|
+
/** Read the #fragment from a URL. Empty string when there is none. */
|
|
79
|
+
function fragmentOf(url) {
|
|
80
|
+
const h = url.indexOf('#');
|
|
81
|
+
return h === -1 ? '' : url.slice(h + 1).trim();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Slice a markdown page down to the section its heading-anchor #fragment names — the deep
|
|
85
|
+
* link the caller actually asked for — instead of head-truncating the whole page and losing
|
|
86
|
+
* a section that sits past the head window (PROMPT-3 item 2). Falls back to {@link truncate}
|
|
87
|
+
* when the URL has no fragment, or the fragment names no heading in THIS content (e.g. the
|
|
88
|
+
* page changed since the link was captured), so a fragment we cannot honour never degrades a
|
|
89
|
+
* page we could otherwise read.
|
|
90
|
+
*/
|
|
91
|
+
export function selectContent(markdown, requestedUrl) {
|
|
92
|
+
const frag = fragmentOf(requestedUrl);
|
|
93
|
+
if (frag) {
|
|
94
|
+
const section = sliceSection(markdown, frag);
|
|
95
|
+
if (section)
|
|
96
|
+
return { content: truncate(section), section: frag };
|
|
97
|
+
}
|
|
98
|
+
return { content: truncate(markdown) };
|
|
99
|
+
}
|
|
100
|
+
function escapeRegExp(s) {
|
|
101
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
102
|
+
}
|
|
103
|
+
/** Find the heading whose anchor is `#<frag>` and return that heading's section: from the
|
|
104
|
+
* heading line up to the next heading of the same or higher level. Anchors are matched on
|
|
105
|
+
* the heading line in both the Docusaurus (`### Title[](#slug "…")`) and GitHub
|
|
106
|
+
* (`## [Title](#slug)`) shapes; `#foo` must not match `#foobar`. */
|
|
107
|
+
function sliceSection(markdown, frag) {
|
|
108
|
+
const lines = markdown.split('\n');
|
|
109
|
+
const anchor = new RegExp('#' + escapeRegExp(frag) + '(?![a-z0-9-])', 'i');
|
|
110
|
+
let start = -1;
|
|
111
|
+
let level = 0;
|
|
112
|
+
for (let i = 0; i < lines.length; i++) {
|
|
113
|
+
const h = /^(#{1,6})\s/.exec(lines[i]);
|
|
114
|
+
if (h && anchor.test(lines[i])) {
|
|
115
|
+
start = i;
|
|
116
|
+
level = h[1].length;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (start === -1)
|
|
121
|
+
return undefined;
|
|
122
|
+
let end = lines.length;
|
|
123
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
124
|
+
const h = /^(#{1,6})\s/.exec(lines[i]);
|
|
125
|
+
if (h && h[1].length <= level) {
|
|
126
|
+
end = i;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const section = lines.slice(start, end).join('\n').trim();
|
|
131
|
+
return section.length > 0 ? section : undefined;
|
|
132
|
+
}
|
|
60
133
|
function truncate(md) {
|
|
61
134
|
if (md.length <= CONTENT_BUDGET)
|
|
62
135
|
return md;
|
|
63
136
|
return md.slice(0, HEAD_CHARS) + TRUNCATION_MARKER + md.slice(md.length - TAIL_CHARS);
|
|
64
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The shipped extraction prompt. Rule 5 was recalibrated for PROMPT 3: the pre-change rule
|
|
140
|
+
* sent the child to `unclear from this page` on ANY ambiguity, which threw away answers that
|
|
141
|
+
* were on the page (measured: 5 of fetch's 10 failures were deterministic false-"unclear").
|
|
142
|
+
* It now (5) answers from the content whenever the content supports one, even partially;
|
|
143
|
+
* (6) reports a page-coverage MISS distinctly, so it is not conflated with ambiguity; and
|
|
144
|
+
* (7) reserves `unclear` for genuinely-ambiguous content — never for absence, and never as
|
|
145
|
+
* licence to invent. The abstention was relaxed WITHOUT relaxing rules 1-2 (verbatim
|
|
146
|
+
* excerpt) — the fabrication guard the A/B scores is untouched.
|
|
147
|
+
*/
|
|
65
148
|
function buildPrompt(args) {
|
|
149
|
+
const sectionNote = args.section ?
|
|
150
|
+
`<page-section>The content below has been narrowed to the "#${args.section}" section of\n`
|
|
151
|
+
+ `the page — the section the URL points to. Treat it as the relevant part of the page.</page-section>\n`
|
|
152
|
+
: '';
|
|
66
153
|
return (`You extract a single piece of information from a web page to answer one question.\n`
|
|
67
154
|
+ `\n`
|
|
68
155
|
+ `Rules:\n`
|
|
@@ -76,16 +163,31 @@ function buildPrompt(args) {
|
|
|
76
163
|
+ ` specifically about page UI.\n`
|
|
77
164
|
+ `4. If the page is not in English, write the <answer> in English (translate key\n`
|
|
78
165
|
+ ` non-English terms) and keep the original-language text in <excerpt>.\n`
|
|
79
|
-
+ `5.
|
|
80
|
-
+ `
|
|
81
|
-
+ `
|
|
82
|
-
+ `
|
|
166
|
+
+ `5. Answer from <page-content> whenever it supports an answer — INCLUDING a partial\n`
|
|
167
|
+
+ ` one. If the content states only part of what is asked, give the part that IS\n`
|
|
168
|
+
+ ` present and note what is missing; do NOT fall back to "unclear" merely because\n`
|
|
169
|
+
+ ` the coverage is incomplete. When <page-content> plainly contains the asked-for\n`
|
|
170
|
+
+ ` thing (e.g. it lists the methods, fields, or signature asked about), you MUST\n`
|
|
171
|
+
+ ` answer it — "unclear" is the wrong response in that case.\n`
|
|
172
|
+
+ `6. If <page-content> is about a DIFFERENT version, topic, or page than the question\n`
|
|
173
|
+
+ ` asks about — the asked-about thing is simply not on this page — write exactly:\n`
|
|
174
|
+
+ ` <answer>${NOT_COVERED_ANSWER}</answer> and quote in <excerpt> the text that\n`
|
|
175
|
+
+ ` shows what this page IS about. Use this, not "unclear", so the caller can try a\n`
|
|
176
|
+
+ ` different page.\n`
|
|
177
|
+
+ `7. Only when the answer is genuinely present but ambiguous or self-contradictory,\n`
|
|
178
|
+
+ ` write exactly: <answer>${UNCLEAR_ANSWER}</answer> with the closest text in\n`
|
|
179
|
+
+ ` <excerpt>. Never invent an answer or state anything not supported by\n`
|
|
180
|
+
+ ` <page-content>.\n`
|
|
181
|
+
+ `8. Be terse. One short paragraph in <answer> max.\n`
|
|
83
182
|
+ `\n`
|
|
84
183
|
+ `<question>${args.query}</question>\n`
|
|
85
184
|
+ `<url>${args.url}</url>\n`
|
|
86
185
|
+ `<page-title>${args.title}</page-title>\n`
|
|
186
|
+
+ sectionNote
|
|
87
187
|
+ `<page-content>\n${args.content}\n</page-content>\n`);
|
|
88
188
|
}
|
|
189
|
+
/** The shipped strategy: fragment-aware selection + the recalibrated prompt. */
|
|
190
|
+
export const shippedStrategy = { selectContent, buildPrompt };
|
|
89
191
|
// ─── Thin wrapper: fetch-core formatResultText (no header) ───────────────────
|
|
90
192
|
export function formatResultText(parsed, verified) {
|
|
91
193
|
return formatResultTextShared('', parsed, verified);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ContextSnapshot, type SpawnFn } from '../shared/child-process.js';
|
|
2
2
|
import { type LoopHit } from '../task/loop-detector.js';
|
|
3
|
+
/** True when a tool call retrieves content an APIS entry could be grounded in. */
|
|
4
|
+
export declare function isGroundingRetrieval(toolName: string): boolean;
|
|
3
5
|
export interface RunWorkerInput {
|
|
4
6
|
prompt: string;
|
|
5
7
|
cwd: string;
|
|
@@ -102,6 +104,15 @@ export interface RunWorkerResult {
|
|
|
102
104
|
* elapsed when the child never produced output.
|
|
103
105
|
*/
|
|
104
106
|
workMs: number;
|
|
107
|
+
/**
|
|
108
|
+
* How many GROUNDING retrieval tool calls the FINAL attempt made — the calls
|
|
109
|
+
* that returned content an APIS entry could be cited from (see
|
|
110
|
+
* isGroundingRetrieval; `ls`/`find` excluded on purpose). Counted over the
|
|
111
|
+
* attempt that produced `text`, not summed across restarts (a restarted
|
|
112
|
+
* attempt discards its predecessor's calls along with its text). Zero here on
|
|
113
|
+
* a non-empty section means every symbol in it came from parametric memory.
|
|
114
|
+
*/
|
|
115
|
+
groundingRetrievalCount: number;
|
|
105
116
|
/**
|
|
106
117
|
* Set when the worker exhausted its re-prompts still leaking a tool call as
|
|
107
118
|
* text (wrong dialect, never executed). The caller must treat this as a
|