@mjasnikovs/pi-task 0.38.1 → 0.38.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.
Files changed (46) hide show
  1. package/dist/config/config.d.ts +7 -0
  2. package/dist/config/config.js +10 -4
  3. package/dist/config/register.d.ts +37 -0
  4. package/dist/config/register.js +89 -114
  5. package/dist/remote/events.js +0 -3
  6. package/dist/remote/register.js +12 -3
  7. package/dist/task/auto-orchestrator.js +119 -94
  8. package/dist/task/command-run.d.ts +104 -0
  9. package/dist/task/command-run.js +138 -0
  10. package/dist/task/coverage-loop.d.ts +45 -0
  11. package/dist/task/critique-probes.d.ts +82 -0
  12. package/dist/task/critique-probes.js +156 -0
  13. package/dist/task/enforce-guidelines.d.ts +14 -17
  14. package/dist/task/enforce-guidelines.js +44 -31
  15. package/dist/task/final-gate.d.ts +8 -10
  16. package/dist/task/final-gate.js +36 -74
  17. package/dist/task/gate-child.d.ts +104 -0
  18. package/dist/task/gate-child.js +177 -0
  19. package/dist/task/gate-deps.js +57 -205
  20. package/dist/task/orchestrator.js +13 -22
  21. package/dist/task/phases.js +109 -182
  22. package/dist/task/plan-session.d.ts +4 -22
  23. package/dist/task/plan-session.js +4 -33
  24. package/dist/task/question-dialog.d.ts +71 -0
  25. package/dist/task/question-dialog.js +89 -0
  26. package/dist/task/terminal-outcome.d.ts +67 -0
  27. package/dist/task/terminal-outcome.js +76 -0
  28. package/dist/task/type-only-answer.js +2 -3
  29. package/dist/workers/abstention.d.ts +71 -0
  30. package/dist/workers/abstention.js +108 -0
  31. package/dist/workers/docs-chunk.d.ts +74 -0
  32. package/dist/workers/docs-chunk.js +143 -0
  33. package/dist/workers/docs-core.d.ts +10 -1
  34. package/dist/workers/docs-core.js +22 -19
  35. package/dist/workers/docs-index.js +2 -69
  36. package/dist/workers/docs-project.d.ts +15 -1
  37. package/dist/workers/docs-project.js +27 -66
  38. package/dist/workers/fetch-core.d.ts +1 -1
  39. package/dist/workers/fetch-core.js +2 -1
  40. package/dist/workers/pi-worker-core.js +157 -86
  41. package/dist/workers/pi-worker-docs.js +5 -10
  42. package/dist/workers/pi-worker-fetch.js +8 -1
  43. package/dist/workers/typeonly-log.js +2 -10
  44. package/dist/workers/worker-failure.d.ts +91 -0
  45. package/dist/workers/worker-failure.js +82 -0
  46. 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 (UNCLEAR.test(a)) {
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
+ }