@mjasnikovs/pi-task 0.40.25 → 0.40.27

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.
@@ -99,6 +99,11 @@ export function splitAtMatches(text, re) {
99
99
  * possible hallucination. Only reachable on non-ASCII text past the chunk ceiling.
100
100
  */
101
101
  export function sliceBytes(s, maxBytes) {
102
+ // A non-positive cap never shrinks the buffer, so the loop below runs forever.
103
+ // Guarded here rather than at the callers: the cap is usually computed, and the
104
+ // next caller to compute one must not have to rediscover this.
105
+ if (maxBytes <= 0)
106
+ return s ? [s] : [];
102
107
  const out = [];
103
108
  let buf = Buffer.from(s, 'utf8');
104
109
  while (buf.length > maxBytes) {
@@ -134,8 +139,19 @@ export function headedSlices(header, body, maxBytes) {
134
139
  const prefixed = `${header}\n${body}`;
135
140
  if (Buffer.byteLength(prefixed, 'utf8') <= maxBytes)
136
141
  return [prefixed];
137
- const room = maxBytes - Buffer.byteLength(`${header}\n`, 'utf8');
138
- return sliceBytes(body, room).map(slice => `${header}\n${slice}`);
142
+ // The header is a LABEL, and `chunkReadme` builds it from an unbounded heading line.
143
+ // Left whole it starves the body: a header two bytes short of the cap turned a 200 KB
144
+ // section into 100,000 two-byte chunks, each re-carrying the 8 KB header. Splitting the
145
+ // cap evenly is the one division that needs no tuning, and a cut label still names the
146
+ // source.
147
+ const head = sliceBytes(header, Math.floor(maxBytes / 2))[0] ?? '';
148
+ const room = maxBytes - Buffer.byteLength(`${head}\n`, 'utf8');
149
+ // Only a cap of a byte or two reaches this. Slice the prefixed string and accept the
150
+ // degenerate result: the body pieces carry no provenance — the very defect this
151
+ // function exists to fix, but the alternative here is emitting nothing.
152
+ if (room <= 0)
153
+ return sliceBytes(prefixed, maxBytes);
154
+ return sliceBytes(body, room).map(slice => `${head}\n${slice}`);
139
155
  }
140
156
  /**
141
157
  * Chunk a declaration file, one chunk per declaration, each labelled with the
@@ -41,6 +41,12 @@ interface DocsDetails {
41
41
  /** The project-lookup budget for this attempt is spent, so the call was refused
42
42
  * before any work. Only set when PI_TASK_PROJECT_DOCS_BUDGET is configured. */
43
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;
44
50
  }
45
51
  /**
46
52
  * Pull `@see {@link https://…}` pointers out of retrieved .d.ts/README text.
@@ -70,15 +76,6 @@ export interface PiWorkerDocsInternals {
70
76
  npmVersionLookup?: typeof defaultNpmVersionLookup;
71
77
  }
72
78
  export declare function registerPiWorkerDocs(pi: ExtensionAPI, internals?: PiWorkerDocsInternals): void;
73
- /**
74
- * The cache rule for the docs channel, as a NAMED export rather than an anonymous
75
- * property of an adapter literal.
76
- *
77
- * As a property of the adapter literal it would be reachable only through
78
- * `registerTool → execute()`, so a test would have to retype the rule and would then
79
- * assert against its own copy — green even after the shipped rule changed. Exported,
80
- * the test imports the rule it is checking.
81
- */
82
79
  /**
83
80
  * Did the excerpt cite a word the source never wrote?
84
81
  *
@@ -89,7 +86,16 @@ export declare function registerPiWorkerDocs(pi: ExtensionAPI, internals?: PiWor
89
86
  export declare function excerptFabricated(check: {
90
87
  absent: readonly string[];
91
88
  } | undefined): boolean;
92
- export declare function docsCacheable(d: Pick<DocsDetails, 'typeOnly' | 'excerptVerified' | 'excerptFabricated'>, text: string): boolean;
89
+ /**
90
+ * The cache rule for the docs channel, as a NAMED export rather than an anonymous
91
+ * property of an adapter literal.
92
+ *
93
+ * As a property of the adapter literal it would be reachable only through
94
+ * `registerTool → execute()`, so a test would have to retype the rule and would then
95
+ * assert against its own copy — green even after the shipped rule changed. Exported,
96
+ * the test imports the rule it is checking.
97
+ */
98
+ export declare function docsCacheable(d: Pick<DocsDetails, 'typeOnly' | 'excerptVerified' | 'excerptFabricated' | 'abstained'>): boolean;
93
99
  /** The docs cache key: a package's answer is per (module, question), with the question
94
100
  * lowercased and its whitespace collapsed so phrasing variants share one entry. Returns
95
101
  * null for the project-source `.` lookup, which is never cached — the working tree
@@ -219,7 +219,8 @@ export function registerPiWorkerDocs(pi, internals = {}) {
219
219
  return workerAnswer(text, {
220
220
  ...baseDetails,
221
221
  excerptVerified: verified,
222
- excerptFabricated: excerptFabricated(extraction.excerptCheck)
222
+ excerptFabricated: excerptFabricated(extraction.excerptCheck),
223
+ ...(isAbstention(extraction.answer) ? { abstained: true } : {})
223
224
  });
224
225
  }
225
226
  // ── npm package lookup (existing path) ──────────────────────────
@@ -355,7 +356,8 @@ export function registerPiWorkerDocs(pi, internals = {}) {
355
356
  ...baseDetails,
356
357
  excerptVerified: verified,
357
358
  excerptFabricated: excerptFabricated(extraction.excerptCheck),
358
- ...(typeOnly.typeOnly ? { typeOnly: true } : {})
359
+ ...(typeOnly.typeOnly ? { typeOnly: true } : {}),
360
+ ...(isAbstention(extraction.answer) ? { abstained: true } : {})
359
361
  });
360
362
  },
361
363
  renderCall(args, theme) {
@@ -384,21 +386,9 @@ export function registerPiWorkerDocs(pi, internals = {}) {
384
386
  // child that ran fine and answered "unclear from this package" exits 0 — so a rule
385
387
  // keyed on exit code would memoise that non-answer and re-serve it as a hit to
386
388
  // every later sibling, with nothing left to re-trigger an escalation.
387
- //
388
- // `text` is supplied by makeWorkerTool (shared.ts) alongside details, so the
389
- // content check needs no new plumbing.
390
389
  cacheable: docsCacheable
391
390
  });
392
391
  }
393
- /**
394
- * The cache rule for the docs channel, as a NAMED export rather than an anonymous
395
- * property of an adapter literal.
396
- *
397
- * As a property of the adapter literal it would be reachable only through
398
- * `registerTool → execute()`, so a test would have to retype the rule and would then
399
- * assert against its own copy — green even after the shipped rule changed. Exported,
400
- * the test imports the rule it is checking.
401
- */
402
392
  /**
403
393
  * Did the excerpt cite a word the source never wrote?
404
394
  *
@@ -409,7 +399,16 @@ export function registerPiWorkerDocs(pi, internals = {}) {
409
399
  export function excerptFabricated(check) {
410
400
  return check !== undefined && check.absent.length > 0;
411
401
  }
412
- export function docsCacheable(d, text) {
402
+ /**
403
+ * The cache rule for the docs channel, as a NAMED export rather than an anonymous
404
+ * property of an adapter literal.
405
+ *
406
+ * As a property of the adapter literal it would be reachable only through
407
+ * `registerTool → execute()`, so a test would have to retype the rule and would then
408
+ * assert against its own copy — green even after the shipped rule changed. Exported,
409
+ * the test imports the rule it is checking.
410
+ */
411
+ export function docsCacheable(d) {
413
412
  // Answer QUALITY only. Whether there IS an answer is `WorkerOutcome.kind`, and
414
413
  // `makeWorkerTool` has already refused an `unavailable` before reaching here —
415
414
  // opening this with `childExitCode === 0` memoises an aborted lookup for the
@@ -420,7 +419,12 @@ export function docsCacheable(d, text) {
420
419
  // classifier defect 18 added, 41 of 41 unverified excerpts across seven runs are
421
420
  // STITCHED — every span verbatim, not one absent word. The gate was refusing
422
421
  // non-contiguous quoting, and every sibling paid a fresh child for it.
423
- return d.typeOnly !== true && d.excerptFabricated !== true && !isAbstention(text);
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;
424
428
  }
425
429
  /** The docs cache key: a package's answer is per (module, question), with the question
426
430
  * lowercased and its whitespace collapsed so phrasing variants share one entry. Returns
@@ -6,6 +6,8 @@ interface FetchDetails {
6
6
  answer?: string;
7
7
  excerpt?: string;
8
8
  excerptVerified?: boolean;
9
+ coverageMiss?: boolean;
10
+ anchoredSection?: string;
9
11
  }
10
12
  interface ProcLike extends EventEmitter {
11
13
  stdout: EventEmitter | null;
@@ -32,7 +34,7 @@ export declare function registerPiWorkerFetch(pi: ExtensionAPI, internals?: PiWo
32
34
  * ABOUT that page, and re-fetching cannot change it — only the abstention sentinel is
33
35
  * refused.
34
36
  */
35
- export declare function fetchCacheable(_d: Pick<FetchDetails, never>, text: string): boolean;
37
+ export declare function fetchCacheable(d: Pick<FetchDetails, 'answer'>): boolean;
36
38
  /** The fetch cache key. URL verbatim (path case can matter), question normalised —
37
39
  * same page, different question is a different answer. */
38
40
  export declare function fetchCacheKey(params: {
@@ -61,6 +61,8 @@ export function registerPiWorkerFetch(pi, internals = {}) {
61
61
  // in the TEXT, not only in details: details are for the harness, and the
62
62
  // worker acts on what it reads.
63
63
  const text = result.nextStep ? `${body}\n\n${result.nextStep}` : body;
64
+ // Named, not inferred: `workerAnswer<T>` reads T off the literal, so a
65
+ // field added here would never reach the declaration the cache rules Pick from.
64
66
  return workerAnswer(text, {
65
67
  childExitCode: 0,
66
68
  answer: result.answer,
@@ -112,12 +114,16 @@ export function registerPiWorkerFetch(pi, internals = {}) {
112
114
  * ABOUT that page, and re-fetching cannot change it — only the abstention sentinel is
113
115
  * refused.
114
116
  */
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);
117
+ export function fetchCacheable(d) {
118
+ // Answer QUALITY only — see docsCacheable. An aborted fetch is kept out of the
119
+ // cache by its `unavailable` outcome upstream, never by this rule; leading with
120
+ // `childExitCode === 0` would not work, because an aborted child settles at 0.
121
+ //
122
+ // It reads the child's bare answer, not the rendered tool text: `isAbstention` is
123
+ // anchored, and the text leads with an excerpt NOTE/WARNING whenever the excerpt
124
+ // did not verify — which, since rule 4 asks for the closest related text, is the
125
+ // ordinary shape of an abstention.
126
+ return d.answer !== undefined && !isAbstention(d.answer);
121
127
  }
122
128
  /** The fetch cache key. URL verbatim (path case can matter), question normalised —
123
129
  * same page, different question is a different answer. */
@@ -99,8 +99,8 @@ export interface WorkerToolSpec<TParams extends TSchema, TDetails> {
99
99
  * the network fetch + child summariser. Return `null` to opt a particular call
100
100
  * OUT of caching (e.g. a project-source `.` lookup, whose answer the working tree
101
101
  * mutates within a run). Omit entirely and the tool is never cached. The stored
102
- * key is namespaced by tool name — joined with a literal NUL, which is why the
103
- * separator is invisible to `grep` and to a plain editor.
102
+ * key is namespaced by tool name — joined with a NUL, a byte no tool name or key
103
+ * can contain, so no pair of them can collide by concatenation.
104
104
  */
105
105
  cacheKey?(params: Static<TParams>): string | null;
106
106
  /**
@@ -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.25",
3
+ "version": "0.40.27",
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",