@mjasnikovs/pi-task 0.40.14 → 0.40.26

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.
@@ -39,15 +39,27 @@ export function abstentionSentence(kind) {
39
39
  * Matches any corpus's abstention. Built from the same table the prompts read, so
40
40
  * adding a corpus cannot leave a matcher behind.
41
41
  *
42
- * A SUBSTRING match, unlike fetch-core's separate `not covered by this page`
43
- * sentinel, which is anchored. The instructions differ, so the matchers must. The
44
- * "not covered" rule asks for a partial answer that NAMES what is missing, so a
45
- * sourced answer legitimately contains the phrase and only an anchored match keeps
46
- * it from being filed as a coverage miss. Rule 4 below asks for this sentinel
47
- * INSTEAD of an answer, so nothing sourced can contain it, and a substring match
48
- * still catches a child that wraps the sentence in an explanation.
42
+ * ANCHORED, and it was a substring match until it was measured. The docstring here
43
+ * used to say "rule 4 asks for this sentinel INSTEAD of an answer, so nothing
44
+ * sourced can contain it". That was true when it was written and defect 15's fix
45
+ * made it false: rule 4 now ends with "answer the parts <content> covers, and name
46
+ * the parts it does not", and a child naming the missing part reaches for the
47
+ * phrase the same prompt just taught it.
48
+ *
49
+ * Six of 73 flagged abstentions across seven runs were substantial answers that
50
+ * named a gap at the end — full signatures for `oneshot`, `TcpListener::bind`,
51
+ * `eitherDecode`, a whole zod schema — every one scored as a non-answer. Rule 4
52
+ * asks for the sentence ALONE, so leading with it is the abstention and trailing it
53
+ * behind real content is the partial answer defect 15 exists to produce.
54
+ *
55
+ * The leading `<answer>` tag is admitted because some consumers see the child's raw
56
+ * output rather than the parsed body.
57
+ *
58
+ * The cost, stated: a child that writes prose first and only then declines outright
59
+ * now scores as an answer. Across 243 recorded answers that shape does not occur —
60
+ * all 67 real abstentions lead with the sentence.
49
61
  */
50
- const ABSTENTION_RE = new RegExp(`unclear\\s+from\\s+this\\s+(${Object.values(NOUNS).join('|')})\\b`, 'i');
62
+ const ABSTENTION_RE = new RegExp(`^(?:\\s*<answer>)?[\\s"'\`\\-*]*unclear\\s+from\\s+this\\s+(${Object.values(NOUNS).join('|')})\\b`, 'i');
51
63
  /** True when the child declined to answer rather than answering. See the header
52
64
  * for the two decisions this drives. */
53
65
  export function isAbstention(text) {
@@ -34,6 +34,19 @@ export declare const MAX_CHUNK_BYTES: number;
34
34
  * defines `fetch`.
35
35
  */
36
36
  export declare const DECL_SPLIT_RE: RegExp;
37
+ /**
38
+ * Where a member of an oversized declaration begins — the same heads, indented.
39
+ *
40
+ * `declare module "bun" { … }` is ONE top-level declaration holding a whole module,
41
+ * so `DECL_SPLIT_RE` matches once and everything after it was cut at byte offsets.
42
+ * That shape is rare and enormous: 3.8% of indexed chunks sat at the cap and held
43
+ * 51.1% of all indexed bytes, 86.8% of `@types/node`'s and 78.1% of `bun-types`'.
44
+ *
45
+ * Only reached when a declaration does not fit. A member split applied to every
46
+ * declaration would cut an interface away from its own members, which is the thing
47
+ * `DECL_SPLIT_RE` exists to prevent.
48
+ */
49
+ export declare const MEMBER_SPLIT_RE: RegExp;
37
50
  /** Where a README section starts. */
38
51
  export declare const README_SPLIT_RE: RegExp;
39
52
  /**
@@ -60,6 +73,18 @@ export declare function splitAtMatches(text: string, re: RegExp): string[];
60
73
  * possible hallucination. Only reachable on non-ASCII text past the chunk ceiling.
61
74
  */
62
75
  export declare function sliceBytes(s: string, maxBytes: number): string[];
76
+ /**
77
+ * Slice a body that will not fit, giving every piece the same header.
78
+ *
79
+ * The header was applied once and then sliced, so only the first piece said where
80
+ * it came from: 487 of 12,815 indexed chunks had no provenance line, 53% of
81
+ * `@types/node`'s and 22% of `bun-types`'. A `node:url` query for `fileURLToPath`
82
+ * came back as two 8,192-byte pieces that began mid-sentence inside a doc comment
83
+ * and named no file.
84
+ *
85
+ * The cap counts the header, so the body budget is what is left after it.
86
+ */
87
+ export declare function headedSlices(header: string, body: string, maxBytes: number): string[];
63
88
  /**
64
89
  * Chunk a declaration file, one chunk per declaration, each labelled with the
65
90
  * file it came from.
@@ -73,6 +98,19 @@ export declare function sliceBytes(s: string, maxBytes: number): string[];
73
98
  * `splitRe` and `commentPrefix` default to the TypeScript pair, which is what
74
99
  * both the project corpus and npm packages are written in.
75
100
  */
76
- export declare function chunkDeclarations(content: string, relPath: string, splitRe?: RegExp, commentPrefix?: string): string[];
101
+ export declare function chunkDeclarations(content: string, relPath: string, splitRe?: RegExp, commentPrefix?: string, memberRe?: RegExp): string[];
102
+ /**
103
+ * Cut a declaration that does not fit at its own member boundaries, keeping the
104
+ * line that says what it is a member OF.
105
+ *
106
+ * Without that line a piece of `declare module "bun"` is an anonymous list of
107
+ * functions: a `Bun.file` question came back carrying slices about S3 ETags and
108
+ * tar archives, because bm25 was matching words in the middles of byte cuts that
109
+ * shared no subject.
110
+ *
111
+ * Byte slicing stays as the floor. A single member wider than the cap — one
112
+ * function with a 20 KB doc comment — still has to be cut somewhere.
113
+ */
114
+ export declare function splitOversized(header: string, body: string, memberRe: RegExp): string[];
77
115
  /** Chunk a README, one chunk per top-level section, each labelled by heading. */
78
116
  export declare function chunkReadme(content: string): string[];
@@ -34,6 +34,19 @@ export const MAX_CHUNK_BYTES = 8 * 1024;
34
34
  * defines `fetch`.
35
35
  */
36
36
  export const DECL_SPLIT_RE = /^(?:export\s+)?(?:declare\s+)?(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(?:function|class|interface|type|namespace|module|const|let|var|enum)\s+/m;
37
+ /**
38
+ * Where a member of an oversized declaration begins — the same heads, indented.
39
+ *
40
+ * `declare module "bun" { … }` is ONE top-level declaration holding a whole module,
41
+ * so `DECL_SPLIT_RE` matches once and everything after it was cut at byte offsets.
42
+ * That shape is rare and enormous: 3.8% of indexed chunks sat at the cap and held
43
+ * 51.1% of all indexed bytes, 86.8% of `@types/node`'s and 78.1% of `bun-types`'.
44
+ *
45
+ * Only reached when a declaration does not fit. A member split applied to every
46
+ * declaration would cut an interface away from its own members, which is the thing
47
+ * `DECL_SPLIT_RE` exists to prevent.
48
+ */
49
+ export const MEMBER_SPLIT_RE = /^[ \t]+(?:export\s+)?(?:declare\s+)?(?:default\s+)?(?:abstract\s+)?(?:async\s+)?(?:function|class|interface|type|namespace|module|const|let|var|enum)\s+/m;
37
50
  /** Where a README section starts. */
38
51
  export const README_SPLIT_RE = /^#{1,2} /m;
39
52
  /**
@@ -106,6 +119,29 @@ export function sliceBytes(s, maxBytes) {
106
119
  out.push(buf.toString('utf8'));
107
120
  return out;
108
121
  }
122
+ /**
123
+ * Slice a body that will not fit, giving every piece the same header.
124
+ *
125
+ * The header was applied once and then sliced, so only the first piece said where
126
+ * it came from: 487 of 12,815 indexed chunks had no provenance line, 53% of
127
+ * `@types/node`'s and 22% of `bun-types`'. A `node:url` query for `fileURLToPath`
128
+ * came back as two 8,192-byte pieces that began mid-sentence inside a doc comment
129
+ * and named no file.
130
+ *
131
+ * The cap counts the header, so the body budget is what is left after it.
132
+ */
133
+ export function headedSlices(header, body, maxBytes) {
134
+ const prefixed = `${header}\n${body}`;
135
+ if (Buffer.byteLength(prefixed, 'utf8') <= maxBytes)
136
+ return [prefixed];
137
+ const room = maxBytes - Buffer.byteLength(`${header}\n`, 'utf8');
138
+ // A header that fills the cap on its own leaves no room, and `sliceBytes` with a
139
+ // non-positive cap never shrinks its buffer. Slice the prefixed string instead:
140
+ // the first piece still names the source, which is all the header buys.
141
+ if (room <= 0)
142
+ return sliceBytes(prefixed, maxBytes);
143
+ return sliceBytes(body, room).map(slice => `${header}\n${slice}`);
144
+ }
109
145
  /**
110
146
  * Chunk a declaration file, one chunk per declaration, each labelled with the
111
147
  * file it came from.
@@ -119,23 +155,48 @@ export function sliceBytes(s, maxBytes) {
119
155
  * `splitRe` and `commentPrefix` default to the TypeScript pair, which is what
120
156
  * both the project corpus and npm packages are written in.
121
157
  */
122
- export function chunkDeclarations(content, relPath, splitRe = DECL_SPLIT_RE, commentPrefix = '//') {
158
+ export function chunkDeclarations(content, relPath, splitRe = DECL_SPLIT_RE, commentPrefix = '//', memberRe = MEMBER_SPLIT_RE) {
123
159
  const chunks = [];
160
+ const header = `${commentPrefix} ${relPath}`;
124
161
  for (const part of splitAtMatches(content, new RegExp(splitRe.source, 'gm'))) {
125
162
  const trimmed = part.trim();
126
163
  if (!trimmed)
127
164
  continue;
128
- const prefixed = `${commentPrefix} ${relPath}\n${trimmed}`;
129
- if (Buffer.byteLength(prefixed, 'utf8') > MAX_CHUNK_BYTES) {
130
- for (const slice of sliceBytes(prefixed, MAX_CHUNK_BYTES))
131
- chunks.push(slice);
132
- }
133
- else {
134
- chunks.push(prefixed);
135
- }
165
+ chunks.push(...splitOversized(header, trimmed, memberRe));
136
166
  }
137
167
  return chunks;
138
168
  }
169
+ /**
170
+ * Cut a declaration that does not fit at its own member boundaries, keeping the
171
+ * line that says what it is a member OF.
172
+ *
173
+ * Without that line a piece of `declare module "bun"` is an anonymous list of
174
+ * functions: a `Bun.file` question came back carrying slices about S3 ETags and
175
+ * tar archives, because bm25 was matching words in the middles of byte cuts that
176
+ * shared no subject.
177
+ *
178
+ * Byte slicing stays as the floor. A single member wider than the cap — one
179
+ * function with a 20 KB doc comment — still has to be cut somewhere.
180
+ */
181
+ export function splitOversized(header, body, memberRe) {
182
+ if (Buffer.byteLength(`${header}\n${body}`, 'utf8') <= MAX_CHUNK_BYTES) {
183
+ return [`${header}\n${body}`];
184
+ }
185
+ const parts = splitAtMatches(body, new RegExp(memberRe.source, 'gm'));
186
+ if (parts.length < 2)
187
+ return headedSlices(header, body, MAX_CHUNK_BYTES);
188
+ // The first part carries the enclosing head; every later one has to be told.
189
+ const enclosing = parts[0].split('\n')[0].trim();
190
+ const out = [];
191
+ for (const [i, part] of parts.entries()) {
192
+ const trimmed = part.replace(/\s+$/, '');
193
+ if (!trimmed.trim())
194
+ continue;
195
+ const withContext = i === 0 ? trimmed : `${enclosing}\n${trimmed}`;
196
+ out.push(...headedSlices(header, withContext, MAX_CHUNK_BYTES));
197
+ }
198
+ return out;
199
+ }
139
200
  /** Chunk a README, one chunk per top-level section, each labelled by heading. */
140
201
  export function chunkReadme(content) {
141
202
  const chunks = [];
@@ -146,14 +207,7 @@ export function chunkReadme(content) {
146
207
  continue;
147
208
  const headingMatch = /^(#{1,2}) (.+)$/m.exec(trimmed);
148
209
  const heading = headingMatch ? headingMatch[2] : '(intro)';
149
- const prefixed = `<!-- README: ${heading} -->\n${trimmed}`;
150
- if (Buffer.byteLength(prefixed, 'utf8') > MAX_CHUNK_BYTES) {
151
- for (const slice of sliceBytes(prefixed, MAX_CHUNK_BYTES))
152
- chunks.push(slice);
153
- }
154
- else {
155
- chunks.push(prefixed);
156
- }
210
+ chunks.push(...headedSlices(`<!-- README: ${heading} -->`, trimmed, MAX_CHUNK_BYTES));
157
211
  }
158
212
  return chunks;
159
213
  }
@@ -107,6 +107,12 @@ export interface EcosystemProfile {
107
107
  surface: (content: string) => string;
108
108
  /** Where a declaration begins, so a chunk never splits a signature. */
109
109
  declSplitRe: RegExp;
110
+ /**
111
+ * Where a MEMBER of a declaration begins — the same heads, indented. Reached
112
+ * only when one declaration does not fit a chunk, which in npm is
113
+ * `declare module "bun" { … }` holding a whole module.
114
+ */
115
+ memberSplitRe: RegExp;
110
116
  /**
111
117
  * The keywords that INTRODUCE a named type in this language, for finding the
112
118
  * chunk that defines a name rather than the many that use it.
@@ -19,10 +19,10 @@ import * as os from 'node:os';
19
19
  import * as path from 'node:path';
20
20
  import { runAutoInstall, findDeclaredRange, extractParentPackage, resolveTypeSourceForDocs, getDocsModulesDir } from './docs-core.js';
21
21
  import { resolvePackage, isDtsFile, isValidModuleName } from './docs-resolve.js';
22
- import { DECL_SPLIT_RE } from './docs-chunk.js';
22
+ import { DECL_SPLIT_RE, MEMBER_SPLIT_RE } from './docs-chunk.js';
23
23
  import { npmVersionLookup } from './npm-version.js';
24
- import { resolveCrate, cratesLatest, crateTarballUrl, crateOf, isValidCrateName, isRustFile, lockedVersion, rustSurface, cargoProjectName, childDirs, lockedDeps, manifestCrates, cargoExportGap, cargoContentFingerprint, cargoSupplementCandidates, CARGO_DECL_SPLIT_RE } from './eco-cargo.js';
25
- import { resolveHackage, hackageLatest, hackageVersion, hackageTarballUrl, hackageExtractDir, hackageProjectName, supplementCandidates, hackageExportGap, hackageContentFingerprint, findCabalTarball, cachedVersions, resolvedVersions, manifestPackages, isValidHackageName, isHaskellFile, haskellSurface, HACKAGE_DECL_SPLIT_RE, HACKAGE_SKIP_DIRS } from './eco-hackage.js';
24
+ import { resolveCrate, cratesLatest, crateTarballUrl, crateOf, isValidCrateName, isRustFile, lockedVersion, rustSurface, cargoProjectName, childDirs, lockedDeps, manifestCrates, cargoExportGap, cargoContentFingerprint, cargoSupplementCandidates, CARGO_DECL_SPLIT_RE, CARGO_MEMBER_SPLIT_RE } from './eco-cargo.js';
25
+ import { resolveHackage, hackageLatest, hackageVersion, hackageTarballUrl, hackageExtractDir, hackageProjectName, supplementCandidates, hackageExportGap, hackageContentFingerprint, findCabalTarball, cachedVersions, resolvedVersions, manifestPackages, isValidHackageName, isHaskellFile, haskellSurface, HACKAGE_DECL_SPLIT_RE, HACKAGE_SKIP_DIRS, HACKAGE_MEMBER_SPLIT_RE } from './eco-hackage.js';
26
26
  import { runChild } from '../shared/child-process.js';
27
27
  /**
28
28
  * Is any of `names` present at `cwd` or above it?
@@ -134,6 +134,7 @@ export function npmProfile(hooks = {}) {
134
134
  // identity for a fingerprint to miss.
135
135
  contentFingerprint: () => String(npmSurface),
136
136
  declSplitRe: DECL_SPLIT_RE,
137
+ memberSplitRe: MEMBER_SPLIT_RE,
137
138
  typeKeywords: ['interface', 'type', 'class', 'enum'],
138
139
  commentPrefix: '//',
139
140
  // A nested node_modules is another package's surface, never this one's.
@@ -313,6 +314,7 @@ const cargoProfile = {
313
314
  // index as `insideTrait`. `contentFingerprint` is what covers its source.
314
315
  surface: content => rustSurface(content),
315
316
  declSplitRe: CARGO_DECL_SPLIT_RE,
317
+ memberSplitRe: CARGO_MEMBER_SPLIT_RE,
316
318
  typeKeywords: ['struct', 'trait', 'enum', 'type', 'union'],
317
319
  commentPrefix: '//',
318
320
  skipDirs: ['tests', 'benches', 'examples', 'target'],
@@ -429,6 +431,7 @@ const hackageProfile = {
429
431
  isSurfaceFile: isHaskellFile,
430
432
  surface: haskellSurface,
431
433
  declSplitRe: HACKAGE_DECL_SPLIT_RE,
434
+ memberSplitRe: HACKAGE_MEMBER_SPLIT_RE,
432
435
  typeKeywords: ['type', 'data', 'newtype', 'class'],
433
436
  commentPrefix: '--',
434
437
  skipDirs: HACKAGE_SKIP_DIRS,
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import {} from './docs-resolve.js';
5
- import { chunkDeclarations, chunkReadme, splitAtMatches } from './docs-chunk.js';
5
+ import { chunkDeclarations, chunkReadme, splitAtMatches, headedSlices, splitOversized, MEMBER_SPLIT_RE } from './docs-chunk.js';
6
6
  import { ECOSYSTEMS } from './docs-ecosystems.js';
7
7
  const ZERO_SEP = Buffer.from([0]);
8
8
  /**
@@ -33,13 +33,27 @@ const ZERO_SEP = Buffer.from([0]);
33
33
  * have said so.
34
34
  */
35
35
  export function chunkerFingerprint() {
36
- return `${String(splitAtMatches)}\u0000${String(chunkDeclarations)}\u0000${String(chunkReadme)}`;
36
+ return [
37
+ String(splitAtMatches),
38
+ String(chunkDeclarations),
39
+ String(chunkReadme),
40
+ // Both chunkers now delegate their slicing, so their own source would sit
41
+ // still through a change to where an oversized declaration is cut. That is
42
+ // the third fix to hide one level below a `String(fn)` here.
43
+ String(headedSlices),
44
+ String(splitOversized),
45
+ // The member split is per-ecosystem, and a profile's regex is not source
46
+ // this function can see. `declSplitRe` is already hashed beside it in
47
+ // computeContentHash; this puts its sibling there too.
48
+ MEMBER_SPLIT_RE.source
49
+ ].join('\u0000');
37
50
  }
38
51
  function computeContentHash(pkg, profile, supplements = []) {
39
52
  const hash = createHash('sha256');
40
53
  hash.update(Buffer.from(`${pkg.name}@${pkg.version}`, 'utf8'));
41
54
  hash.update(ZERO_SEP);
42
- hash.update(Buffer.from(`${profile.declSplitRe.source}\u0000${profile.commentPrefix}`, 'utf8'));
55
+ hash.update(Buffer.from(`${profile.declSplitRe.source}\u0000${profile.memberSplitRe.source}`
56
+ + `\u0000${profile.commentPrefix}`, 'utf8'));
43
57
  hash.update(ZERO_SEP);
44
58
  hash.update(Buffer.from(chunkerFingerprint(), 'utf8'));
45
59
  hash.update(ZERO_SEP);
@@ -205,7 +219,7 @@ function ingestBody(cache, pkg, profile, contentHash, supplements = []) {
205
219
  catch {
206
220
  continue;
207
221
  }
208
- const chunks = chunkDeclarations(profile.surface(raw), rel, profile.declSplitRe, profile.commentPrefix);
222
+ const chunks = chunkDeclarations(profile.surface(raw), rel, profile.declSplitRe, profile.commentPrefix, profile.memberSplitRe);
209
223
  if (!chunks.length)
210
224
  continue;
211
225
  filesIngested++;
@@ -236,7 +250,7 @@ function ingestBody(cache, pkg, profile, contentHash, supplements = []) {
236
250
  // wrong package is the bug this whole table exists for.
237
251
  const rel = `${sup.name}-${sup.version}/` + path.relative(sup.root, abs).replace(/\\/g, '/');
238
252
  const whole = gap.wholesale(rel, raw);
239
- for (const c of chunkDeclarations(profile.surface(raw), rel, profile.declSplitRe, profile.commentPrefix)) {
253
+ for (const c of chunkDeclarations(profile.surface(raw), rel, profile.declSplitRe, profile.commentPrefix, profile.memberSplitRe)) {
240
254
  if (!whole && !gap.fillsHole(c.replace(/^\S.*\n/, '')))
241
255
  continue;
242
256
  if (seen.has(c))
@@ -174,7 +174,7 @@ export function ensureProjectIndexed(cache, name, version, files, cwd) {
174
174
  // `pi-worker-docs(".", …)` answers "no chunks" for the whole project.
175
175
  // Only the CHUNK BOUNDARY is language-specific.
176
176
  const profile = profileForFile(abs, profiles);
177
- const chunks = chunkDeclarations(raw, rel, profile.declSplitRe, profile.commentPrefix);
177
+ const chunks = chunkDeclarations(raw, rel, profile.declSplitRe, profile.commentPrefix, profile.memberSplitRe);
178
178
  if (!chunks.length)
179
179
  continue;
180
180
  filesIngested++;
@@ -30,12 +30,20 @@ export interface RetrieveOptions {
30
30
  *
31
31
  * retrieval — does a chunk DEFINE the queried symbol? 91/101 -> 97/101,
32
32
  * monotone to 50 and flat at 100, p = 0.0703
33
- * answers — does the extraction child answer at all? 67/94 -> 79/94,
34
- * only-8 3, only-50 15, McNemar exact p = 0.0075
33
+ * answers — 67/94 -> 79/94, p = 0.0075 when first measured, and it DOES NOT
34
+ * replicate. That run was block-ordered, which makes arm and
35
+ * position the same variable; re-run as warm-up then ABBA it reads
36
+ * 159/206 -> 163/206, p = 0.5716, with both within-arm A/A controls
37
+ * at p = 1.0000. The retrieval line above is what justifies 50.
35
38
  *
36
- * Abstention on the replay corpus falls 29% to 16%. The cost is +70% of retrieved
37
- * text, and `RETRIEVE_CONTENT_BUDGET` still bounds it at 8 only 6 of 74 calls
38
- * reached that budget, so two thirds of it was never spent.
39
+ * The cost is +70% of retrieved text, 14,986 characters per call to 20,882, and
40
+ * `RETRIEVE_CONTENT_BUDGET` now binds where it did not: at 8 only 6 of 74 calls
41
+ * reached the budget, at 50 it is 90 of 103.
42
+ *
43
+ * Do not raise the budget to follow it. 24,000 -> 48,000 was measured the same
44
+ * balanced way and reads p = 0.1360 on answers and p = 0.2500 on defines. Three
45
+ * measurements now agree that retrieved VOLUME is not what makes this child
46
+ * answer; what the text IS, is.
39
47
  *
40
48
  * Changing either is still a retrieval-policy change, not a tidy-up.
41
49
  */
@@ -43,4 +51,22 @@ export declare const PACKAGE_RETRIEVE_LIMIT = 50;
43
51
  export declare const PROJECT_RETRIEVE_LIMIT = 50;
44
52
  /** Character budget for the assembled chunk text. The same for both corpora. */
45
53
  export declare const RETRIEVE_CONTENT_BUDGET = 24000;
54
+ /**
55
+ * The type names the retrieved text declares MEMBERS as, whose own definitions
56
+ * are not in hand and which the query itself names — by the member or by the
57
+ * type.
58
+ *
59
+ * This is the alias hop. A package that types its public surface through
60
+ * interface aliases puts every real signature one declaration away from the name
61
+ * a query matches: hono writes `get: HandlerInterface<…>` in hono-base.d.ts and
62
+ * keeps the call signatures in `HandlerInterface`, in types.d.ts. BM25 ranks
63
+ * chunks independently, so retrieval lands on the alias and the extraction child
64
+ * sees a name where a signature should be. Measured on hono 4.13.5: three real
65
+ * lookups, three abstentions, and the definition in one chunk of 708.
66
+ *
67
+ * Ranking hops by frequency does not work — `Response` and the English word
68
+ * `The` both outrank `HandlerInterface` in the same text. What the query names
69
+ * is the signal.
70
+ */
71
+ export declare function hopNames(text: string, tokens: string[]): string[];
46
72
  export declare function retrieveChunks(cache: CacheHandle, opts: RetrieveOptions): RetrievedChunk[];
@@ -7,12 +7,20 @@
7
7
  *
8
8
  * retrieval — does a chunk DEFINE the queried symbol? 91/101 -> 97/101,
9
9
  * monotone to 50 and flat at 100, p = 0.0703
10
- * answers — does the extraction child answer at all? 67/94 -> 79/94,
11
- * only-8 3, only-50 15, McNemar exact p = 0.0075
10
+ * answers — 67/94 -> 79/94, p = 0.0075 when first measured, and it DOES NOT
11
+ * replicate. That run was block-ordered, which makes arm and
12
+ * position the same variable; re-run as warm-up then ABBA it reads
13
+ * 159/206 -> 163/206, p = 0.5716, with both within-arm A/A controls
14
+ * at p = 1.0000. The retrieval line above is what justifies 50.
12
15
  *
13
- * Abstention on the replay corpus falls 29% to 16%. The cost is +70% of retrieved
14
- * text, and `RETRIEVE_CONTENT_BUDGET` still bounds it at 8 only 6 of 74 calls
15
- * reached that budget, so two thirds of it was never spent.
16
+ * The cost is +70% of retrieved text, 14,986 characters per call to 20,882, and
17
+ * `RETRIEVE_CONTENT_BUDGET` now binds where it did not: at 8 only 6 of 74 calls
18
+ * reached the budget, at 50 it is 90 of 103.
19
+ *
20
+ * Do not raise the budget to follow it. 24,000 -> 48,000 was measured the same
21
+ * balanced way and reads p = 0.1360 on answers and p = 0.2500 on defines. Three
22
+ * measurements now agree that retrieved VOLUME is not what makes this child
23
+ * answer; what the text IS, is.
16
24
  *
17
25
  * Changing either is still a retrieval-policy change, not a tidy-up.
18
26
  */
@@ -25,6 +33,25 @@ export const RETRIEVE_CONTENT_BUDGET = 24_000;
25
33
  // backstop for a third caller that does not.
26
34
  const DEFAULT_LIMIT = PROJECT_RETRIEVE_LIMIT;
27
35
  const DEFAULT_BUDGET = RETRIEVE_CONTENT_BUDGET;
36
+ /**
37
+ * Shortest query token that reaches the FTS query. Swept twice, and 2 stays.
38
+ *
39
+ * The first sweep put the peak at 3 — one gain, no losses — because no truth entry
40
+ * named a two-letter symbol and the metric could not see what 3 throws away. Two-
41
+ * letter tokens across every recorded query are mostly English filler (`to` 97,
42
+ * `of` 64, `in` 57), and mixed in with them are `v4` 13, `it` 47, `IO`, `fn`, `u8`.
43
+ *
44
+ * One entry for `bun:test`'s `it` was enough to reverse it:
45
+ *
46
+ * min without a 2-letter entry with one
47
+ * 2 150/157 151/159 <- peak
48
+ * 3 151/157 +1 150/159 -1
49
+ * 4 150/157 146/159 p = 0.1797
50
+ * 5 134/157 p = 0.0015 131/159 p = 8.8e-5
51
+ *
52
+ * Raising it drops `v4` from a zod query, which is the token that distinguishes the
53
+ * major this whole test set exists to pin.
54
+ */
28
55
  const MIN_TOKEN_LEN = 2;
29
56
  /**
30
57
  * How many alias definitions one retrieval will chase. Three covers the observed
@@ -120,6 +147,10 @@ function enforceBudget(chunks, budget) {
120
147
  total += c.content.length;
121
148
  continue;
122
149
  }
150
+ // `break`, not `continue`, and it is load-bearing. Packing lower-ranked
151
+ // chunks into the gap left by an oversized one leaves less room in the
152
+ // SECOND enforceBudget, where the alias hops sit — and a dropped hop costs
153
+ // more than a gained tail chunk. Measured: defines 150/157 -> 149/157.
123
154
  if (total + c.content.length > budget)
124
155
  break;
125
156
  out.push(c);
@@ -144,7 +175,7 @@ function enforceBudget(chunks, budget) {
144
175
  * `The` both outrank `HandlerInterface` in the same text. What the query names
145
176
  * is the signal.
146
177
  */
147
- function hopNames(text, tokens) {
178
+ export function hopNames(text, tokens) {
148
179
  const declared = new Set([...text.matchAll(TYPE_DECL_RE)].map(m => m[1]));
149
180
  const typeParams = new Set();
150
181
  for (const m of text.matchAll(TYPE_PARAMS_RE)) {
@@ -83,6 +83,8 @@ export declare function crateTarballUrl(name: string, version: string): string;
83
83
  * receiver type, carrying the next method's doc comment.
84
84
  */
85
85
  export declare const CARGO_DECL_SPLIT_RE: RegExp;
86
+ /** The same heads, indented — a member of an oversized `impl` or `mod` block. */
87
+ export declare const CARGO_MEMBER_SPLIT_RE: RegExp;
86
88
  interface Item {
87
89
  /** Doc comments and attributes immediately above the item. */
88
90
  pending: string;
@@ -365,6 +365,8 @@ export function crateTarballUrl(name, version) {
365
365
  * receiver type, carrying the next method's doc comment.
366
366
  */
367
367
  export const CARGO_DECL_SPLIT_RE = /^(?:#\[[^\n]*\]\s*)*(?:pub\s+)?(?:async\s+|unsafe\s+|const\s+|extern\s+)*(?:fn|struct|enum|union|trait|type|impl|mod|const|static)\b/m;
368
+ /** The same heads, indented — a member of an oversized `impl` or `mod` block. */
369
+ export const CARGO_MEMBER_SPLIT_RE = /^[ \t]+(?:#\[[^\n]*\]\s*)*(?:pub\s+)?(?:async\s+|unsafe\s+|const\s+|extern\s+)*(?:fn|struct|enum|union|trait|type|impl|mod|const|static)\b/m;
368
370
  // `macro_rules!` carries its own terminator, so it sits OUTSIDE the `\b` — a word
369
371
  // boundary after `!` requires a word character next, and what follows is a space.
370
372
  const ITEM_HEAD_RE = /^(?:pub(?:\s*\([^)]*\))?\s+)?(?:default\s+|async\s+|unsafe\s+|const\s+|extern\s+"[^"]*"\s+|extern\s+)*((?:fn|struct|enum|union|trait|impl|mod|type|const|static|use)\b|macro_rules!)/;
@@ -67,6 +67,8 @@ export declare function resolveHackage(name: string, cwd: string, dirs: HackageR
67
67
  export declare function hackageLatest(name: string, fetchFn: typeof fetch, signal?: AbortSignal): Promise<NpmVersionInfo | null>;
68
68
  /** Where a Haskell declaration begins, so a chunk never splits a signature. */
69
69
  export declare const HACKAGE_DECL_SPLIT_RE: RegExp;
70
+ /** The same heads, indented — a member of an oversized `class` or `instance`. */
71
+ export declare const HACKAGE_MEMBER_SPLIT_RE: RegExp;
70
72
  /**
71
73
  * Reduce Haskell source to its public API surface: the module header with its
72
74
  * export list, every top-level type signature, every type and class
@@ -303,6 +303,8 @@ export async function hackageLatest(name, fetchFn, signal) {
303
303
  // ── surface extraction ──────────────────────────────────────────────────────
304
304
  /** Where a Haskell declaration begins, so a chunk never splits a signature. */
305
305
  export const HACKAGE_DECL_SPLIT_RE = /^(?:[a-z_][\w']*\s*::|data\s|newtype\s|type\s|class\s|instance\s|pattern\s)/m;
306
+ /** The same heads, indented — a member of an oversized `class` or `instance`. */
307
+ export const HACKAGE_MEMBER_SPLIT_RE = /^[ \t]+(?:[a-z_][\w']*\s*::|data\s|newtype\s|type\s|class\s|instance\s|pattern\s)/m;
306
308
  const SIGNATURE_RE = /^[a-z_][\w']*(?:\s*,\s*[a-z_][\w']*)*\s*::/;
307
309
  const OPERATOR_SIGNATURE_RE = /^\([^)]+\)\s*::/;
308
310
  /** The same heads with the `::` wrapped onto the next line. */
@@ -12,6 +12,12 @@ interface DocsDetails {
12
12
  hitCache?: boolean;
13
13
  chunksRetrieved?: number;
14
14
  excerptVerified?: boolean;
15
+ /**
16
+ * The excerpt cites a word the source never wrote — a possible fabrication, as
17
+ * opposed to a quote stitched from several real spans. `excerptVerified: false`
18
+ * covers both and cannot tell them apart.
19
+ */
20
+ excerptFabricated?: boolean;
15
21
  childExitCode?: number;
16
22
  indexingMs?: number;
17
23
  indexedFiles?: number;
@@ -35,6 +41,12 @@ interface DocsDetails {
35
41
  /** The project-lookup budget for this attempt is spent, so the call was refused
36
42
  * before any work. Only set when PI_TASK_PROJECT_DOCS_BUDGET is configured. */
37
43
  budgetSpent?: boolean;
44
+ /**
45
+ * The child declined to answer. Recorded HERE, at the one place the bare
46
+ * `<answer>` is in hand: the tool text a cache predicate is handed leads with a
47
+ * provenance header, and the anchored matcher scores that as a real answer.
48
+ */
49
+ abstained?: boolean;
38
50
  }
39
51
  /**
40
52
  * Pull `@see {@link https://…}` pointers out of retrieved .d.ts/README text.
@@ -73,7 +85,17 @@ export declare function registerPiWorkerDocs(pi: ExtensionAPI, internals?: PiWor
73
85
  * assert against its own copy — green even after the shipped rule changed. Exported,
74
86
  * the test imports the rule it is checking.
75
87
  */
76
- export declare function docsCacheable(d: Pick<DocsDetails, 'typeOnly' | 'excerptVerified'>, text: string): boolean;
88
+ /**
89
+ * Did the excerpt cite a word the source never wrote?
90
+ *
91
+ * `verified` is false for a stitched quote too, and the two must not share a gate:
92
+ * one is a possible fabrication and the other is what the extraction prompt asks
93
+ * for. Absent of a check, nothing was verified and nothing is claimed.
94
+ */
95
+ export declare function excerptFabricated(check: {
96
+ absent: readonly string[];
97
+ } | undefined): boolean;
98
+ export declare function docsCacheable(d: Pick<DocsDetails, 'typeOnly' | 'excerptVerified' | 'excerptFabricated' | 'abstained'>): boolean;
77
99
  /** The docs cache key: a package's answer is per (module, question), with the question
78
100
  * lowercased and its whitespace collapsed so phrasing variants share one entry. Returns
79
101
  * null for the project-source `.` lookup, which is never cached — the working tree
@@ -218,7 +218,9 @@ export function registerPiWorkerDocs(pi, internals = {}) {
218
218
  });
219
219
  return workerAnswer(text, {
220
220
  ...baseDetails,
221
- excerptVerified: verified
221
+ excerptVerified: verified,
222
+ excerptFabricated: excerptFabricated(extraction.excerptCheck),
223
+ ...(isAbstention(extraction.answer) ? { abstained: true } : {})
222
224
  });
223
225
  }
224
226
  // ── npm package lookup (existing path) ──────────────────────────
@@ -353,7 +355,9 @@ export function registerPiWorkerDocs(pi, internals = {}) {
353
355
  return workerAnswer(text, {
354
356
  ...baseDetails,
355
357
  excerptVerified: verified,
356
- ...(typeOnly.typeOnly ? { typeOnly: true } : {})
358
+ excerptFabricated: excerptFabricated(extraction.excerptCheck),
359
+ ...(typeOnly.typeOnly ? { typeOnly: true } : {}),
360
+ ...(isAbstention(extraction.answer) ? { abstained: true } : {})
357
361
  });
358
362
  },
359
363
  renderCall(args, theme) {
@@ -382,9 +386,6 @@ export function registerPiWorkerDocs(pi, internals = {}) {
382
386
  // child that ran fine and answered "unclear from this package" exits 0 — so a rule
383
387
  // keyed on exit code would memoise that non-answer and re-serve it as a hit to
384
388
  // every later sibling, with nothing left to re-trigger an escalation.
385
- //
386
- // `text` is supplied by makeWorkerTool (shared.ts) alongside details, so the
387
- // content check needs no new plumbing.
388
389
  cacheable: docsCacheable
389
390
  });
390
391
  }
@@ -397,12 +398,33 @@ export function registerPiWorkerDocs(pi, internals = {}) {
397
398
  * assert against its own copy — green even after the shipped rule changed. Exported,
398
399
  * the test imports the rule it is checking.
399
400
  */
400
- export function docsCacheable(d, text) {
401
+ /**
402
+ * Did the excerpt cite a word the source never wrote?
403
+ *
404
+ * `verified` is false for a stitched quote too, and the two must not share a gate:
405
+ * one is a possible fabrication and the other is what the extraction prompt asks
406
+ * for. Absent of a check, nothing was verified and nothing is claimed.
407
+ */
408
+ export function excerptFabricated(check) {
409
+ return check !== undefined && check.absent.length > 0;
410
+ }
411
+ export function docsCacheable(d) {
401
412
  // Answer QUALITY only. Whether there IS an answer is `WorkerOutcome.kind`, and
402
413
  // `makeWorkerTool` has already refused an `unavailable` before reaching here —
403
414
  // opening this with `childExitCode === 0` memoises an aborted lookup for the
404
415
  // whole run, because a signal-killed child satisfies it.
405
- return d.typeOnly !== true && d.excerptVerified !== false && !isAbstention(text);
416
+ //
417
+ // It reads `excerptFabricated`, not `excerptVerified`. A quarter of every run's
418
+ // answers come back unverified and this refused all of them; re-checked with the
419
+ // classifier defect 18 added, 41 of 41 unverified excerpts across seven runs are
420
+ // STITCHED — every span verbatim, not one absent word. The gate was refusing
421
+ // non-contiguous quoting, and every sibling paid a fresh child for it.
422
+ //
423
+ // The abstention is read off DETAILS, not off the tool text. `makeWorkerTool`
424
+ // hands this the FINAL text, which leads with `Per <pkg>@<version>:` — and
425
+ // `isAbstention` is anchored, so testing that text scored every abstention as a
426
+ // real answer and memoised the dead end for the whole run.
427
+ return d.typeOnly !== true && d.excerptFabricated !== true && d.abstained !== true;
406
428
  }
407
429
  /** The docs cache key: a package's answer is per (module, question), with the question
408
430
  * lowercased and its whitespace collapsed so phrasing variants share one entry. Returns
@@ -32,7 +32,7 @@ export declare function registerPiWorkerFetch(pi: ExtensionAPI, internals?: PiWo
32
32
  * ABOUT that page, and re-fetching cannot change it — only the abstention sentinel is
33
33
  * refused.
34
34
  */
35
- export declare function fetchCacheable(_d: Pick<FetchDetails, never>, text: string): boolean;
35
+ export declare function fetchCacheable(d: Pick<FetchDetails, 'answer'>): boolean;
36
36
  /** The fetch cache key. URL verbatim (path case can matter), question normalised —
37
37
  * same page, different question is a different answer. */
38
38
  export declare function fetchCacheKey(params: {
@@ -112,12 +112,16 @@ export function registerPiWorkerFetch(pi, internals = {}) {
112
112
  * ABOUT that page, and re-fetching cannot change it — only the abstention sentinel is
113
113
  * refused.
114
114
  */
115
- export function fetchCacheable(_d, text) {
116
- // Answer QUALITY only — see docsCacheable. This predicate returns true for
117
- // `"Fetch aborted."` on its own; what keeps an aborted fetch out of the cache is
118
- // the `unavailable` outcome upstream. Leading the rule with `childExitCode === 0`
119
- // would not, because an aborted child settles at exit code 0.
120
- return !isAbstention(text);
115
+ export function fetchCacheable(d) {
116
+ // Answer QUALITY only — see docsCacheable. An aborted fetch is kept out of the
117
+ // cache by its `unavailable` outcome upstream, never by this rule; leading with
118
+ // `childExitCode === 0` would not work, because an aborted child settles at 0.
119
+ //
120
+ // It reads the child's bare answer, not the rendered tool text: `isAbstention` is
121
+ // anchored, and the text leads with an excerpt NOTE/WARNING whenever the excerpt
122
+ // did not verify — which, since rule 4 asks for the closest related text, is the
123
+ // ordinary shape of an abstention.
124
+ return d.answer !== undefined && !isAbstention(d.answer);
121
125
  }
122
126
  /** The fetch cache key. URL verbatim (path case can matter), question normalised —
123
127
  * same page, different question is a different answer. */
@@ -121,8 +121,12 @@ export interface WorkerToolSpec<TParams extends TSchema, TDetails> {
121
121
  * (type-only, an abstention, an unverified excerpt), never about process
122
122
  * health. An `unavailable` outcome never reaches this: `makeWorkerTool` has
123
123
  * already refused it. Defaults to always-cacheable when omitted.
124
+ *
125
+ * It sees `details` only. Handed the rendered tool text as well, a rule reaches
126
+ * for it and reads a string that leads with provenance and excerpt notes — and an
127
+ * anchored abstention matcher then scores every abstention as an answer.
124
128
  */
125
- cacheable?(details: TDetails, text: string): boolean;
129
+ cacheable?(details: TDetails): boolean;
126
130
  }
127
131
  /** Register a worker tool from its spec, supplying the shared registration ritual. */
128
132
  export declare function makeWorkerTool<TParams extends TSchema, TDetails>(pi: ExtensionAPI, spec: WorkerToolSpec<TParams, TDetails>): void;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.14",
3
+ "version": "0.40.26",
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",