@mjasnikovs/pi-task 0.2.0

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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +6 -0
  5. package/dist/shared/child-output.d.ts +21 -0
  6. package/dist/shared/child-output.js +40 -0
  7. package/dist/shared/child-process.d.ts +71 -0
  8. package/dist/shared/child-process.js +190 -0
  9. package/dist/shared/pi-invocation.d.ts +7 -0
  10. package/dist/shared/pi-invocation.js +24 -0
  11. package/dist/task/child-runner.d.ts +66 -0
  12. package/dist/task/child-runner.js +157 -0
  13. package/dist/task/enrichment.d.ts +12 -0
  14. package/dist/task/enrichment.js +82 -0
  15. package/dist/task/failure-classifier.d.ts +15 -0
  16. package/dist/task/failure-classifier.js +63 -0
  17. package/dist/task/file-inventory.d.ts +9 -0
  18. package/dist/task/file-inventory.js +44 -0
  19. package/dist/task/loop-detector.d.ts +32 -0
  20. package/dist/task/loop-detector.js +46 -0
  21. package/dist/task/orchestrator.d.ts +54 -0
  22. package/dist/task/orchestrator.js +387 -0
  23. package/dist/task/parsers.d.ts +32 -0
  24. package/dist/task/parsers.js +172 -0
  25. package/dist/task/phases.d.ts +56 -0
  26. package/dist/task/phases.js +477 -0
  27. package/dist/task/prompts.d.ts +21 -0
  28. package/dist/task/prompts.js +346 -0
  29. package/dist/task/service-blocks.d.ts +3 -0
  30. package/dist/task/service-blocks.js +10 -0
  31. package/dist/task/task-file.d.ts +14 -0
  32. package/dist/task/task-file.js +15 -0
  33. package/dist/task/task-io.d.ts +19 -0
  34. package/dist/task/task-io.js +78 -0
  35. package/dist/task/task-parsers.d.ts +12 -0
  36. package/dist/task/task-parsers.js +75 -0
  37. package/dist/task/task-types.d.ts +21 -0
  38. package/dist/task/task-types.js +18 -0
  39. package/dist/task/timings.d.ts +18 -0
  40. package/dist/task/timings.js +36 -0
  41. package/dist/task/widget.d.ts +39 -0
  42. package/dist/task/widget.js +122 -0
  43. package/dist/workers/brave-search.d.ts +17 -0
  44. package/dist/workers/brave-search.js +77 -0
  45. package/dist/workers/docs-cache.d.ts +16 -0
  46. package/dist/workers/docs-cache.js +66 -0
  47. package/dist/workers/docs-core.d.ts +86 -0
  48. package/dist/workers/docs-core.js +329 -0
  49. package/dist/workers/docs-index.d.ts +9 -0
  50. package/dist/workers/docs-index.js +200 -0
  51. package/dist/workers/docs-resolve.d.ts +12 -0
  52. package/dist/workers/docs-resolve.js +126 -0
  53. package/dist/workers/docs-retrieve.d.ts +15 -0
  54. package/dist/workers/docs-retrieve.js +91 -0
  55. package/dist/workers/fetch-core.d.ts +35 -0
  56. package/dist/workers/fetch-core.js +91 -0
  57. package/dist/workers/html-clean.d.ts +17 -0
  58. package/dist/workers/html-clean.js +142 -0
  59. package/dist/workers/index.d.ts +2 -0
  60. package/dist/workers/index.js +10 -0
  61. package/dist/workers/npm-version.d.ts +32 -0
  62. package/dist/workers/npm-version.js +102 -0
  63. package/dist/workers/pi-worker-core.d.ts +28 -0
  64. package/dist/workers/pi-worker-core.js +29 -0
  65. package/dist/workers/pi-worker-docs.d.ts +16 -0
  66. package/dist/workers/pi-worker-docs.js +143 -0
  67. package/dist/workers/pi-worker-fetch.d.ts +20 -0
  68. package/dist/workers/pi-worker-fetch.js +72 -0
  69. package/dist/workers/pi-worker-search.d.ts +7 -0
  70. package/dist/workers/pi-worker-search.js +55 -0
  71. package/dist/workers/pi-worker.d.ts +10 -0
  72. package/dist/workers/pi-worker.js +61 -0
  73. package/dist/workers/search-core.d.ts +19 -0
  74. package/dist/workers/search-core.js +35 -0
  75. package/dist/workers/shared.d.ts +3 -0
  76. package/dist/workers/shared.js +4 -0
  77. package/package.json +50 -0
@@ -0,0 +1,91 @@
1
+ const DEFAULT_LIMIT = 8;
2
+ const DEFAULT_BUDGET = 24_000;
3
+ const MIN_TOKEN_LEN = 2;
4
+ const FALLBACK_DTS_CHARS = 12_000;
5
+ const FALLBACK_README_CHARS = 4_000;
6
+ function tokenize(query) {
7
+ return query
8
+ .split(/\s+/)
9
+ .map(t => t.replace(/[^a-zA-Z0-9_]/g, ''))
10
+ .filter(t => t.length >= MIN_TOKEN_LEN);
11
+ }
12
+ function buildFtsQuery(tokens) {
13
+ return tokens.map(t => `"${t}"`).join(' OR ');
14
+ }
15
+ function fallbackChunks(cache, name, version) {
16
+ const dts = cache.db
17
+ .prepare('SELECT file_path, kind, content, 0 AS rank FROM chunks WHERE name = ? AND version = ? AND kind = \'dts\' ORDER BY file_path, id LIMIT 1')
18
+ .all(name, version);
19
+ const readme = cache.db
20
+ .prepare('SELECT file_path, kind, content, 0 AS rank FROM chunks WHERE name = ? AND version = ? AND kind = \'readme\' ORDER BY id LIMIT 1')
21
+ .all(name, version);
22
+ const out = [];
23
+ for (const r of dts) {
24
+ out.push({
25
+ filePath: r.file_path,
26
+ kind: r.kind,
27
+ content: r.content.slice(0, FALLBACK_DTS_CHARS),
28
+ rank: 0
29
+ });
30
+ }
31
+ for (const r of readme) {
32
+ out.push({
33
+ filePath: r.file_path,
34
+ kind: r.kind,
35
+ content: r.content.slice(0, FALLBACK_README_CHARS),
36
+ rank: 0
37
+ });
38
+ }
39
+ return out;
40
+ }
41
+ function enforceBudget(chunks, budget) {
42
+ if (!chunks.length)
43
+ return chunks;
44
+ const out = [];
45
+ let total = 0;
46
+ for (const c of chunks) {
47
+ if (out.length === 0) {
48
+ out.push(c);
49
+ total += c.content.length;
50
+ continue;
51
+ }
52
+ if (total + c.content.length > budget)
53
+ break;
54
+ out.push(c);
55
+ total += c.content.length;
56
+ }
57
+ return out;
58
+ }
59
+ export function retrieveChunks(cache, opts) {
60
+ const limit = opts.limit ?? DEFAULT_LIMIT;
61
+ const budget = opts.contentBudget ?? DEFAULT_BUDGET;
62
+ const tokens = tokenize(opts.query);
63
+ if (tokens.length === 0) {
64
+ return enforceBudget(fallbackChunks(cache, opts.name, opts.version), budget);
65
+ }
66
+ const ftsQuery = buildFtsQuery(tokens);
67
+ let rows;
68
+ try {
69
+ rows = cache.db
70
+ .prepare(`SELECT c.file_path, c.kind, c.content, bm25(chunks_fts) AS rank
71
+ FROM chunks_fts
72
+ JOIN chunks c ON c.id = chunks_fts.rowid
73
+ WHERE c.name = ?1 AND c.version = ?2 AND chunks_fts MATCH ?3
74
+ ORDER BY rank
75
+ LIMIT ?4`)
76
+ .all(opts.name, opts.version, ftsQuery, limit);
77
+ }
78
+ catch {
79
+ return enforceBudget(fallbackChunks(cache, opts.name, opts.version), budget);
80
+ }
81
+ if (rows.length === 0) {
82
+ return enforceBudget(fallbackChunks(cache, opts.name, opts.version), budget);
83
+ }
84
+ const mapped = rows.map(r => ({
85
+ filePath: r.file_path,
86
+ kind: r.kind,
87
+ content: r.content,
88
+ rank: r.rank
89
+ }));
90
+ return enforceBudget(mapped, budget);
91
+ }
@@ -0,0 +1,35 @@
1
+ import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
2
+ import { type SpawnFn } from '../shared/child-process.js';
3
+ export interface FetchRawInput {
4
+ url: string;
5
+ signal?: AbortSignal;
6
+ fetchAndClean?: typeof defaultFetchAndClean;
7
+ }
8
+ export interface FetchRawResult {
9
+ markdown: string;
10
+ finalUrl: string;
11
+ title: string;
12
+ }
13
+ export declare function fetchRaw(input: FetchRawInput): Promise<FetchRawResult>;
14
+ export interface FetchFocusedInput {
15
+ url: string;
16
+ query: string;
17
+ cwd: string;
18
+ signal?: AbortSignal;
19
+ fetchAndClean?: typeof defaultFetchAndClean;
20
+ spawn?: SpawnFn;
21
+ }
22
+ export interface FetchFocusedResult {
23
+ answer: string;
24
+ excerpt?: string;
25
+ excerptVerified?: boolean;
26
+ childExitCode: number;
27
+ aborted: boolean;
28
+ stderr: string;
29
+ stdout: string;
30
+ }
31
+ export declare function fetchFocused(input: FetchFocusedInput): Promise<FetchFocusedResult>;
32
+ export declare function formatResultText(parsed: {
33
+ answer: string;
34
+ excerpt?: string;
35
+ }, verified: boolean | undefined): string;
@@ -0,0 +1,91 @@
1
+ import { spawn as defaultSpawn } from 'node:child_process';
2
+ import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
3
+ import { getPiInvocation } from '../shared/pi-invocation.js';
4
+ import { CHILD_BASE_ARGS, runChild } from '../shared/child-process.js';
5
+ import { parseChildOutput, isExcerptInContent, formatResultText as formatResultTextShared } from '../shared/child-output.js';
6
+ const CONTENT_BUDGET = 30_000;
7
+ const HEAD_CHARS = 25_000;
8
+ const TAIL_CHARS = 5_000;
9
+ const TRUNCATION_MARKER = '\n\n[...page continues, truncated...]\n\n';
10
+ const CHILD_ARGS = [...CHILD_BASE_ARGS, '--no-tools'];
11
+ export async function fetchRaw(input) {
12
+ const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
13
+ const cleaned = await fetchAndCleanFn(input.url, { signal: input.signal });
14
+ return { markdown: cleaned.markdown, finalUrl: cleaned.finalUrl, title: cleaned.title };
15
+ }
16
+ export async function fetchFocused(input) {
17
+ const fetchAndCleanFn = input.fetchAndClean ?? defaultFetchAndClean;
18
+ const spawnFn = input.spawn ?? defaultSpawn;
19
+ const cleaned = await fetchAndCleanFn(input.url, { signal: input.signal });
20
+ const truncated = truncate(cleaned.markdown);
21
+ const prompt = buildPrompt({
22
+ query: input.query,
23
+ url: cleaned.finalUrl,
24
+ title: cleaned.title,
25
+ content: truncated
26
+ });
27
+ const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
28
+ const childResult = await runChild(spawnFn, invocation, input.cwd, input.signal);
29
+ if (childResult.aborted) {
30
+ return {
31
+ answer: '',
32
+ childExitCode: childResult.exitCode,
33
+ aborted: true,
34
+ stderr: childResult.stderr,
35
+ stdout: childResult.stdout
36
+ };
37
+ }
38
+ if (childResult.exitCode !== 0) {
39
+ return {
40
+ answer: '',
41
+ childExitCode: childResult.exitCode,
42
+ aborted: false,
43
+ stderr: childResult.stderr,
44
+ stdout: childResult.stdout
45
+ };
46
+ }
47
+ const parsed = parseChildOutput(childResult.stdout);
48
+ const excerptVerified = parsed.excerpt ? isExcerptInContent(parsed.excerpt, cleaned.markdown) : undefined;
49
+ return {
50
+ answer: parsed.answer,
51
+ excerpt: parsed.excerpt,
52
+ excerptVerified,
53
+ childExitCode: 0,
54
+ aborted: false,
55
+ stderr: childResult.stderr,
56
+ stdout: childResult.stdout
57
+ };
58
+ }
59
+ function truncate(md) {
60
+ if (md.length <= CONTENT_BUDGET)
61
+ return md;
62
+ return md.slice(0, HEAD_CHARS) + TRUNCATION_MARKER + md.slice(md.length - TAIL_CHARS);
63
+ }
64
+ function buildPrompt(args) {
65
+ return (`You extract a single piece of information from a web page to answer one question.\n`
66
+ + `\n`
67
+ + `Rules:\n`
68
+ + `1. Output ONLY two tags, in this order, with NO text outside them:\n`
69
+ + ` <answer>...your answer...</answer>\n`
70
+ + ` <excerpt>...verbatim quote from <page-content>...</excerpt>\n`
71
+ + `2. The <excerpt> MUST be copied character-for-character from <page-content>.\n`
72
+ + ` Do not paraphrase, translate, or summarise inside <excerpt>.\n`
73
+ + `3. Distinguish content from UI: button labels, player widgets, status indicators,\n`
74
+ + ` navigation, breadcrumbs, and footers are NOT the answer unless the question is\n`
75
+ + ` specifically about page UI.\n`
76
+ + `4. If the page is not in English, write the <answer> in English (translate key\n`
77
+ + ` non-English terms) and keep the original-language text in <excerpt>.\n`
78
+ + `5. If the answer is unclear, ambiguous, or absent from <page-content>, write\n`
79
+ + ` exactly: <answer>unclear from this page</answer> and put the closest related\n`
80
+ + ` text in <excerpt>. Do not guess.\n`
81
+ + `6. Be terse. One short paragraph in <answer> max.\n`
82
+ + `\n`
83
+ + `<question>${args.query}</question>\n`
84
+ + `<url>${args.url}</url>\n`
85
+ + `<page-title>${args.title}</page-title>\n`
86
+ + `<page-content>\n${args.content}\n</page-content>\n`);
87
+ }
88
+ // ─── Thin wrapper: fetch-core formatResultText (no header) ───────────────────
89
+ export function formatResultText(parsed, verified) {
90
+ return formatResultTextShared('', parsed, verified);
91
+ }
@@ -0,0 +1,17 @@
1
+ export interface CleanResult {
2
+ title: string;
3
+ markdown: string;
4
+ finalUrl: string;
5
+ }
6
+ export declare function cleanHtml(html: string, baseUrl: string): CleanResult;
7
+ export declare class FetchAndCleanError extends Error {
8
+ readonly kind: 'invalid-url' | 'http-error' | 'not-html' | 'too-large' | 'network' | 'aborted';
9
+ readonly cause?: unknown | undefined;
10
+ constructor(message: string, kind: 'invalid-url' | 'http-error' | 'not-html' | 'too-large' | 'network' | 'aborted', cause?: unknown | undefined);
11
+ }
12
+ export interface FetchAndCleanOpts {
13
+ timeoutMs?: number;
14
+ maxBytes?: number;
15
+ signal?: AbortSignal;
16
+ }
17
+ export declare function fetchAndClean(url: string, opts?: FetchAndCleanOpts): Promise<CleanResult>;
@@ -0,0 +1,142 @@
1
+ import { JSDOM } from 'jsdom';
2
+ import { Readability } from '@mozilla/readability';
3
+ import TurndownService from 'turndown';
4
+ const turndown = new TurndownService({
5
+ codeBlockStyle: 'fenced',
6
+ headingStyle: 'atx',
7
+ bulletListMarker: '-'
8
+ });
9
+ export function cleanHtml(html, baseUrl) {
10
+ const dom = new JSDOM(html, { url: baseUrl });
11
+ const reader = new Readability(dom.window.document);
12
+ const parsed = reader.parse();
13
+ if (parsed && parsed.content) {
14
+ return {
15
+ title: parsed.title || dom.window.document.title || new URL(baseUrl).hostname,
16
+ markdown: turndown.turndown(parsed.content).trim(),
17
+ finalUrl: baseUrl
18
+ };
19
+ }
20
+ // Fallback: turndown the body
21
+ const body = dom.window.document.body;
22
+ const bodyHtml = body ? body.innerHTML : '';
23
+ const markdown = turndown.turndown(bodyHtml).trim();
24
+ return {
25
+ title: dom.window.document.title || new URL(baseUrl).hostname,
26
+ markdown,
27
+ finalUrl: baseUrl
28
+ };
29
+ }
30
+ const DEFAULT_TIMEOUT_MS = 15_000;
31
+ const DEFAULT_MAX_BYTES = 2 * 1024 * 1024; // 2 MB
32
+ const PKG_VERSION = '0.2.0'; // bump in lockstep with package.json on release
33
+ const USER_AGENT = `pi-worker/${PKG_VERSION} (+https://npmjs.com/package/@mjasnikovs/pi-worker)`;
34
+ export class FetchAndCleanError extends Error {
35
+ kind;
36
+ cause;
37
+ constructor(message, kind, cause) {
38
+ super(message);
39
+ this.kind = kind;
40
+ this.cause = cause;
41
+ this.name = 'FetchAndCleanError';
42
+ }
43
+ }
44
+ export async function fetchAndClean(url, opts = {}) {
45
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
46
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
47
+ const internalController = new AbortController();
48
+ let sizeExceeded = false;
49
+ let userAborted = false;
50
+ const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
51
+ const onUserAbort = () => {
52
+ userAborted = true;
53
+ internalController.abort();
54
+ };
55
+ if (opts.signal) {
56
+ if (opts.signal.aborted)
57
+ onUserAbort();
58
+ else
59
+ opts.signal.addEventListener('abort', onUserAbort, { once: true });
60
+ }
61
+ try {
62
+ let response;
63
+ try {
64
+ response = await fetch(url, {
65
+ headers: { 'user-agent': USER_AGENT },
66
+ redirect: 'follow',
67
+ signal: internalController.signal
68
+ });
69
+ }
70
+ catch (err) {
71
+ if (userAborted) {
72
+ throw new FetchAndCleanError('Fetch aborted.', 'aborted', err);
73
+ }
74
+ throw new FetchAndCleanError(`Could not fetch ${url}: ${describeError(err)}`, 'network', err);
75
+ }
76
+ if (!response.ok) {
77
+ throw new FetchAndCleanError(`Fetch failed: HTTP ${response.status} ${response.statusText} for ${url}`, 'http-error');
78
+ }
79
+ const contentType = response.headers.get('content-type') ?? '';
80
+ if (!contentType.toLowerCase().includes('text/html')) {
81
+ throw new FetchAndCleanError(`${url} is ${contentType || 'unknown content type'}, not HTML. pi-worker-fetch only reads HTML pages.`, 'not-html');
82
+ }
83
+ const reader = response.body?.getReader();
84
+ if (!reader) {
85
+ throw new FetchAndCleanError(`Could not fetch ${url}: empty response body`, 'network');
86
+ }
87
+ const decoder = new TextDecoder('utf-8', { fatal: false });
88
+ let html = '';
89
+ let bytesRead = 0;
90
+ try {
91
+ while (true) {
92
+ const { value, done } = await reader.read();
93
+ if (done)
94
+ break;
95
+ if (value) {
96
+ bytesRead += value.byteLength;
97
+ if (bytesRead > maxBytes) {
98
+ sizeExceeded = true;
99
+ internalController.abort();
100
+ break;
101
+ }
102
+ html += decoder.decode(value, { stream: true });
103
+ }
104
+ }
105
+ html += decoder.decode();
106
+ }
107
+ catch (err) {
108
+ if (sizeExceeded) {
109
+ // fall through to throw outside the catch
110
+ }
111
+ else if (userAborted) {
112
+ throw new FetchAndCleanError('Fetch aborted.', 'aborted', err);
113
+ }
114
+ else {
115
+ throw new FetchAndCleanError(`Could not fetch ${url}: ${describeError(err)}`, 'network', err);
116
+ }
117
+ }
118
+ if (sizeExceeded) {
119
+ throw new FetchAndCleanError(`${url} exceeds ${formatBytes(maxBytes)} size cap. Try a more specific URL.`, 'too-large');
120
+ }
121
+ const finalUrl = response.url || url;
122
+ const cleaned = cleanHtml(html, finalUrl);
123
+ return cleaned;
124
+ }
125
+ finally {
126
+ clearTimeout(timeoutHandle);
127
+ if (opts.signal)
128
+ opts.signal.removeEventListener('abort', onUserAbort);
129
+ }
130
+ }
131
+ function describeError(err) {
132
+ if (err instanceof Error)
133
+ return err.message;
134
+ return String(err);
135
+ }
136
+ function formatBytes(n) {
137
+ if (n >= 1024 * 1024)
138
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
139
+ if (n >= 1024)
140
+ return `${(n / 1024).toFixed(0)} KB`;
141
+ return `${n} B`;
142
+ }
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ export declare function registerWorkers(pi: ExtensionAPI): void;
@@ -0,0 +1,10 @@
1
+ import { registerPiWorker } from './pi-worker.js';
2
+ import { registerPiWorkerSearch } from './pi-worker-search.js';
3
+ import { registerPiWorkerFetch } from './pi-worker-fetch.js';
4
+ import { registerPiWorkerDocs } from './pi-worker-docs.js';
5
+ export function registerWorkers(pi) {
6
+ registerPiWorker(pi);
7
+ registerPiWorkerSearch(pi);
8
+ registerPiWorkerFetch(pi);
9
+ registerPiWorkerDocs(pi);
10
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Live npm registry version lookup.
3
+ *
4
+ * Solves a real failure mode: the auto-answer/research workers have no live
5
+ * source for "what's the latest published version of X", so they answer from
6
+ * training data, which goes stale. TASK_0005 hit this — the worker said
7
+ * "18.3.1, the latest stable React" when React 19.x had shipped months earlier.
8
+ *
9
+ * This module fetches the npm registry's metadata endpoint and returns just
10
+ * enough to anchor the worker in current reality: the dist-tag 'latest', a
11
+ * short list of recent versions, and the publish date of latest.
12
+ */
13
+ export interface NpmVersionInfo {
14
+ pkg: string;
15
+ latest: string;
16
+ recent: string[];
17
+ publishedAt?: string;
18
+ }
19
+ export interface NpmVersionOpts {
20
+ timeoutMs?: number;
21
+ signal?: AbortSignal;
22
+ registry?: string;
23
+ }
24
+ /**
25
+ * Fetch the latest version + recent version list for an npm package.
26
+ * Returns `null` (never throws) on any failure — registry down, package not
27
+ * found, network error, malformed response. The caller treats null as "no
28
+ * fresh data available" and continues without it.
29
+ */
30
+ export declare function npmVersionLookup(pkg: string, opts?: NpmVersionOpts): Promise<NpmVersionInfo | null>;
31
+ /** Format an NpmVersionInfo as a short Markdown block for EXTERNAL CONTEXT. */
32
+ export declare function formatNpmVersionSection(info: NpmVersionInfo): string;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Live npm registry version lookup.
3
+ *
4
+ * Solves a real failure mode: the auto-answer/research workers have no live
5
+ * source for "what's the latest published version of X", so they answer from
6
+ * training data, which goes stale. TASK_0005 hit this — the worker said
7
+ * "18.3.1, the latest stable React" when React 19.x had shipped months earlier.
8
+ *
9
+ * This module fetches the npm registry's metadata endpoint and returns just
10
+ * enough to anchor the worker in current reality: the dist-tag 'latest', a
11
+ * short list of recent versions, and the publish date of latest.
12
+ */
13
+ const REGISTRY_BASE = 'https://registry.npmjs.org';
14
+ const DEFAULT_TIMEOUT_MS = 3000;
15
+ const RECENT_VERSIONS_LIMIT = 10;
16
+ /**
17
+ * Fetch the latest version + recent version list for an npm package.
18
+ * Returns `null` (never throws) on any failure — registry down, package not
19
+ * found, network error, malformed response. The caller treats null as "no
20
+ * fresh data available" and continues without it.
21
+ */
22
+ export async function npmVersionLookup(pkg, opts = {}) {
23
+ if (!isValidPackageName(pkg))
24
+ return null;
25
+ const base = opts.registry ?? REGISTRY_BASE;
26
+ const url = `${base}/${encodePackageName(pkg)}`;
27
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28
+ const internalController = new AbortController();
29
+ const timeoutHandle = setTimeout(() => internalController.abort(), timeoutMs);
30
+ const onUserAbort = () => internalController.abort();
31
+ if (opts.signal) {
32
+ if (opts.signal.aborted)
33
+ onUserAbort();
34
+ else
35
+ opts.signal.addEventListener('abort', onUserAbort, { once: true });
36
+ }
37
+ try {
38
+ let response;
39
+ try {
40
+ response = await fetch(url, {
41
+ method: 'GET',
42
+ headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
43
+ signal: internalController.signal
44
+ });
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ if (!response.ok)
50
+ return null;
51
+ let body;
52
+ try {
53
+ body = (await response.json());
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ const latest = body['dist-tags']?.latest;
59
+ if (typeof latest !== 'string' || latest.length === 0)
60
+ return null;
61
+ const allVersions = Object.keys(body.versions ?? {});
62
+ const recent = allVersions.slice(-RECENT_VERSIONS_LIMIT).reverse();
63
+ const publishedAtRaw = body.time?.[latest];
64
+ const publishedAt = typeof publishedAtRaw === 'string' ? publishedAtRaw : undefined;
65
+ return { pkg, latest, recent, publishedAt };
66
+ }
67
+ finally {
68
+ clearTimeout(timeoutHandle);
69
+ if (opts.signal)
70
+ opts.signal.removeEventListener('abort', onUserAbort);
71
+ }
72
+ }
73
+ /** Format an NpmVersionInfo as a short Markdown block for EXTERNAL CONTEXT. */
74
+ export function formatNpmVersionSection(info) {
75
+ const lines = [`### npm: ${info.pkg}`, `latest: ${info.latest}`];
76
+ if (info.publishedAt) {
77
+ const date = info.publishedAt.slice(0, 10);
78
+ lines[1] += ` (published ${date})`;
79
+ }
80
+ if (info.recent.length > 0) {
81
+ lines.push(`recent: ${info.recent.join(', ')}`);
82
+ }
83
+ return lines.join('\n');
84
+ }
85
+ function isValidPackageName(pkg) {
86
+ if (pkg.length === 0 || pkg.length > 214)
87
+ return false;
88
+ if (pkg.startsWith('.') || pkg.startsWith('_'))
89
+ return false;
90
+ return /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i.test(pkg);
91
+ }
92
+ function encodePackageName(pkg) {
93
+ if (pkg.startsWith('@')) {
94
+ const slash = pkg.indexOf('/');
95
+ if (slash > 0) {
96
+ return (encodeURIComponent(pkg.slice(0, slash))
97
+ + '/'
98
+ + encodeURIComponent(pkg.slice(slash + 1)));
99
+ }
100
+ }
101
+ return encodeURIComponent(pkg);
102
+ }
@@ -0,0 +1,28 @@
1
+ import { type SpawnFn } from '../shared/child-process.js';
2
+ export interface RunWorkerInput {
3
+ prompt: string;
4
+ cwd: string;
5
+ signal?: AbortSignal;
6
+ spawn?: SpawnFn;
7
+ /** Comma-separated tool whitelist passed to `pi --tools`. Defaults to read,grep,find,ls. */
8
+ tools?: string;
9
+ }
10
+ export interface RunWorkerResult {
11
+ text: string;
12
+ exitCode: number;
13
+ stderr: string;
14
+ aborted: boolean;
15
+ /**
16
+ * Milliseconds between spawn and the child's first stdout chunk. When
17
+ * multiple workers run concurrently and the upstream model API queues at
18
+ * some concurrency cap, this is the queue-wait portion of the run.
19
+ */
20
+ waitMs: number;
21
+ /**
22
+ * Milliseconds between first stdout chunk and process exit — the
23
+ * generation/tool-call portion, independent of queue wait. Equals total
24
+ * elapsed when the child never produced output.
25
+ */
26
+ workMs: number;
27
+ }
28
+ export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
@@ -0,0 +1,29 @@
1
+ import { getPiInvocation } from '../shared/pi-invocation.js';
2
+ import { CHILD_BASE_ARGS, runChildDefault } from '../shared/child-process.js';
3
+ // `--mode json` makes pi emit structured events as they happen instead of
4
+ // buffering the assistant text and flushing on exit. That matters for the
5
+ // wait/work timing split: in text mode the first stdout chunk only arrives at
6
+ // the very end, so onFirstByte fires moments before close and workMs is
7
+ // effectively zero. With JSON events the first byte lands as soon as the
8
+ // model starts producing — making waitMs the real queue/cold-start cost and
9
+ // workMs the real generation+tool-call cost.
10
+ const DEFAULT_TOOLS = 'read,grep,find,ls';
11
+ export async function runWorker(input) {
12
+ const tools = input.tools ?? DEFAULT_TOOLS;
13
+ const childArgs = [...CHILD_BASE_ARGS, '--mode', 'json', '--tools', tools];
14
+ const invocation = getPiInvocation([...childArgs, input.prompt]);
15
+ const tStart = Date.now();
16
+ let tFirstByte = null;
17
+ const result = await runChildDefault(invocation, input.cwd, input.signal, { mode: 'json-events', onFirstByte: () => (tFirstByte = Date.now()) }, input.spawn);
18
+ const tEnd = Date.now();
19
+ const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
20
+ const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
21
+ return {
22
+ text: result.text ?? '',
23
+ exitCode: result.exitCode,
24
+ stderr: result.stderr.trim(),
25
+ aborted: result.aborted,
26
+ waitMs,
27
+ workMs
28
+ };
29
+ }
@@ -0,0 +1,16 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { openCache as defaultOpenCache } from './docs-cache.js';
3
+ import { ensureIndexed as defaultEnsureIndexed } from './docs-index.js';
4
+ import { resolvePackage as defaultResolvePackage } from './docs-resolve.js';
5
+ import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
+ import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
7
+ import { type SpawnFn } from '../shared/child-process.js';
8
+ export interface PiWorkerDocsInternals {
9
+ resolvePackage?: typeof defaultResolvePackage;
10
+ ensureIndexed?: typeof defaultEnsureIndexed;
11
+ retrieveChunks?: typeof defaultRetrieveChunks;
12
+ openCache?: typeof defaultOpenCache;
13
+ spawn?: SpawnFn;
14
+ npmVersionLookup?: typeof defaultNpmVersionLookup;
15
+ }
16
+ export declare function registerPiWorkerDocs(pi: ExtensionAPI, internals?: PiWorkerDocsInternals): void;