@mjasnikovs/pi-task 0.38.2 → 0.38.4
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 +7 -0
- package/dist/config/config.js +10 -4
- package/dist/config/register.d.ts +37 -0
- package/dist/config/register.js +89 -114
- package/dist/remote/events.js +0 -3
- package/dist/remote/register.js +12 -3
- package/dist/task/auto-orchestrator.js +119 -94
- package/dist/task/command-run.d.ts +104 -0
- package/dist/task/command-run.js +138 -0
- package/dist/task/coverage-loop.d.ts +45 -0
- package/dist/task/critique-probes.d.ts +82 -0
- package/dist/task/critique-probes.js +156 -0
- package/dist/task/deep-render-check.d.ts +30 -0
- package/dist/task/deep-render-check.js +19 -11
- package/dist/task/enforce-guidelines.d.ts +14 -17
- package/dist/task/enforce-guidelines.js +44 -31
- package/dist/task/final-gate.d.ts +8 -10
- package/dist/task/final-gate.js +36 -74
- package/dist/task/gate-child.d.ts +104 -0
- package/dist/task/gate-child.js +177 -0
- package/dist/task/gate-deps.d.ts +13 -0
- package/dist/task/gate-deps.js +72 -208
- package/dist/task/orchestrator.js +13 -22
- package/dist/task/phases.js +109 -182
- package/dist/task/plan-session.d.ts +4 -22
- package/dist/task/plan-session.js +4 -33
- package/dist/task/question-dialog.d.ts +71 -0
- package/dist/task/question-dialog.js +89 -0
- package/dist/task/terminal-outcome.d.ts +67 -0
- package/dist/task/terminal-outcome.js +76 -0
- package/dist/task/type-only-answer.js +2 -3
- package/dist/workers/abstention.d.ts +71 -0
- package/dist/workers/abstention.js +108 -0
- package/dist/workers/docs-chunk.d.ts +74 -0
- package/dist/workers/docs-chunk.js +143 -0
- package/dist/workers/docs-core.d.ts +10 -1
- package/dist/workers/docs-core.js +22 -19
- package/dist/workers/docs-index.js +2 -69
- package/dist/workers/docs-project.d.ts +15 -1
- package/dist/workers/docs-project.js +27 -66
- package/dist/workers/fetch-core.d.ts +1 -1
- package/dist/workers/fetch-core.js +2 -1
- package/dist/workers/pi-worker-core.js +157 -86
- package/dist/workers/pi-worker-docs.js +5 -10
- package/dist/workers/pi-worker-fetch.js +8 -1
- package/dist/workers/typeonly-log.js +2 -10
- package/dist/workers/worker-failure.d.ts +91 -0
- package/dist/workers/worker-failure.js +82 -0
- package/package.json +1 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* question-dialog — the one-question-at-a-time picker shared by the three places
|
|
3
|
+
* that ask the user to settle a fork: `/task`'s grill phase, `/task-auto`'s
|
|
4
|
+
* clarify loop, and the Plan session.
|
|
5
|
+
*
|
|
6
|
+
* All three do the same thing. Strip markdown for storage and render it for
|
|
7
|
+
* display; decide whether the question is a binary fork; short-circuit under
|
|
8
|
+
* YOLO; build `A: …` / `B: …` cards; call `ui.ask`; treat `undefined` as a
|
|
9
|
+
* cancel; and map the reply back onto an answer — where an empty submit accepts
|
|
10
|
+
* the recommendation, a bare "A"/"B" from a remote user (or the picker's
|
|
11
|
+
* free-text fallback) maps back to the option's full text, and anything else is
|
|
12
|
+
* taken verbatim.
|
|
13
|
+
*
|
|
14
|
+
* The mapping is the load-bearing part. Storing the literal letter "A" leaves the
|
|
15
|
+
* next generation call a dangling reference it cannot decode, so getting it wrong
|
|
16
|
+
* is not cosmetic.
|
|
17
|
+
*
|
|
18
|
+
* It was written three times. The Plan session factored its copy into a pure
|
|
19
|
+
* `resolveAnswer` returning a typed `AnswerSource`, and its own docstring said so
|
|
20
|
+
* out loud — "Mirrors the identical mapping in phaseGrill and planAuto" — but the
|
|
21
|
+
* two mirrors were never converted, and they had already drifted apart in three
|
|
22
|
+
* ways (which of them stamps an accepted recommendation, which has a
|
|
23
|
+
* single-option card branch, which handles a typed reply that equals an option).
|
|
24
|
+
* None was a crash. The next edit to any of them is where the bug lands, which is
|
|
25
|
+
* why they now share this.
|
|
26
|
+
*
|
|
27
|
+
* What stays at the call sites is POLICY, not mechanics: grill's auto-answer and
|
|
28
|
+
* widget line, clarify's plan-shape and triage pre-emption, plan's control
|
|
29
|
+
* actions. Those genuinely differ.
|
|
30
|
+
*/
|
|
31
|
+
/** True when the question is a binary fork rather than a single recommendation. */
|
|
32
|
+
export function isTwoOption(p) {
|
|
33
|
+
return p.suggested !== undefined && p.alt !== undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The answer cards: the recommendation first (index 0 is the green RECOMMENDED
|
|
37
|
+
* card), the alternative second when the question is a fork. `undefined` — not an
|
|
38
|
+
* empty array — when there is nothing to recommend, because that is what makes
|
|
39
|
+
* `ui.ask` fall back to a bare text prompt instead of an empty picker.
|
|
40
|
+
*/
|
|
41
|
+
export function buildOptionCards(p) {
|
|
42
|
+
if (isTwoOption(p)) {
|
|
43
|
+
return [
|
|
44
|
+
{ label: `A: ${p.shownSuggested ?? p.suggested}`, value: p.suggested },
|
|
45
|
+
{ label: `B: ${p.shownAlt ?? p.alt}`, value: p.alt }
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
if (p.suggested !== undefined) {
|
|
49
|
+
return [{ label: p.shownSuggested ?? p.suggested, value: p.suggested }];
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Map what the picker returned onto the answer that gets recorded, and say WHERE
|
|
55
|
+
* the answer came from.
|
|
56
|
+
*
|
|
57
|
+
* The `source` is returned rather than baked into the string because the three
|
|
58
|
+
* call sites disagree about provenance stamping, and that disagreement is real:
|
|
59
|
+
* `/task-auto`'s clarify transcript marks an accepted recommendation
|
|
60
|
+
* ("… (accepted recommendation)") while grill's does not, because grill's
|
|
61
|
+
* transcript is fed back verbatim into the next grill-gen prompt and the stamp
|
|
62
|
+
* would become model input. Keeping the stamp at the call site makes that a
|
|
63
|
+
* one-line difference you can see instead of a divergence hidden inside two
|
|
64
|
+
* six-branch ladders.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveAnswer(p, raw) {
|
|
67
|
+
const typed = raw.trim();
|
|
68
|
+
const twoOption = isTwoOption(p);
|
|
69
|
+
if (typed.length === 0 && p.suggested !== undefined) {
|
|
70
|
+
return { answer: p.suggested, source: 'accepted' };
|
|
71
|
+
}
|
|
72
|
+
if (typed.length === 0)
|
|
73
|
+
return { answer: '(skipped)', source: 'skipped' };
|
|
74
|
+
if (twoOption && /^a[.)]?$/i.test(typed))
|
|
75
|
+
return { answer: p.suggested, source: 'chosen' };
|
|
76
|
+
if (twoOption && /^b[.)]?$/i.test(typed))
|
|
77
|
+
return { answer: p.alt, source: 'chosen' };
|
|
78
|
+
if (p.suggested !== undefined && typed === p.suggested) {
|
|
79
|
+
// Accepting the single green card by pressing it has the same provenance
|
|
80
|
+
// as accepting it by submitting empty. On a FORK, picking one of two is a
|
|
81
|
+
// choice, not an acceptance.
|
|
82
|
+
return { answer: p.suggested, source: twoOption ? 'chosen' : 'accepted' };
|
|
83
|
+
}
|
|
84
|
+
if (p.alt !== undefined) {
|
|
85
|
+
if (typed === p.alt)
|
|
86
|
+
return { answer: p.alt, source: 'chosen' };
|
|
87
|
+
}
|
|
88
|
+
return { answer: typed, source: 'typed' };
|
|
89
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each way a gated task can END means for persistence and for what the user
|
|
3
|
+
* is told — stated once, for both `/task` and `/task-auto`.
|
|
4
|
+
*
|
|
5
|
+
* `runGatesForTask` is already shared verbatim by the two commands, so both
|
|
6
|
+
* consume the same five-kind outcome. What was NOT shared was the answer to the
|
|
7
|
+
* three questions each kind raises: demote the inner task file? fail the parent
|
|
8
|
+
* run file? what do we say, at what level? Those were two `switch`es 600 lines
|
|
9
|
+
* apart with line-for-line correspondence — down to the identical
|
|
10
|
+
* `reason.slice(0, 160)` truncation — differing only in the resume verb, whether
|
|
11
|
+
* a parent run file exists to fail, and whether the message names the step.
|
|
12
|
+
*
|
|
13
|
+
* That is not the shared "task-runner base" CONTEXT.md rules out, and for the
|
|
14
|
+
* same reason `runFinalGateStage` could leave `runAutoLoop`: this is the
|
|
15
|
+
* outcome → (persistence, message) mapping, and it touches none of either loop's
|
|
16
|
+
* state. Adding a sixth outcome kind is now a compile error in one place rather
|
|
17
|
+
* than a silently-unhandled fallthrough in whichever command was not updated.
|
|
18
|
+
*
|
|
19
|
+
* The PRE-gate outcomes (`res.sessionCancelled` / `res.interrupted` / `!res.ok`,
|
|
20
|
+
* before the gate runs) are deliberately NOT here. Their wording genuinely
|
|
21
|
+
* differs between the two commands — `/task` says "could not start a fresh
|
|
22
|
+
* session for /task" where `/task-auto` says "could not start a session. Run
|
|
23
|
+
* /task-auto-resume to retry" — and `/task-auto` has a cancel-requested branch
|
|
24
|
+
* `/task` has no equivalent for. Folding them in would mean a table of message
|
|
25
|
+
* overrides, which is not a simplification.
|
|
26
|
+
*/
|
|
27
|
+
/** The gate outcomes a command has to act on. Mirrors runGatesForTask's union. */
|
|
28
|
+
export type TerminalOutcomeKind = 'done' | 'paused' | 'session-cancelled' | 'interrupted' | 'failed';
|
|
29
|
+
/** What the message needs to name. */
|
|
30
|
+
export interface TerminalMessageContext {
|
|
31
|
+
/** The task or run id shown to the user (`TASK_0007`, `AUTO_0002`, `Task`). */
|
|
32
|
+
tag: string;
|
|
33
|
+
/**
|
|
34
|
+
* The step this happened at, ALREADY formatted with its leading space —
|
|
35
|
+
* ` at "Add auth routes"`. Empty for `/task`, which runs one task and has no
|
|
36
|
+
* step to name.
|
|
37
|
+
*/
|
|
38
|
+
at: string;
|
|
39
|
+
/** The failure cause, already truncated and formatted with its leading dash. */
|
|
40
|
+
why: string;
|
|
41
|
+
/** `/task-resume` or `/task-auto-resume`. */
|
|
42
|
+
resumeCmd: string;
|
|
43
|
+
}
|
|
44
|
+
export interface TerminalOutcome {
|
|
45
|
+
/**
|
|
46
|
+
* Demote the INNER task file to resumable. It reads `completed` from
|
|
47
|
+
* spec-handoff, and leaving it that way is how a failed run's task file
|
|
48
|
+
* claimed success in the run 6 audit.
|
|
49
|
+
*/
|
|
50
|
+
markResumable: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Mark the PARENT run file failed. Only `/task-auto` has one; `/task` ignores
|
|
53
|
+
* this, which is why it is a property of the OUTCOME rather than of the
|
|
54
|
+
* command — the outcome is equally fatal either way.
|
|
55
|
+
*/
|
|
56
|
+
failParent: boolean;
|
|
57
|
+
level: 'info' | 'warning' | 'error';
|
|
58
|
+
message: (c: TerminalMessageContext) => string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Truncate a failure reason for a one-line notification, or produce nothing.
|
|
62
|
+
* Both commands did this identically and inline.
|
|
63
|
+
*/
|
|
64
|
+
export declare function formatWhy(reason?: string): string;
|
|
65
|
+
/** The step suffix, or empty when the command has no step to name. */
|
|
66
|
+
export declare function formatAt(title?: string): string;
|
|
67
|
+
export declare const TERMINAL_OUTCOMES: Record<TerminalOutcomeKind, TerminalOutcome>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each way a gated task can END means for persistence and for what the user
|
|
3
|
+
* is told — stated once, for both `/task` and `/task-auto`.
|
|
4
|
+
*
|
|
5
|
+
* `runGatesForTask` is already shared verbatim by the two commands, so both
|
|
6
|
+
* consume the same five-kind outcome. What was NOT shared was the answer to the
|
|
7
|
+
* three questions each kind raises: demote the inner task file? fail the parent
|
|
8
|
+
* run file? what do we say, at what level? Those were two `switch`es 600 lines
|
|
9
|
+
* apart with line-for-line correspondence — down to the identical
|
|
10
|
+
* `reason.slice(0, 160)` truncation — differing only in the resume verb, whether
|
|
11
|
+
* a parent run file exists to fail, and whether the message names the step.
|
|
12
|
+
*
|
|
13
|
+
* That is not the shared "task-runner base" CONTEXT.md rules out, and for the
|
|
14
|
+
* same reason `runFinalGateStage` could leave `runAutoLoop`: this is the
|
|
15
|
+
* outcome → (persistence, message) mapping, and it touches none of either loop's
|
|
16
|
+
* state. Adding a sixth outcome kind is now a compile error in one place rather
|
|
17
|
+
* than a silently-unhandled fallthrough in whichever command was not updated.
|
|
18
|
+
*
|
|
19
|
+
* The PRE-gate outcomes (`res.sessionCancelled` / `res.interrupted` / `!res.ok`,
|
|
20
|
+
* before the gate runs) are deliberately NOT here. Their wording genuinely
|
|
21
|
+
* differs between the two commands — `/task` says "could not start a fresh
|
|
22
|
+
* session for /task" where `/task-auto` says "could not start a session. Run
|
|
23
|
+
* /task-auto-resume to retry" — and `/task-auto` has a cancel-requested branch
|
|
24
|
+
* `/task` has no equivalent for. Folding them in would mean a table of message
|
|
25
|
+
* overrides, which is not a simplification.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Truncate a failure reason for a one-line notification, or produce nothing.
|
|
29
|
+
* Both commands did this identically and inline.
|
|
30
|
+
*/
|
|
31
|
+
export function formatWhy(reason) {
|
|
32
|
+
return reason ? ` — ${reason.slice(0, 160)}` : '';
|
|
33
|
+
}
|
|
34
|
+
/** The step suffix, or empty when the command has no step to name. */
|
|
35
|
+
export function formatAt(title) {
|
|
36
|
+
return title === undefined ? '' : ` at "${title}"`;
|
|
37
|
+
}
|
|
38
|
+
export const TERMINAL_OUTCOMES = {
|
|
39
|
+
done: {
|
|
40
|
+
markResumable: false,
|
|
41
|
+
failParent: false,
|
|
42
|
+
level: 'info',
|
|
43
|
+
message: c => `${c.tag} complete — verified.`
|
|
44
|
+
},
|
|
45
|
+
paused: {
|
|
46
|
+
// The user was shown the verify-failure picker and dismissed it. Nothing
|
|
47
|
+
// is wrong with the tree; they simply have not decided yet.
|
|
48
|
+
markResumable: true,
|
|
49
|
+
failParent: true,
|
|
50
|
+
level: 'warning',
|
|
51
|
+
message: c => `${c.tag} paused${c.at} — verification failed and you dismissed the choice; `
|
|
52
|
+
+ `resume with ${c.resumeCmd}.`
|
|
53
|
+
},
|
|
54
|
+
'session-cancelled': {
|
|
55
|
+
// No session for the autofix child. Nothing ran, so nothing to demote.
|
|
56
|
+
markResumable: false,
|
|
57
|
+
failParent: false,
|
|
58
|
+
level: 'warning',
|
|
59
|
+
message: c => `${c.tag} paused — could not start a session for autofix. `
|
|
60
|
+
+ `Run ${c.resumeCmd} to retry.`
|
|
61
|
+
},
|
|
62
|
+
interrupted: {
|
|
63
|
+
markResumable: true,
|
|
64
|
+
// NOT a failure: the user stopped it. The parent run stays in_progress so
|
|
65
|
+
// a resume picks up where it left off.
|
|
66
|
+
failParent: false,
|
|
67
|
+
level: 'warning',
|
|
68
|
+
message: c => `${c.tag} paused${c.at} — resume with ${c.resumeCmd}.`
|
|
69
|
+
},
|
|
70
|
+
failed: {
|
|
71
|
+
markResumable: true,
|
|
72
|
+
failParent: true,
|
|
73
|
+
level: 'error',
|
|
74
|
+
message: c => `${c.tag} stopped${c.at}${c.why} — fix and run ${c.resumeCmd}.`
|
|
75
|
+
}
|
|
76
|
+
};
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
*
|
|
59
59
|
* Pure and side-effect free; unit-tested in type-only-answer.test.ts against real run-15 text.
|
|
60
60
|
*/
|
|
61
|
+
import { isAbstention } from '../workers/abstention.js';
|
|
61
62
|
/**
|
|
62
63
|
* Question seeks usage/semantics rather than a bare type. A usage concept whose MEANING is
|
|
63
64
|
* being asked about ("base url") counts, because the F-2 defect is exactly a semantics need
|
|
@@ -164,8 +165,6 @@ const SIGNATURE = [
|
|
|
164
165
|
/\bconstructor\s+accepts/i,
|
|
165
166
|
/declare\s+(const|function)/i
|
|
166
167
|
];
|
|
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
168
|
function firstMatch(text, patterns) {
|
|
170
169
|
for (const p of patterns) {
|
|
171
170
|
const m = p.exec(text);
|
|
@@ -213,7 +212,7 @@ export function isTypeOnlyAnswer(answer, question) {
|
|
|
213
212
|
if (a.length === 0) {
|
|
214
213
|
return { typeOnly: false, reason: 'empty answer' };
|
|
215
214
|
}
|
|
216
|
-
if (
|
|
215
|
+
if (isAbstention(a)) {
|
|
217
216
|
return {
|
|
218
217
|
typeOnly: false,
|
|
219
218
|
reason: 'explicit "unclear" non-answer — routed through the existing unclear/escalation path, not type-only'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ABSTENTION — the explicit non-answer a focused-extractor child writes when the
|
|
3
|
+
* content it was given cannot answer the question, and the one place both halves
|
|
4
|
+
* of that contract live.
|
|
5
|
+
*
|
|
6
|
+
* Why one module. The sentinel has two halves that MUST agree: the sentence a
|
|
7
|
+
* prompt instructs the child to emit, and the predicate that later recognises it.
|
|
8
|
+
* They used to be independent. Three prompts wrote three phrasings — two of them
|
|
9
|
+
* as bare string literals buried in a template — and four regexes matched
|
|
10
|
+
* different subsets of the three:
|
|
11
|
+
*
|
|
12
|
+
* pi-worker-docs /unclear from this package/i (package only)
|
|
13
|
+
* typeonly-log /unclear from this (package|project)/i (no page)
|
|
14
|
+
* type-only-answer /unclear from this (package|page)/i (no project)
|
|
15
|
+
* pi-worker-fetch no check at all
|
|
16
|
+
*
|
|
17
|
+
* That already cost one bug, recorded in typeonly-log: matching only the first
|
|
18
|
+
* silently scored every PROJECT abstention as a valid answer. The fix went into
|
|
19
|
+
* that one regex; the other three never learned.
|
|
20
|
+
*
|
|
21
|
+
* And it was still live on the fetch channel. pi-worker-docs documents F-2(e) at
|
|
22
|
+
* length — an "unclear" non-answer exits 0, so it was memoised into the research
|
|
23
|
+
* cache and re-served to every sibling task (52 of run 15's cached entries were
|
|
24
|
+
* "unclear" with hitCache true), one dead end paid for many times, with
|
|
25
|
+
* escalation unable to re-fire because the miss never recurred. `pi-worker-fetch`
|
|
26
|
+
* cached on `childExitCode === 0` alone, so "unclear from this page" reproduced
|
|
27
|
+
* exactly that failure on pages instead of packages.
|
|
28
|
+
*
|
|
29
|
+
* With emitter and matcher reading one table, a fourth corpus is one row and
|
|
30
|
+
* cannot be half-wired.
|
|
31
|
+
*/
|
|
32
|
+
/** The content a focused extractor was pointed at. One row per corpus. */
|
|
33
|
+
export type AbstentionKind = 'package' | 'project' | 'page';
|
|
34
|
+
/**
|
|
35
|
+
* The exact sentence to instruct a child to emit. Prompt builders must
|
|
36
|
+
* interpolate this rather than typing the words, or the matcher below stops
|
|
37
|
+
* describing what the prompts actually ask for.
|
|
38
|
+
*/
|
|
39
|
+
export declare function abstentionSentence(kind: AbstentionKind): string;
|
|
40
|
+
/**
|
|
41
|
+
* True when the child declined to answer rather than answering.
|
|
42
|
+
*
|
|
43
|
+
* Callers use this for two different decisions and both matter: it must not be
|
|
44
|
+
* SCORED as an answer (typeonly-log, type-only-answer), and it must not be
|
|
45
|
+
* CACHED as one (pi-worker-docs, pi-worker-fetch) — a memoised non-answer is
|
|
46
|
+
* re-served to every later sibling and permanently suppresses escalation.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isAbstention(text: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The extraction prompt every corpus shares.
|
|
51
|
+
*
|
|
52
|
+
* Rules 1–5 were word-for-word identical in the npm and project prompt builders,
|
|
53
|
+
* differing only in the four nouns this signature takes — and one of those, the
|
|
54
|
+
* abstention sentence in rule 4, was a bare literal that the matchers above then
|
|
55
|
+
* had to guess at. Building the prompt from the same table that recognises its
|
|
56
|
+
* output is the whole point: rule 4 tells the child to write EXACTLY the sentence
|
|
57
|
+
* `isAbstention` looks for, by construction.
|
|
58
|
+
*
|
|
59
|
+
* `subject` is the prose noun ("npm package", "local project's source code");
|
|
60
|
+
* `tag` names both the identity element and the content element, which must match
|
|
61
|
+
* because rules 1, 2 and 4 all refer to `<{tag}-content>`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function buildExtractionPrompt(opts: {
|
|
64
|
+
kind: AbstentionKind;
|
|
65
|
+
subject: string;
|
|
66
|
+
tag: string;
|
|
67
|
+
/** What goes inside `<{tag}>` — `hono@4.6.3`, or a project name. */
|
|
68
|
+
identity: string;
|
|
69
|
+
query: string;
|
|
70
|
+
content: string;
|
|
71
|
+
}): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ABSTENTION — the explicit non-answer a focused-extractor child writes when the
|
|
3
|
+
* content it was given cannot answer the question, and the one place both halves
|
|
4
|
+
* of that contract live.
|
|
5
|
+
*
|
|
6
|
+
* Why one module. The sentinel has two halves that MUST agree: the sentence a
|
|
7
|
+
* prompt instructs the child to emit, and the predicate that later recognises it.
|
|
8
|
+
* They used to be independent. Three prompts wrote three phrasings — two of them
|
|
9
|
+
* as bare string literals buried in a template — and four regexes matched
|
|
10
|
+
* different subsets of the three:
|
|
11
|
+
*
|
|
12
|
+
* pi-worker-docs /unclear from this package/i (package only)
|
|
13
|
+
* typeonly-log /unclear from this (package|project)/i (no page)
|
|
14
|
+
* type-only-answer /unclear from this (package|page)/i (no project)
|
|
15
|
+
* pi-worker-fetch no check at all
|
|
16
|
+
*
|
|
17
|
+
* That already cost one bug, recorded in typeonly-log: matching only the first
|
|
18
|
+
* silently scored every PROJECT abstention as a valid answer. The fix went into
|
|
19
|
+
* that one regex; the other three never learned.
|
|
20
|
+
*
|
|
21
|
+
* And it was still live on the fetch channel. pi-worker-docs documents F-2(e) at
|
|
22
|
+
* length — an "unclear" non-answer exits 0, so it was memoised into the research
|
|
23
|
+
* cache and re-served to every sibling task (52 of run 15's cached entries were
|
|
24
|
+
* "unclear" with hitCache true), one dead end paid for many times, with
|
|
25
|
+
* escalation unable to re-fire because the miss never recurred. `pi-worker-fetch`
|
|
26
|
+
* cached on `childExitCode === 0` alone, so "unclear from this page" reproduced
|
|
27
|
+
* exactly that failure on pages instead of packages.
|
|
28
|
+
*
|
|
29
|
+
* With emitter and matcher reading one table, a fourth corpus is one row and
|
|
30
|
+
* cannot be half-wired.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* The noun each corpus calls itself IN THE PROMPT. The sentinel is built from
|
|
34
|
+
* this, so the sentence a child is told to write and the sentence the host looks
|
|
35
|
+
* for cannot drift apart.
|
|
36
|
+
*/
|
|
37
|
+
const NOUNS = {
|
|
38
|
+
package: 'package',
|
|
39
|
+
project: 'project',
|
|
40
|
+
page: 'page'
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The exact sentence to instruct a child to emit. Prompt builders must
|
|
44
|
+
* interpolate this rather than typing the words, or the matcher below stops
|
|
45
|
+
* describing what the prompts actually ask for.
|
|
46
|
+
*/
|
|
47
|
+
export function abstentionSentence(kind) {
|
|
48
|
+
return `unclear from this ${NOUNS[kind]}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Matches any corpus's abstention. Built from the same table the prompts read, so
|
|
52
|
+
* adding a corpus cannot leave a matcher behind.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately a SUBSTRING match, unlike fetch-core's separate
|
|
55
|
+
* `not covered by this page` sentinel, which is anchored. The two differ because
|
|
56
|
+
* the instructions differ: the "not covered" rule asks for a partial answer that
|
|
57
|
+
* NAMES what is missing, so a sourced answer can legitimately contain the phrase
|
|
58
|
+
* and an anchored match is required to avoid filing it as a coverage miss. The
|
|
59
|
+
* abstention rule asks for the sentinel INSTEAD of an answer, and models wrap it
|
|
60
|
+
* ("Unclear from this package — the README does not mention it."), so anchoring
|
|
61
|
+
* here would miss the real non-answers this exists to catch.
|
|
62
|
+
*/
|
|
63
|
+
const ABSTENTION_RE = new RegExp(`unclear\\s+from\\s+this\\s+(${Object.values(NOUNS).join('|')})\\b`, 'i');
|
|
64
|
+
/**
|
|
65
|
+
* True when the child declined to answer rather than answering.
|
|
66
|
+
*
|
|
67
|
+
* Callers use this for two different decisions and both matter: it must not be
|
|
68
|
+
* SCORED as an answer (typeonly-log, type-only-answer), and it must not be
|
|
69
|
+
* CACHED as one (pi-worker-docs, pi-worker-fetch) — a memoised non-answer is
|
|
70
|
+
* re-served to every later sibling and permanently suppresses escalation.
|
|
71
|
+
*/
|
|
72
|
+
export function isAbstention(text) {
|
|
73
|
+
return ABSTENTION_RE.test(text);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The extraction prompt every corpus shares.
|
|
77
|
+
*
|
|
78
|
+
* Rules 1–5 were word-for-word identical in the npm and project prompt builders,
|
|
79
|
+
* differing only in the four nouns this signature takes — and one of those, the
|
|
80
|
+
* abstention sentence in rule 4, was a bare literal that the matchers above then
|
|
81
|
+
* had to guess at. Building the prompt from the same table that recognises its
|
|
82
|
+
* output is the whole point: rule 4 tells the child to write EXACTLY the sentence
|
|
83
|
+
* `isAbstention` looks for, by construction.
|
|
84
|
+
*
|
|
85
|
+
* `subject` is the prose noun ("npm package", "local project's source code");
|
|
86
|
+
* `tag` names both the identity element and the content element, which must match
|
|
87
|
+
* because rules 1, 2 and 4 all refer to `<{tag}-content>`.
|
|
88
|
+
*/
|
|
89
|
+
export function buildExtractionPrompt(opts) {
|
|
90
|
+
const { tag } = opts;
|
|
91
|
+
return (`You answer one question about ${opts.subject}, using only the provided content.\n`
|
|
92
|
+
+ `\n`
|
|
93
|
+
+ `Rules:\n`
|
|
94
|
+
+ `1. Output ONLY two tags, in this order, with NO text outside them:\n`
|
|
95
|
+
+ ` <answer>...your answer...</answer>\n`
|
|
96
|
+
+ ` <excerpt>...verbatim quote from <${tag}-content>...</excerpt>\n`
|
|
97
|
+
+ `2. The <excerpt> MUST be copied character-for-character from <${tag}-content>.\n`
|
|
98
|
+
+ ` Do not paraphrase, translate, or summarise inside <excerpt>.\n`
|
|
99
|
+
+ `3. Prefer type signatures, function declarations, and code blocks as evidence over prose.\n`
|
|
100
|
+
+ `4. If the answer is unclear, ambiguous, or absent from <${tag}-content>, write exactly:\n`
|
|
101
|
+
+ ` <answer>${abstentionSentence(opts.kind)}</answer> and put the closest related text in <excerpt>.\n`
|
|
102
|
+
+ ` Do not guess.\n`
|
|
103
|
+
+ `5. Be terse. One short paragraph in <answer> max.\n`
|
|
104
|
+
+ `\n`
|
|
105
|
+
+ `<${tag}>${opts.identity}</${tag}>\n`
|
|
106
|
+
+ `<question>${opts.query}</question>\n`
|
|
107
|
+
+ `<${tag}-content>\n${opts.content}\n</${tag}-content>\n`);
|
|
108
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docs-chunk — cutting source text into retrievable chunks, for every corpus the
|
|
3
|
+
* docs Worker tool indexes.
|
|
4
|
+
*
|
|
5
|
+
* There are two corpora — an npm package's `.d.ts` + README, and the local
|
|
6
|
+
* project's own `.ts`/`.tsx` — and they were chunked by two copies of this code.
|
|
7
|
+
* Not similar code: the same chunk-split regex, the same size cap, byte-identical
|
|
8
|
+
* `chunkDts`/`chunkTs` bodies, and byte-identical `splitAtMatches`/`sliceBytes`,
|
|
9
|
+
* in `docs-index.ts` and `docs-project.ts`. One copy had tests; the other
|
|
10
|
+
* (`docs-project.ts`, 319 lines) had no test file at all and was only ever
|
|
11
|
+
* reached incidentally through the worker's own suite.
|
|
12
|
+
*
|
|
13
|
+
* The chunk boundary is load-bearing for retrieval quality — a chunk that splits
|
|
14
|
+
* a declaration in half retrieves as neither — so having it in two places was two
|
|
15
|
+
* places for it to drift.
|
|
16
|
+
*
|
|
17
|
+
* What is NOT unified: the two INDEX bodies. They key on genuinely different
|
|
18
|
+
* provenance (a package is name+version with a content hash and keeps its old
|
|
19
|
+
* versions; the project is a cwd key with a max-mtime version and drops its old
|
|
20
|
+
* ones on every re-index), and collapsing them would change one of those
|
|
21
|
+
* behaviours rather than describe them.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Chunk ceiling. Chosen against the retrieval budget: chunks are assembled into
|
|
25
|
+
* a fixed character budget for the extraction child, so a chunk larger than this
|
|
26
|
+
* would crowd out every other result.
|
|
27
|
+
*/
|
|
28
|
+
export declare const MAX_CHUNK_BYTES: number;
|
|
29
|
+
/**
|
|
30
|
+
* Where a declaration starts. Splitting here keeps a signature and its body in
|
|
31
|
+
* one chunk, which is what makes a retrieved chunk quotable as evidence.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DECL_SPLIT_RE: RegExp;
|
|
34
|
+
/** Where a README section starts. */
|
|
35
|
+
export declare const README_SPLIT_RE: RegExp;
|
|
36
|
+
/**
|
|
37
|
+
* Split at every match, keeping the match with the text that FOLLOWS it — so a
|
|
38
|
+
* declaration keyword opens its chunk rather than closing the previous one.
|
|
39
|
+
*
|
|
40
|
+
* Never returns empty: a text with no match is one chunk, not zero.
|
|
41
|
+
*/
|
|
42
|
+
export declare function splitAtMatches(text: string, re: RegExp): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Cut a string into pieces of at most `maxBytes` UTF-8 bytes, never splitting a
|
|
45
|
+
* character.
|
|
46
|
+
*
|
|
47
|
+
* The cut point is walked BACK to a UTF-8 lead byte first. Both copies of this
|
|
48
|
+
* function used to cut at exactly `maxBytes` and rely on
|
|
49
|
+
* `Buffer.toString('utf8')` to tidy up, which it does not: decoding a buffer that
|
|
50
|
+
* ends mid-character yields a U+FFFD replacement character. That replacement is
|
|
51
|
+
* 3 bytes wide, so measuring the advance by the decoded slice's byte length then
|
|
52
|
+
* skipped PAST the straddling character. A `€`-dense chunk came out as
|
|
53
|
+
* `€€€�€€€�…` — corrupted, and one character shorter per slice.
|
|
54
|
+
*
|
|
55
|
+
* It matters beyond looking wrong: a chunk is quoted back as an `<excerpt>` and
|
|
56
|
+
* checked verbatim against the source, and an excerpt carrying a replacement
|
|
57
|
+
* character can never be found, so the answer is flagged as a possible
|
|
58
|
+
* hallucination. Only reachable on non-ASCII text past the 8 KB chunk ceiling,
|
|
59
|
+
* which is why two copies of it survived untested.
|
|
60
|
+
*/
|
|
61
|
+
export declare function sliceBytes(s: string, maxBytes: number): string[];
|
|
62
|
+
/**
|
|
63
|
+
* Chunk a declaration file (`.d.ts`, `.ts`, `.tsx`), one chunk per declaration,
|
|
64
|
+
* each labelled with the file it came from.
|
|
65
|
+
*
|
|
66
|
+
* `relPath` is a MODEL-FACING label and is used exactly as given — the npm path
|
|
67
|
+
* normalises it to POSIX first so an index is identical across platforms, while
|
|
68
|
+
* the project path keeps the native separator. It is never re-joined to the
|
|
69
|
+
* filesystem, so neither choice is wrong; passing it through keeps that decision
|
|
70
|
+
* with the caller that has a reason for it.
|
|
71
|
+
*/
|
|
72
|
+
export declare function chunkDeclarations(content: string, relPath: string): string[];
|
|
73
|
+
/** Chunk a README, one chunk per top-level section, each labelled by heading. */
|
|
74
|
+
export declare function chunkReadme(content: string): string[];
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docs-chunk — cutting source text into retrievable chunks, for every corpus the
|
|
3
|
+
* docs Worker tool indexes.
|
|
4
|
+
*
|
|
5
|
+
* There are two corpora — an npm package's `.d.ts` + README, and the local
|
|
6
|
+
* project's own `.ts`/`.tsx` — and they were chunked by two copies of this code.
|
|
7
|
+
* Not similar code: the same chunk-split regex, the same size cap, byte-identical
|
|
8
|
+
* `chunkDts`/`chunkTs` bodies, and byte-identical `splitAtMatches`/`sliceBytes`,
|
|
9
|
+
* in `docs-index.ts` and `docs-project.ts`. One copy had tests; the other
|
|
10
|
+
* (`docs-project.ts`, 319 lines) had no test file at all and was only ever
|
|
11
|
+
* reached incidentally through the worker's own suite.
|
|
12
|
+
*
|
|
13
|
+
* The chunk boundary is load-bearing for retrieval quality — a chunk that splits
|
|
14
|
+
* a declaration in half retrieves as neither — so having it in two places was two
|
|
15
|
+
* places for it to drift.
|
|
16
|
+
*
|
|
17
|
+
* What is NOT unified: the two INDEX bodies. They key on genuinely different
|
|
18
|
+
* provenance (a package is name+version with a content hash and keeps its old
|
|
19
|
+
* versions; the project is a cwd key with a max-mtime version and drops its old
|
|
20
|
+
* ones on every re-index), and collapsing them would change one of those
|
|
21
|
+
* behaviours rather than describe them.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Chunk ceiling. Chosen against the retrieval budget: chunks are assembled into
|
|
25
|
+
* a fixed character budget for the extraction child, so a chunk larger than this
|
|
26
|
+
* would crowd out every other result.
|
|
27
|
+
*/
|
|
28
|
+
export const MAX_CHUNK_BYTES = 8 * 1024;
|
|
29
|
+
/**
|
|
30
|
+
* Where a declaration starts. Splitting here keeps a signature and its body in
|
|
31
|
+
* one chunk, which is what makes a retrieved chunk quotable as evidence.
|
|
32
|
+
*/
|
|
33
|
+
export const DECL_SPLIT_RE = /^(?:export\s+|declare\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|namespace|module|const|let|var|enum)\s+/m;
|
|
34
|
+
/** Where a README section starts. */
|
|
35
|
+
export const README_SPLIT_RE = /^#{1,2} /m;
|
|
36
|
+
/**
|
|
37
|
+
* Split at every match, keeping the match with the text that FOLLOWS it — so a
|
|
38
|
+
* declaration keyword opens its chunk rather than closing the previous one.
|
|
39
|
+
*
|
|
40
|
+
* Never returns empty: a text with no match is one chunk, not zero.
|
|
41
|
+
*/
|
|
42
|
+
export function splitAtMatches(text, re) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
let lastIndex = 0;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = re.exec(text))) {
|
|
47
|
+
if (m.index > lastIndex)
|
|
48
|
+
parts.push(text.slice(lastIndex, m.index));
|
|
49
|
+
lastIndex = m.index;
|
|
50
|
+
// Advance by ONE, not by the match length: the next declaration can begin
|
|
51
|
+
// inside what this match consumed (`export default async function`).
|
|
52
|
+
re.lastIndex = m.index + 1;
|
|
53
|
+
}
|
|
54
|
+
if (lastIndex < text.length)
|
|
55
|
+
parts.push(text.slice(lastIndex));
|
|
56
|
+
return parts.length ? parts : [text];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Cut a string into pieces of at most `maxBytes` UTF-8 bytes, never splitting a
|
|
60
|
+
* character.
|
|
61
|
+
*
|
|
62
|
+
* The cut point is walked BACK to a UTF-8 lead byte first. Both copies of this
|
|
63
|
+
* function used to cut at exactly `maxBytes` and rely on
|
|
64
|
+
* `Buffer.toString('utf8')` to tidy up, which it does not: decoding a buffer that
|
|
65
|
+
* ends mid-character yields a U+FFFD replacement character. That replacement is
|
|
66
|
+
* 3 bytes wide, so measuring the advance by the decoded slice's byte length then
|
|
67
|
+
* skipped PAST the straddling character. A `€`-dense chunk came out as
|
|
68
|
+
* `€€€�€€€�…` — corrupted, and one character shorter per slice.
|
|
69
|
+
*
|
|
70
|
+
* It matters beyond looking wrong: a chunk is quoted back as an `<excerpt>` and
|
|
71
|
+
* checked verbatim against the source, and an excerpt carrying a replacement
|
|
72
|
+
* character can never be found, so the answer is flagged as a possible
|
|
73
|
+
* hallucination. Only reachable on non-ASCII text past the 8 KB chunk ceiling,
|
|
74
|
+
* which is why two copies of it survived untested.
|
|
75
|
+
*/
|
|
76
|
+
export function sliceBytes(s, maxBytes) {
|
|
77
|
+
const out = [];
|
|
78
|
+
let buf = Buffer.from(s, 'utf8');
|
|
79
|
+
while (buf.length > maxBytes) {
|
|
80
|
+
// Continuation bytes are 0b10xxxxxx. Back up while the byte we are about
|
|
81
|
+
// to cut before is one, so the cut lands on a character boundary.
|
|
82
|
+
let end = maxBytes;
|
|
83
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80)
|
|
84
|
+
end--;
|
|
85
|
+
// A single character wider than the whole cap cannot be placed. Cut
|
|
86
|
+
// anyway rather than loop forever; unreachable for any cap >= 4.
|
|
87
|
+
if (end === 0)
|
|
88
|
+
end = maxBytes;
|
|
89
|
+
out.push(buf.subarray(0, end).toString('utf8'));
|
|
90
|
+
buf = buf.subarray(end);
|
|
91
|
+
}
|
|
92
|
+
if (buf.length)
|
|
93
|
+
out.push(buf.toString('utf8'));
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Chunk a declaration file (`.d.ts`, `.ts`, `.tsx`), one chunk per declaration,
|
|
98
|
+
* each labelled with the file it came from.
|
|
99
|
+
*
|
|
100
|
+
* `relPath` is a MODEL-FACING label and is used exactly as given — the npm path
|
|
101
|
+
* normalises it to POSIX first so an index is identical across platforms, while
|
|
102
|
+
* the project path keeps the native separator. It is never re-joined to the
|
|
103
|
+
* filesystem, so neither choice is wrong; passing it through keeps that decision
|
|
104
|
+
* with the caller that has a reason for it.
|
|
105
|
+
*/
|
|
106
|
+
export function chunkDeclarations(content, relPath) {
|
|
107
|
+
const chunks = [];
|
|
108
|
+
for (const part of splitAtMatches(content, new RegExp(DECL_SPLIT_RE.source, 'gm'))) {
|
|
109
|
+
const trimmed = part.trim();
|
|
110
|
+
if (!trimmed)
|
|
111
|
+
continue;
|
|
112
|
+
const prefixed = `// ${relPath}\n${trimmed}`;
|
|
113
|
+
if (Buffer.byteLength(prefixed, 'utf8') > MAX_CHUNK_BYTES) {
|
|
114
|
+
for (const slice of sliceBytes(prefixed, MAX_CHUNK_BYTES))
|
|
115
|
+
chunks.push(slice);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
chunks.push(prefixed);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return chunks;
|
|
122
|
+
}
|
|
123
|
+
/** Chunk a README, one chunk per top-level section, each labelled by heading. */
|
|
124
|
+
export function chunkReadme(content) {
|
|
125
|
+
const chunks = [];
|
|
126
|
+
for (const part of splitAtMatches(content, new RegExp(README_SPLIT_RE.source, 'gm'))) {
|
|
127
|
+
// Trailing whitespace only — leading blank lines carry the section break.
|
|
128
|
+
const trimmed = part.replace(/\s+$/, '');
|
|
129
|
+
if (!trimmed)
|
|
130
|
+
continue;
|
|
131
|
+
const headingMatch = /^(#{1,2}) (.+)$/m.exec(trimmed);
|
|
132
|
+
const heading = headingMatch ? headingMatch[2] : '(intro)';
|
|
133
|
+
const prefixed = `<!-- README: ${heading} -->\n${trimmed}`;
|
|
134
|
+
if (Buffer.byteLength(prefixed, 'utf8') > MAX_CHUNK_BYTES) {
|
|
135
|
+
for (const slice of sliceBytes(prefixed, MAX_CHUNK_BYTES))
|
|
136
|
+
chunks.push(slice);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
chunks.push(prefixed);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return chunks;
|
|
143
|
+
}
|