@mjasnikovs/pi-task 0.18.49 → 0.18.50
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/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
|
@@ -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
|
|
@@ -15,6 +15,29 @@ import { streamStallHint } from '../shared/stream-watchdog.js';
|
|
|
15
15
|
// model starts producing — making waitMs the real queue/cold-start cost and
|
|
16
16
|
// workMs the real generation+tool-call cost.
|
|
17
17
|
const DEFAULT_TOOLS = 'read,grep,find,ls';
|
|
18
|
+
/**
|
|
19
|
+
* Tool calls that can GROUND an APIS claim — i.e. return content a signature or
|
|
20
|
+
* command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
|
|
21
|
+
* (project source), and the web escalations `pi-worker-search`/`pi-worker-fetch`.
|
|
22
|
+
*
|
|
23
|
+
* `ls` and `find` are deliberately EXCLUDED: they return file/directory NAMES,
|
|
24
|
+
* and APIS owns symbols by name only, never paths (RESEARCH_APIS_PROMPT). Bare
|
|
25
|
+
* enumeration cannot verify a signature, so a worker that fabricates its section
|
|
26
|
+
* from memory does not launder itself grounded by calling `ls` once. That
|
|
27
|
+
* exclusion is the anti-gaming property of any gate built on this count: "one
|
|
28
|
+
* trivial `ls` then fabricate the rest" leaves groundingRetrievalCount at 0.
|
|
29
|
+
*/
|
|
30
|
+
const GROUNDING_RETRIEVAL_TOOLS = new Set([
|
|
31
|
+
'pi-worker-docs',
|
|
32
|
+
'read',
|
|
33
|
+
'grep',
|
|
34
|
+
'pi-worker-search',
|
|
35
|
+
'pi-worker-fetch'
|
|
36
|
+
]);
|
|
37
|
+
/** True when a tool call retrieves content an APIS entry could be grounded in. */
|
|
38
|
+
export function isGroundingRetrieval(toolName) {
|
|
39
|
+
return GROUNDING_RETRIEVAL_TOOLS.has(toolName);
|
|
40
|
+
}
|
|
18
41
|
/**
|
|
19
42
|
* Hard wall-clock bound on a single research worker run (one spawn). The
|
|
20
43
|
* exact-match LoopDetector only catches *identical* repeated tool calls; a model
|
|
@@ -190,6 +213,10 @@ export async function runWorker(input) {
|
|
|
190
213
|
// SIGTERM that kill produces would surface as a bare non-zero exit the
|
|
191
214
|
// caller couldn't distinguish from a crash.
|
|
192
215
|
let loopHit;
|
|
216
|
+
// Reset EACH attempt: on a restart the previous attempt's calls are
|
|
217
|
+
// discarded with its text, so the count must describe only the attempt
|
|
218
|
+
// whose text this call returns.
|
|
219
|
+
let groundingRetrievalCount = 0;
|
|
193
220
|
const timeout = workerTimeout(input.signal, timeoutMs);
|
|
194
221
|
// Per-tool-call watchdog for this attempt (null when off). Its abort is
|
|
195
222
|
// OR'd with the worker timeout / external cancel into the child's signal.
|
|
@@ -214,6 +241,8 @@ export async function runWorker(input) {
|
|
|
214
241
|
onFirstByte: () => (tFirstByte = Date.now()),
|
|
215
242
|
onToolCall: call => {
|
|
216
243
|
cmdWatch?.onStart(call);
|
|
244
|
+
if (isGroundingRetrieval(call.name))
|
|
245
|
+
groundingRetrievalCount++;
|
|
217
246
|
if (!loopDetector)
|
|
218
247
|
return null;
|
|
219
248
|
const hit = loopDetector.record(call);
|
|
@@ -302,6 +331,7 @@ export async function runWorker(input) {
|
|
|
302
331
|
aborted: result.aborted,
|
|
303
332
|
waitMs,
|
|
304
333
|
workMs,
|
|
334
|
+
groundingRetrievalCount,
|
|
305
335
|
...(leaked ? { leakedToolCall: leaked } : {}),
|
|
306
336
|
...(loopHit ? { loopHit } : {}),
|
|
307
337
|
...(timedOut ? { timedOut: true } : {}),
|
|
@@ -5,6 +5,15 @@ import { resolvePackage as defaultResolvePackage } from './docs-resolve.js';
|
|
|
5
5
|
import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
|
|
6
6
|
import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
|
|
7
7
|
import { type SpawnFn } from '../shared/child-process.js';
|
|
8
|
+
/**
|
|
9
|
+
* Pull `@see {@link https://…}` pointers out of retrieved .d.ts/README text.
|
|
10
|
+
*
|
|
11
|
+
* F-2(d): the answer to a type-only lookup usually is not in the package at all — it lives
|
|
12
|
+
* at the `@see` URL that the very excerpt being returned already carries. In run 15,
|
|
13
|
+
* hono.dev appeared in cache values ONLY inside these JSDoc links, and was never fetched.
|
|
14
|
+
* Surfacing the link is therefore free: the pointer is already in hand.
|
|
15
|
+
*/
|
|
16
|
+
export declare function extractSeeUrls(content: string): string[];
|
|
8
17
|
/**
|
|
9
18
|
* The package NAME a module specifier belongs to — `hono/client` → `hono`,
|
|
10
19
|
* `@scope/name/sub` → `@scope/name`. The cache stores this (not the raw specifier) as an
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
1
2
|
import { Type } from '@sinclair/typebox';
|
|
2
3
|
import { Text } from '@earendil-works/pi-tui';
|
|
3
4
|
import { openCache as defaultOpenCache } from './docs-cache.js';
|
|
@@ -9,6 +10,8 @@ import { childBaseArgs } from '../shared/child-extensions.js';
|
|
|
9
10
|
import { parseChildOutput, isExcerptInContent } from '../shared/child-output.js';
|
|
10
11
|
import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
11
12
|
import { formatChildFailure, makeWorkerTool } from './shared.js';
|
|
13
|
+
import { isTypeOnlyAnswer } from '../task/type-only-answer.js';
|
|
14
|
+
import { logDocsAnswer } from './typeonly-log.js';
|
|
12
15
|
import { normalizeQuery } from './research-cache.js';
|
|
13
16
|
import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
|
|
14
17
|
const childArgs = () => [...childBaseArgs(), '--no-tools'];
|
|
@@ -21,6 +24,25 @@ const Params = Type.Object({
|
|
|
21
24
|
description: 'What to extract from the docs. The child pi reads ranked chunks and returns ONLY content answering this.'
|
|
22
25
|
})
|
|
23
26
|
});
|
|
27
|
+
/**
|
|
28
|
+
* Pull `@see {@link https://…}` pointers out of retrieved .d.ts/README text.
|
|
29
|
+
*
|
|
30
|
+
* F-2(d): the answer to a type-only lookup usually is not in the package at all — it lives
|
|
31
|
+
* at the `@see` URL that the very excerpt being returned already carries. In run 15,
|
|
32
|
+
* hono.dev appeared in cache values ONLY inside these JSDoc links, and was never fetched.
|
|
33
|
+
* Surfacing the link is therefore free: the pointer is already in hand.
|
|
34
|
+
*/
|
|
35
|
+
export function extractSeeUrls(content) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const re = /@see\s*\{?\s*@?link\s+(https?:\/\/[^}\s)]+)/gi;
|
|
38
|
+
let m;
|
|
39
|
+
while ((m = re.exec(content)) !== null) {
|
|
40
|
+
const url = m[1].replace(/[.,;]+$/, '');
|
|
41
|
+
if (!out.includes(url))
|
|
42
|
+
out.push(url);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
24
46
|
/**
|
|
25
47
|
* The package NAME a module specifier belongs to — `hono/client` → `hono`,
|
|
26
48
|
* `@scope/name/sub` → `@scope/name`. The cache stores this (not the raw specifier) as an
|
|
@@ -72,10 +94,13 @@ export function registerPiWorkerDocs(pi, internals = {}) {
|
|
|
72
94
|
+ '- You need docs for a specific newer version than what is installed — use pi-worker-fetch on the upstream docs site',
|
|
73
95
|
parameters: Params,
|
|
74
96
|
async run(params, signal, ctx) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
97
|
+
// Always node:child_process spawn (matching fetch-core and every other
|
|
98
|
+
// worker). The former globalThis.Bun branch called Bun.spawn — whose signature
|
|
99
|
+
// is Bun.spawn([cmd, ...args], opts), NOT the node (cmd, args, opts) that
|
|
100
|
+
// runChild/SpawnFn require — so it threw "cmd must be an array" whenever it ran.
|
|
101
|
+
// It was DEAD in production (pi runs under node) and BYPASSED under bun test
|
|
102
|
+
// (internals.spawn is always injected), i.e. untested, unreachable, and wrong.
|
|
103
|
+
const spawn = internals.spawn ?? defaultSpawn;
|
|
79
104
|
// ── Project source lookup ───────────────────────────────────────
|
|
80
105
|
if (params.module === '.') {
|
|
81
106
|
const openCache = internals.openCache ?? defaultOpenCache;
|
|
@@ -138,6 +163,27 @@ export function registerPiWorkerDocs(pi, internals = {}) {
|
|
|
138
163
|
entryDts: null,
|
|
139
164
|
readme: null
|
|
140
165
|
}, parsed, verified);
|
|
166
|
+
// SAME instrumentation channel as the package path below, extended to the
|
|
167
|
+
// project-source branch because that branch is the MAJORITY of what
|
|
168
|
+
// worker:apis asks — 13 of 17 docs calls in run 15's fatal task, 7 of 12 in
|
|
169
|
+
// the first live diagnostic rep. With only the package path recorded, "the
|
|
170
|
+
// last docs answer before the worker stopped" was unanswerable: the sink's
|
|
171
|
+
// last row was routinely not the worker's last answer.
|
|
172
|
+
//
|
|
173
|
+
// `typeOnly` is recorded FALSE with an explicit reason rather than by running
|
|
174
|
+
// the detector: this path never applies it, and the record must say what the
|
|
175
|
+
// shipped tool decided, not what it would have decided. Inventing a verdict
|
|
176
|
+
// here would let a firing-rate computed off this sink count answers the lever
|
|
177
|
+
// does not reach.
|
|
178
|
+
logDocsAnswer({
|
|
179
|
+
module: params.module,
|
|
180
|
+
query: params.query,
|
|
181
|
+
answer: parsed.answer,
|
|
182
|
+
typeOnly: false,
|
|
183
|
+
reason: 'project-source lookup — the type-only detector is not applied here',
|
|
184
|
+
excerptVerified: verified,
|
|
185
|
+
toolText: text
|
|
186
|
+
});
|
|
141
187
|
return {
|
|
142
188
|
text,
|
|
143
189
|
details: {
|
|
@@ -233,13 +279,66 @@ export function registerPiWorkerDocs(pi, internals = {}) {
|
|
|
233
279
|
}
|
|
234
280
|
const parsed = parseChildOutput(child.stdout);
|
|
235
281
|
const verified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, concatenated) : undefined;
|
|
236
|
-
const
|
|
282
|
+
const body = formatResultText(pkg, parsed, verified);
|
|
283
|
+
// F-2: a TYPE-ONLY answer is the dangerous failure. "unclear from this package"
|
|
284
|
+
// is honest and already escalates; a signature is a well-formed, confident,
|
|
285
|
+
// on-topic answer that names the very parameter asked about, so the worker
|
|
286
|
+
// stops — and worker:context then fills the semantic gap from memory (F-1).
|
|
287
|
+
// Measured: 14 of 17 live reps terminated on exactly this shape.
|
|
288
|
+
//
|
|
289
|
+
// The retrieved type is KEPT (it is real and useful) and an UNANSWERED banner
|
|
290
|
+
// is prepended, naming the gap and — when the excerpt carries one — the `@see`
|
|
291
|
+
// URL that actually documents the semantics. That pointer is free: F-2(d) found
|
|
292
|
+
// hono.dev present in run-15 cache values ONLY inside these JSDoc links, never
|
|
293
|
+
// fetched. Prompting the escalation beats performing it here: this tool runs in
|
|
294
|
+
// parallel execution mode and cannot cleanly spawn a fetch of its own.
|
|
295
|
+
const typeOnly = isTypeOnlyAnswer(parsed.answer, params.query);
|
|
296
|
+
let text = versionBanner + npmHeader + body;
|
|
297
|
+
if (typeOnly.typeOnly) {
|
|
298
|
+
const seeUrls = extractSeeUrls(concatenated);
|
|
299
|
+
text =
|
|
300
|
+
versionBanner
|
|
301
|
+
+ npmHeader
|
|
302
|
+
+ 'UNANSWERED — TYPE-ONLY: the package gave a declaration, not the '
|
|
303
|
+
+ 'usage semantics this question needs. A signature says what the '
|
|
304
|
+
+ 'parameter IS, not what it MEANS. Do NOT answer from memory and do '
|
|
305
|
+
+ 'NOT treat the type below as the answer.\n'
|
|
306
|
+
+ (seeUrls.length > 0 ?
|
|
307
|
+
`NEXT STEP: the retrieved excerpt itself cites documentation — `
|
|
308
|
+
+ `fetch ${seeUrls[0]} (pi-worker-fetch) and re-ask this same `
|
|
309
|
+
+ `question.\n`
|
|
310
|
+
: 'NEXT STEP: use pi-worker-search / pi-worker-fetch for the official '
|
|
311
|
+
+ 'documentation of this API, then re-ask this same question.\n')
|
|
312
|
+
+ '\nThe declaration that WAS retrieved (context only, not the answer):\n'
|
|
313
|
+
+ body;
|
|
314
|
+
}
|
|
315
|
+
// STAGE 1 INSTRUMENTATION — off unless PI_TASK_TYPEONLY_LOG names a sink, and
|
|
316
|
+
// side-effect only: nothing below reads it, and every failure inside is
|
|
317
|
+
// swallowed. It records EVERY answer, flagged or not, because the open question
|
|
318
|
+
// is a RATE — how often this fires — and a log of firings alone has no
|
|
319
|
+
// denominator. See typeonly-log.ts.
|
|
320
|
+
//
|
|
321
|
+
// It sits AFTER `text` is final (it used to sit above `text`'s first assignment)
|
|
322
|
+
// so the record carries what the worker was actually handed, banner and cited
|
|
323
|
+
// excerpt included, not just the child's prose. Purely a move: logDocsAnswer
|
|
324
|
+
// returns nothing and nothing between the two positions reads it, so the tool's
|
|
325
|
+
// behaviour and its return value are unchanged.
|
|
326
|
+
logDocsAnswer({
|
|
327
|
+
module: params.module,
|
|
328
|
+
query: params.query,
|
|
329
|
+
answer: parsed.answer,
|
|
330
|
+
typeOnly: typeOnly.typeOnly,
|
|
331
|
+
reason: typeOnly.reason,
|
|
332
|
+
excerptVerified: verified,
|
|
333
|
+
toolText: text
|
|
334
|
+
});
|
|
237
335
|
return {
|
|
238
336
|
text,
|
|
239
337
|
details: {
|
|
240
338
|
...baseDetails,
|
|
241
339
|
childExitCode: 0,
|
|
242
|
-
excerptVerified: verified
|
|
340
|
+
excerptVerified: verified,
|
|
341
|
+
...(typeOnly.typeOnly ? { typeOnly: true } : {})
|
|
243
342
|
}
|
|
244
343
|
};
|
|
245
344
|
},
|
|
@@ -268,6 +367,19 @@ export function registerPiWorkerDocs(pi, internals = {}) {
|
|
|
268
367
|
// Only a completed lookup (child exited 0) is a real answer; not-installed,
|
|
269
368
|
// no-chunks, resolve/cache errors, and aborts omit childExitCode:0 and fall
|
|
270
369
|
// through to a live retry next time.
|
|
271
|
-
|
|
370
|
+
//
|
|
371
|
+
// F-2(e): process health is NOT answer quality. A child that ran fine and answered
|
|
372
|
+
// "unclear from this package" exits 0, so the NON-ANSWER was memoised and re-served
|
|
373
|
+
// as a cache hit to every later sibling task — 52 of run 15's cached entries were
|
|
374
|
+
// "unclear" with hitCache true. One dead end, paid for many times, and escalation
|
|
375
|
+
// could never re-fire because the miss never recurred. So a non-answer is now never
|
|
376
|
+
// stored: the next task that asks pays for a real lookup and can escalate.
|
|
377
|
+
//
|
|
378
|
+
// `text` is supplied by makeWorkerTool (shared.ts) alongside details, so the
|
|
379
|
+
// content check needs no new plumbing.
|
|
380
|
+
cacheable: (d, text) => d.childExitCode === 0
|
|
381
|
+
&& d.typeOnly !== true
|
|
382
|
+
&& d.excerptVerified !== false
|
|
383
|
+
&& !/unclear from this package/i.test(text)
|
|
272
384
|
});
|
|
273
385
|
}
|
|
@@ -59,7 +59,9 @@ export function registerPiWorkerFetch(pi, internals = {}) {
|
|
|
59
59
|
childExitCode: 0,
|
|
60
60
|
answer: result.answer,
|
|
61
61
|
excerpt: result.excerpt,
|
|
62
|
-
excerptVerified: result.excerptVerified
|
|
62
|
+
excerptVerified: result.excerptVerified,
|
|
63
|
+
coverageMiss: result.coverageMiss,
|
|
64
|
+
anchoredSection: result.anchoredSection
|
|
63
65
|
}
|
|
64
66
|
};
|
|
65
67
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Env var naming the JSONL sink. Unset (or empty) ⇒ instrumentation is entirely off. */
|
|
2
|
+
export declare const TYPEONLY_LOG_ENV = "PI_TASK_TYPEONLY_LOG";
|
|
3
|
+
/** One pi-worker-docs answer, as observed at the tool layer. */
|
|
4
|
+
export interface TypeOnlyLogRecord {
|
|
5
|
+
/** ISO timestamp — lets records be attributed to a rep/phase window after the fact. */
|
|
6
|
+
at: string;
|
|
7
|
+
/** The `module` param: a package specifier, or "." for project source. */
|
|
8
|
+
module: string;
|
|
9
|
+
/** The query verbatim as the worker asked it (NOT the lowercased cache key). */
|
|
10
|
+
query: string;
|
|
11
|
+
/** The child's `<answer>` prose — retained so every counter can be re-scored offline. */
|
|
12
|
+
answer: string;
|
|
13
|
+
/** The shipped detector's verdict for (answer, query). */
|
|
14
|
+
typeOnly: boolean;
|
|
15
|
+
/** The detector's reason string — names which gate decided, for auditing precision. */
|
|
16
|
+
reason: string;
|
|
17
|
+
/** True when the answer is the explicit "unclear from this package" non-answer. */
|
|
18
|
+
unclear: boolean;
|
|
19
|
+
/** child-output's excerpt check; undefined when the child cited no excerpt. */
|
|
20
|
+
excerptVerified?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The tool's ENTIRE return text — version banner, npm header, the answer prose, the cited
|
|
23
|
+
* excerpt, and (when it fires) the type-only banner. Optional so logs written before this
|
|
24
|
+
* field existed still parse.
|
|
25
|
+
*
|
|
26
|
+
* WHY IT IS NOT REDUNDANT WITH `answer`. `answer` is the child's prose only. The worker
|
|
27
|
+
* receives strictly more than that, and the extra part is where the SYMBOL NAMES live: the
|
|
28
|
+
* cited `.d.ts` excerpt. Asking "did the worker write an APIS entry it had actually looked
|
|
29
|
+
* up, or one it produced from memory" is a substring question against what the worker was
|
|
30
|
+
* GIVEN, and answering it off `answer` alone would score a symbol that appeared verbatim in
|
|
31
|
+
* the retrieved declaration as ungrounded. That would inflate the very counter it is meant
|
|
32
|
+
* to measure. Recording the full text makes the grounding test conservative in the
|
|
33
|
+
* direction that cannot manufacture a finding.
|
|
34
|
+
*/
|
|
35
|
+
toolText?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Append one record to the JSONL sink named by `PI_TASK_TYPEONLY_LOG`, if set.
|
|
39
|
+
*
|
|
40
|
+
* @param rec everything but `at` and `unclear`, which are derived here so every call site
|
|
41
|
+
* stamps them identically.
|
|
42
|
+
*/
|
|
43
|
+
export declare function logDocsAnswer(rec: Omit<TypeOnlyLogRecord, 'at' | 'unclear'>, getEnv?: (k: string) => string | undefined): void;
|
|
44
|
+
/** Parse a sink written by {@link logDocsAnswer}; malformed lines are skipped, not thrown. */
|
|
45
|
+
export declare function readTypeOnlyLog(text: string): TypeOnlyLogRecord[];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STAGE 1 INSTRUMENTATION for F-2 / PROMPT 2 — firing-rate observability, no behaviour.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. PROMPT 2's live A/B measured 82% baseline vs 91% treatment, p = 0.88,
|
|
5
|
+
* and was written off as "the lever does not work". It was a broken EXPERIMENT, and it was
|
|
6
|
+
* broken for a reason this module fixes: the metric counted ALL research terminations while
|
|
7
|
+
* the lever touches only TYPE-ONLY answers, and nothing anywhere recorded how often a
|
|
8
|
+
* type-only answer actually occurs. The causal claim "workers stop BECAUSE of type-only
|
|
9
|
+
* answers" was asserted from one static corpus (1 flag in 150 recorded answers, 0.7%) and
|
|
10
|
+
* never measured live. You cannot size an arm, pick a metric, or decide whether the lever is
|
|
11
|
+
* a population fix or a single-case guard without that firing rate.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT DOES. When `PI_TASK_TYPEONLY_LOG` names a file, every pi-worker-docs answer — not
|
|
14
|
+
* only the flagged ones — appends one JSON line there. Denominator and numerator both, from
|
|
15
|
+
* the same channel, so a rate is computable rather than inferable. With the variable unset
|
|
16
|
+
* this is a no-op and nothing is written.
|
|
17
|
+
*
|
|
18
|
+
* MECHANICAL, NOT SELF-REPORT. The record is written at the TOOL layer from the verdict the
|
|
19
|
+
* shipped detector just returned, next to the same `details` the caller receives. No model is
|
|
20
|
+
* asked whether it thought the answer was a type signature.
|
|
21
|
+
*
|
|
22
|
+
* BEHAVIOUR-NEUTRALITY IS THE WHOLE POINT — this lands in a stage that forbids src/ behaviour
|
|
23
|
+
* change, so:
|
|
24
|
+
* - it is called for its side effect only; nothing reads its return value;
|
|
25
|
+
* - every failure is swallowed (a full disk or an unwritable path must not turn a working
|
|
26
|
+
* docs lookup into an error — instrumentation that can break the thing it measures is
|
|
27
|
+
* worse than no instrumentation);
|
|
28
|
+
* - it appends synchronously, because the process that writes it (a pi child running the
|
|
29
|
+
* docs extension) can exit immediately after the tool returns and a queued async write
|
|
30
|
+
* would be lost.
|
|
31
|
+
*
|
|
32
|
+
* THE FULL ANSWER TEXT IS RETAINED, deliberately. Run 15's audit could not decide F-3(f) —
|
|
33
|
+
* whether an `excerptVerified === false` was fabrication or a normaliser gap — because the
|
|
34
|
+
* text it judged was kept nowhere. The same hole would make every stability question here
|
|
35
|
+
* unanswerable: whether a question is type-only in EVERY rep or churns between identical
|
|
36
|
+
* reps can only be settled by re-scoring the recorded answers. Cheap to keep, impossible to
|
|
37
|
+
* reconstruct later.
|
|
38
|
+
*/
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
/** Env var naming the JSONL sink. Unset (or empty) ⇒ instrumentation is entirely off. */
|
|
41
|
+
export const TYPEONLY_LOG_ENV = 'PI_TASK_TYPEONLY_LOG';
|
|
42
|
+
/**
|
|
43
|
+
* The honest non-answer, in BOTH wordings the tool can emit. A package lookup is told to
|
|
44
|
+
* write "unclear from this package" (docs-core.ts:622); a project-source lookup is told
|
|
45
|
+
* "unclear from this project" (docs-project.ts:310). Matching only the first silently scored
|
|
46
|
+
* every project-source abstention as a valid answer — and project-source is the MAJORITY of
|
|
47
|
+
* what worker:apis asks (13 of 17 calls in run 15's fatal task), so that one missing word
|
|
48
|
+
* would have put the wrong denominator under the whole termination diagnostic.
|
|
49
|
+
*/
|
|
50
|
+
const UNCLEAR = /unclear from this (package|project)/i;
|
|
51
|
+
/**
|
|
52
|
+
* Append one record to the JSONL sink named by `PI_TASK_TYPEONLY_LOG`, if set.
|
|
53
|
+
*
|
|
54
|
+
* @param rec everything but `at` and `unclear`, which are derived here so every call site
|
|
55
|
+
* stamps them identically.
|
|
56
|
+
*/
|
|
57
|
+
export function logDocsAnswer(rec, getEnv = k => process.env[k]) {
|
|
58
|
+
const sink = getEnv(TYPEONLY_LOG_ENV);
|
|
59
|
+
if (!sink || sink.trim().length === 0)
|
|
60
|
+
return;
|
|
61
|
+
const full = {
|
|
62
|
+
at: new Date().toISOString(),
|
|
63
|
+
...rec,
|
|
64
|
+
unclear: UNCLEAR.test(rec.answer)
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
fs.appendFileSync(sink, `${JSON.stringify(full)}\n`, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Deliberately silent. This is a measurement side-channel; a docs lookup that
|
|
71
|
+
// succeeded must not be reported as failed because the sink was unwritable.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Parse a sink written by {@link logDocsAnswer}; malformed lines are skipped, not thrown. */
|
|
75
|
+
export function readTypeOnlyLog(text) {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const line of text.split('\n')) {
|
|
78
|
+
if (line.trim().length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
out.push(JSON.parse(line));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// A truncated final line is normal when a process is killed mid-append.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.50",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|