@mjasnikovs/pi-task 0.37.2 → 0.37.3

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.
@@ -4,6 +4,7 @@ import { type ExcerptVerification } from '../shared/child-output.js';
4
4
  /** The exact non-answers the child is instructed to emit, matched at the tool layer. */
5
5
  export declare const UNCLEAR_ANSWER = "unclear from this page";
6
6
  export declare const NOT_COVERED_ANSWER = "not covered by this page";
7
+ export declare function normaliseSourceUrl(url: string): string;
7
8
  export interface FetchRawInput {
8
9
  url: string;
9
10
  signal?: AbortSignal;
@@ -51,6 +52,15 @@ export interface FetchFocusedResult {
51
52
  * "page has no answer" and "answer is ambiguous" as the same thing (PROMPT-3 item 3).
52
53
  */
53
54
  coverageMiss: boolean;
55
+ /**
56
+ * What to do INSTEAD, present only on a coverage miss. `coverageMiss` was computed and
57
+ * stored and read nowhere, so the distinct channel it exists to provide never reached the
58
+ * worker: all it ever saw was the bare sentence "not covered by this page", which reads
59
+ * as "ask again, differently". It did — 9 of the 84 corpus fetches re-read a URL that had
60
+ * already returned a non-answer, one release-notes page three times with near-identical
61
+ * questions.
62
+ */
63
+ nextStep?: string;
54
64
  /** The #fragment slug that was anchored, when the URL carried one and it was located. */
55
65
  anchoredSection?: string;
56
66
  /** Retained evidence for a false `excerptVerified`, so it is diagnosable without re-fetch. */
@@ -63,6 +73,16 @@ export interface FetchFocusedResult {
63
73
  stdout: string;
64
74
  }
65
75
  export declare function fetchFocused(input: FetchFocusedInput): Promise<FetchFocusedResult>;
76
+ /**
77
+ * The instruction that goes with a coverage miss. It says two things the bare sentinel does
78
+ * not: WHICH page was actually read (after the blob rewrite these differ, and a worker that
79
+ * cannot see that would "retry" the raw URL it already got), and that re-reading this page
80
+ * with a reworded question returns the same answer — so the next move is a different URL.
81
+ *
82
+ * Deliberately not a suggestion of WHICH other URL. Nothing at this layer knows one, and
83
+ * naming a guess is how a dead end becomes two dead ends.
84
+ */
85
+ export declare function coverageMissNextStep(requestedUrl: string, fetchedUrl: string): string;
66
86
  export interface SelectedContent {
67
87
  content: string;
68
88
  /** The #fragment slug that was anchored, if the URL carried one AND it was located. */
@@ -11,18 +11,58 @@ const TRUNCATION_MARKER = '\n\n[...page continues, truncated...]\n\n';
11
11
  /** The exact non-answers the child is instructed to emit, matched at the tool layer. */
12
12
  export const UNCLEAR_ANSWER = 'unclear from this page';
13
13
  export const NOT_COVERED_ANSWER = 'not covered by this page';
14
- const NOT_COVERED_RE = /not covered by this page/i;
14
+ /**
15
+ * Rule 6 asks the child to write the sentinel and NOTHING else, so the sentinel is the whole
16
+ * answer — anchored, not a substring search. A substring search misreads the opposite case:
17
+ * rule 5 tells the child to answer partially and say what is missing, and it says it in the
18
+ * prompt's own words ("… `obs_add_raw_audio_callback` and `obs_remove_raw_audio_callback`
19
+ * are not covered by this page"). That is a sourced answer, and the loose match filed it as
20
+ * a coverage miss. Observed twice in 5 reps of scripts/fetch-url-normalise-ab.ts once the
21
+ * rewrite started delivering pages that could half-answer; never in the 84 recorded corpus
22
+ * fetches, where 11 of 11 sentinel answers are the bare sentinel — so tightening it changes
23
+ * no recorded verdict.
24
+ */
25
+ const NOT_COVERED_RE = /^not covered by this page[.\s]*$/i;
15
26
  const childArgs = () => [...childBaseArgs(), '--no-tools'];
27
+ /**
28
+ * `github.com/{owner}/{repo}/blob/{ref}/{path}` renders the file through a client-side
29
+ * viewer, so the HTML we clean carries GitHub chrome ("Sign in", "Appearance settings")
30
+ * and none of the file. Every one of the 8 blob URLs in the three-project research-cache
31
+ * corpus came back a non-answer for that reason. `raw.githubusercontent.com` serves the
32
+ * same bytes as text/plain.
33
+ *
34
+ * Only the URL handed to the fetcher is rewritten — the caller's URL still keys the cache
35
+ * and still supplies the #fragment — so a worker that retries the raw URL by hand hits the
36
+ * cache, and a run's own recorded URLs stay what the run asked for.
37
+ *
38
+ * The query string is dropped: every blob query param (`?plain=1`, `?w=1`, …) is a viewer
39
+ * setting with no meaning on raw. The #fragment is dropped from the request too (it never
40
+ * reaches a server) but is preserved for {@link selectContent} via the original URL.
41
+ */
42
+ const GH_BLOB_RE = /^https?:\/\/(?:www\.)?github\.com\/([^/?#]+)\/([^/?#]+)\/blob\/([^?#]+?)\/*(?:[?#].*)?$/i;
43
+ export function normaliseSourceUrl(url) {
44
+ const m = GH_BLOB_RE.exec(url.trim());
45
+ if (!m)
46
+ return url;
47
+ const [, owner, repo, refAndPath] = m;
48
+ // `/blob/{ref}/{path}` — a bare `/blob/{ref}` with no path names no file.
49
+ if (!refAndPath.includes('/'))
50
+ return url;
51
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${refAndPath}`;
52
+ }
16
53
  export async function fetchRaw(input) {
17
54
  const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
18
- const cleaned = await fetchAndCleanFn(input.url, { signal: input.signal });
55
+ const cleaned = await fetchAndCleanFn(normaliseSourceUrl(input.url), {
56
+ signal: input.signal
57
+ });
19
58
  return { markdown: cleaned.markdown, finalUrl: cleaned.finalUrl, title: cleaned.title };
20
59
  }
21
60
  export async function fetchFocused(input) {
22
61
  const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
23
62
  const spawnFn = input.spawn ?? defaultSpawn;
24
63
  const strategy = input.strategy ?? shippedStrategy;
25
- const cleaned = await fetchAndCleanFn(input.url, { signal: input.signal });
64
+ const fetchedUrl = normaliseSourceUrl(input.url);
65
+ const cleaned = await fetchAndCleanFn(fetchedUrl, { signal: input.signal });
26
66
  // The #fragment is a client-side concern the server never sees, so anchor from the
27
67
  // ORIGINALLY REQUESTED url, not the post-redirect finalUrl (which will have dropped it).
28
68
  const selected = strategy.selectContent(cleaned.markdown, input.url);
@@ -60,6 +100,7 @@ export async function fetchFocused(input) {
60
100
  };
61
101
  }
62
102
  const parsed = parseChildOutput(childResult.stdout);
103
+ const coverageMiss = NOT_COVERED_RE.test(parsed.answer.trim());
63
104
  // Verify against the FULL page, not the anchored slice: the slice is a substring of it,
64
105
  // so a genuine excerpt still verifies, and an excerpt the child pulled from memory still
65
106
  // fails — the detector's discrimination is unchanged by fragment anchoring.
@@ -69,12 +110,32 @@ export async function fetchFocused(input) {
69
110
  excerpt: parsed.excerpt,
70
111
  excerptVerified: check?.verified,
71
112
  excerptCheck: check,
72
- coverageMiss: NOT_COVERED_RE.test(parsed.answer),
113
+ coverageMiss,
114
+ nextStep: coverageMiss ? coverageMissNextStep(input.url, fetchedUrl) : undefined,
73
115
  childExitCode: 0,
74
116
  aborted: false,
75
117
  ...base
76
118
  };
77
119
  }
120
+ /**
121
+ * The instruction that goes with a coverage miss. It says two things the bare sentinel does
122
+ * not: WHICH page was actually read (after the blob rewrite these differ, and a worker that
123
+ * cannot see that would "retry" the raw URL it already got), and that re-reading this page
124
+ * with a reworded question returns the same answer — so the next move is a different URL.
125
+ *
126
+ * Deliberately not a suggestion of WHICH other URL. Nothing at this layer knows one, and
127
+ * naming a guess is how a dead end becomes two dead ends.
128
+ */
129
+ export function coverageMissNextStep(requestedUrl, fetchedUrl) {
130
+ const rewritten = fetchedUrl !== requestedUrl ?
131
+ ` ${requestedUrl} is a GitHub file viewer whose HTML does not carry the file, so the`
132
+ + ` file itself was already read from ${fetchedUrl} — fetching that raw URL by hand`
133
+ + ` returns exactly this.`
134
+ : '';
135
+ return (`NEXT STEP: this page does not contain the answer.${rewritten}`
136
+ + ` Asking ${fetchedUrl} the same question a different way returns this same result —`
137
+ + ` do not re-read it. Try a different URL, or search for one.`);
138
+ }
78
139
  /** Read the #fragment from a URL. Empty string when there is none. */
79
140
  function fragmentOf(url) {
80
141
  const h = url.indexOf('#');
@@ -52,7 +52,11 @@ export function registerPiWorkerFetch(pi, internals = {}) {
52
52
  if (failure !== null) {
53
53
  return { text: failure, details: { childExitCode: result.childExitCode } };
54
54
  }
55
- const text = formatResultText({ answer: result.answer, excerpt: result.excerpt }, result.excerptVerified) || '(no output)';
55
+ const body = formatResultText({ answer: result.answer, excerpt: result.excerpt }, result.excerptVerified) || '(no output)';
56
+ // The coverage miss is the one outcome that carries an instruction. It goes
57
+ // in the TEXT, not only in details: details are for the harness, and the
58
+ // worker acts on what it reads.
59
+ const text = result.nextStep ? `${body}\n\n${result.nextStep}` : body;
56
60
  return {
57
61
  text,
58
62
  details: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.37.2",
3
+ "version": "0.37.3",
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",