@mjasnikovs/pi-task 0.10.2 → 0.11.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.
@@ -38,6 +38,8 @@ interface PhaseDeps {
38
38
  */
39
39
  recordSubStep?: (label: string, ms: number) => void;
40
40
  spawn?: SpawnFn;
41
+ /** Write a timestamped line to the per-task debug log. Fire-and-forget. */
42
+ logDebug?: (msg: string) => void;
41
43
  }
42
44
  export type { PhaseDeps };
43
45
  /**
@@ -164,6 +164,13 @@ export class TaskRunner {
164
164
  // any phase work — and recover it if the session dies mid-pipeline.
165
165
  if (this._onStart)
166
166
  await this._onStart(id);
167
+ // Wire up per-task debug log (<cwd>/.pi-tasks/TASK_XXXX-debug.log).
168
+ const debugLogPath = path.join(tasksDir(cwd), `${id}-debug.log`);
169
+ this._deps.logDebug = (msg) => {
170
+ const line = `${new Date().toISOString()} ${msg}\n`;
171
+ fsp.appendFile(debugLogPath, line).catch(() => { });
172
+ };
173
+ this._deps.logDebug(`run: start phase=${resumePhase}`);
167
174
  // Register as active.
168
175
  this._widgetState.taskId = id;
169
176
  this._widgetState.title = title;
@@ -194,6 +201,7 @@ export class TaskRunner {
194
201
  continue;
195
202
  }
196
203
  await advance(phase.name);
204
+ this._deps.logDebug?.(`phase:${phase.name}: start`);
197
205
  const children = [];
198
206
  this._currentPhaseChildren = children;
199
207
  const phaseStart = Date.now();
@@ -202,12 +210,10 @@ export class TaskRunner {
202
210
  out = await phase.run(this._deps, this._pc);
203
211
  }
204
212
  finally {
205
- this._timings.push({
206
- label: phase.name,
207
- ms: Date.now() - phaseStart,
208
- children
209
- });
213
+ const phaseMs = Date.now() - phaseStart;
214
+ this._timings.push({ label: phase.name, ms: phaseMs, children });
210
215
  this._currentPhaseChildren = null;
216
+ this._deps.logDebug?.(`phase:${phase.name}: done ms=${phaseMs}`);
211
217
  }
212
218
  await setTaskSection(cwd, id, phase.section, out);
213
219
  this._pc[phase.field] = out;
@@ -74,6 +74,7 @@ export async function phaseVerifyTooling(deps, research) {
74
74
  await setTaskSection(deps.cwd, deps.taskId, 'verified tooling', verifiedSection);
75
75
  return replaceToolingWithVerified(research, parsed.verified);
76
76
  }
77
+ const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.url).pathname;
77
78
  export async function phaseResearch(deps, refined, researchDeps = {}) {
78
79
  const docsRawFn = researchDeps.docsRaw ?? docsRaw;
79
80
  const fetchRawFn = researchDeps.fetchRaw ?? fetchRaw;
@@ -192,7 +193,12 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
192
193
  label: 'worker:files',
193
194
  prompt: appendNoThink(promptHeader + RESEARCH_FILES_PROMPT(refined))
194
195
  },
195
- { label: 'worker:apis', prompt: appendNoThink(promptHeader + RESEARCH_APIS_PROMPT(refined)) },
196
+ {
197
+ label: 'worker:apis',
198
+ prompt: appendNoThink(promptHeader + RESEARCH_APIS_PROMPT(refined)),
199
+ tools: 'read,grep,find,ls,pi-worker-docs',
200
+ extensions: [DOCS_EXTENSION_PATH]
201
+ },
196
202
  {
197
203
  label: 'worker:context',
198
204
  prompt: appendNoThink(promptHeader + RESEARCH_CONTEXT_PROMPT(refined)),
@@ -209,13 +215,22 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
209
215
  ];
210
216
  const workerResults = [];
211
217
  for (const spec of workerSpecs) {
218
+ deps.logDebug?.(`${spec.label}: start`);
212
219
  const r = await recordWorker(spec.label, runWorker({
213
220
  prompt: spec.prompt,
214
221
  cwd: deps.cwd,
215
222
  signal: deps.signal,
216
223
  spawn: deps.spawn,
217
- ...(spec.tools ? { tools: spec.tools } : {})
224
+ ...(spec.tools ? { tools: spec.tools } : {}),
225
+ ...(spec.extensions ? { extensions: spec.extensions } : {}),
226
+ onLine: line => {
227
+ deps.logDebug?.(`${spec.label}: ${line}`);
228
+ deps.onChildOutput?.(`${spec.label}: ${line}`);
229
+ }
218
230
  }));
231
+ deps.logDebug?.(`${spec.label}: done exit=${r.exitCode} wait=${r.waitMs}ms work=${r.workMs}ms`
232
+ + (r.stderr ? ` stderr=${r.stderr.slice(0, 300)}` : '')
233
+ + (r.leakedToolCall ? ` leaked=${r.leakedToolCall.trim().slice(0, 80)}` : ''));
219
234
  updateProgress();
220
235
  workerResults.push(r);
221
236
  }
@@ -88,7 +88,9 @@ No section header. No other sections. No preamble.
88
88
 
89
89
  Task:
90
90
  ${refined}`;
91
- const RESEARCH_APIS_PROMPT = (refined) => `You are doing targeted research for an AI coding agent. Use the read, grep, find, and ls tools to identify the commands, functions, types, and interfaces the agent will use for the following task.
91
+ const RESEARCH_APIS_PROMPT = (refined) => `You are doing targeted research for an AI coding agent. Use the read, grep, find, and ls tools — and \`pi-worker-docs\` for installed npm packages — to identify the commands, functions, types, and interfaces the agent will use for the following task.
92
+
93
+ NPM PACKAGES — use pi-worker-docs, NOT file reads: for any third-party npm package (e.g. "zod", "hono", "drizzle-orm"), call \`pi-worker-docs(module, query)\` to get its type signatures and API surface. Do NOT open node_modules source files directly — those reads are expensive and produce far more noise than the tool. The tool returns a compact, focused excerpt in a fraction of the token cost.
92
94
 
93
95
  APIS owns symbols and commands BY NAME ONLY. Do NOT include any file path or path fragment — no \`package.json\`, no \`./src/foo.ts\`, no \`package.json#scripts.lint\`. If the symbol is a script defined in package.json, write the invocation (\`npm run lint\`), not its location. If the symbol is a config file, it does not belong in APIS at all — it belongs in FILES.
94
96
 
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ export default function (pi: ExtensionAPI): void;
@@ -0,0 +1,4 @@
1
+ import { registerPiWorkerDocs } from './pi-worker-docs.js';
2
+ export default function (pi) {
3
+ registerPiWorkerDocs(pi);
4
+ }
@@ -0,0 +1,38 @@
1
+ import type { CacheHandle } from './docs-cache.js';
2
+ import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
3
+ import type { RetrievedChunk } from './docs-retrieve.js';
4
+ export declare function getProjectName(cwd: string): string;
5
+ export declare function cwdKey(cwd: string): string;
6
+ export declare function getProjectFiles(cwd: string): string[];
7
+ export declare function getMaxMtime(files: string[]): string;
8
+ export interface ProjectIndexResult {
9
+ hitCache: boolean;
10
+ filesIngested: number;
11
+ chunksWritten: number;
12
+ indexingMs?: number;
13
+ }
14
+ export declare function ensureProjectIndexed(cache: CacheHandle, name: string, version: string, files: string[], cwd: string): ProjectIndexResult;
15
+ export type ProjectDocsRawResult = {
16
+ kind: 'ok';
17
+ projectName: string;
18
+ cacheKey: string;
19
+ version: string;
20
+ chunks: RetrievedChunk[];
21
+ hitCache: boolean;
22
+ filesIngested: number;
23
+ chunksWritten: number;
24
+ indexingMs?: number;
25
+ } | {
26
+ kind: 'no_chunks';
27
+ projectName: string;
28
+ cacheKey: string;
29
+ version: string;
30
+ hitCache: boolean;
31
+ filesIngested: number;
32
+ } | {
33
+ kind: 'error';
34
+ projectName: string;
35
+ message: string;
36
+ };
37
+ export declare function projectDocsRaw(cache: CacheHandle, cwd: string, query: string, retrieveChunksFn?: typeof defaultRetrieveChunks): ProjectDocsRawResult;
38
+ export declare function buildProjectPrompt(projectName: string, query: string, content: string): string;
@@ -0,0 +1,249 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
+ const MAX_CHUNK_BYTES = 8 * 1024;
7
+ const DEFAULT_LIMIT = 50;
8
+ const DEFAULT_BUDGET = 24_000;
9
+ const DECL_SPLIT_RE = /^(?:export\s+|declare\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|namespace|module|const|let|var|enum)\s+/m;
10
+ export function getProjectName(cwd) {
11
+ try {
12
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
13
+ if (pkg.name)
14
+ return pkg.name;
15
+ }
16
+ catch { }
17
+ return path.basename(cwd);
18
+ }
19
+ export function cwdKey(cwd) {
20
+ return createHash('sha256').update(cwd).digest('hex').slice(0, 8);
21
+ }
22
+ export function getProjectFiles(cwd) {
23
+ try {
24
+ const result = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '*.ts', '*.tsx'], { cwd, encoding: 'utf8', timeout: 5000 });
25
+ if (result.status === 0 && result.stdout?.trim()) {
26
+ return result.stdout
27
+ .trim()
28
+ .split('\n')
29
+ .filter(Boolean)
30
+ .map(f => path.join(cwd, f));
31
+ }
32
+ }
33
+ catch { }
34
+ return walkTsFiles(cwd);
35
+ }
36
+ function walkTsFiles(root) {
37
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
38
+ const out = [];
39
+ const stack = [root];
40
+ while (stack.length) {
41
+ const dir = stack.pop();
42
+ let entries;
43
+ try {
44
+ entries = fs.readdirSync(dir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ for (const entry of entries) {
50
+ if (SKIP.has(entry.name))
51
+ continue;
52
+ const full = path.join(dir, entry.name);
53
+ if (entry.isDirectory())
54
+ stack.push(full);
55
+ else if (entry.isFile() &&
56
+ (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
57
+ out.push(full);
58
+ }
59
+ }
60
+ }
61
+ return out.sort();
62
+ }
63
+ export function getMaxMtime(files) {
64
+ let max = 0;
65
+ for (const f of files) {
66
+ try {
67
+ const { mtimeMs } = fs.statSync(f);
68
+ if (mtimeMs > max)
69
+ max = mtimeMs;
70
+ }
71
+ catch { }
72
+ }
73
+ return String(Math.floor(max));
74
+ }
75
+ function chunkTs(content, relPath) {
76
+ const splits = splitAtMatches(content, new RegExp(DECL_SPLIT_RE.source, 'gm'));
77
+ const chunks = [];
78
+ for (const part of splits) {
79
+ const trimmed = part.trim();
80
+ if (!trimmed)
81
+ continue;
82
+ const prefixed = `// ${relPath}\n${trimmed}`;
83
+ if (Buffer.byteLength(prefixed, 'utf8') > MAX_CHUNK_BYTES) {
84
+ for (const slice of sliceBytes(prefixed, MAX_CHUNK_BYTES))
85
+ chunks.push(slice);
86
+ }
87
+ else {
88
+ chunks.push(prefixed);
89
+ }
90
+ }
91
+ return chunks;
92
+ }
93
+ function splitAtMatches(text, re) {
94
+ const parts = [];
95
+ let lastIndex = 0;
96
+ let m;
97
+ while ((m = re.exec(text))) {
98
+ if (m.index > lastIndex)
99
+ parts.push(text.slice(lastIndex, m.index));
100
+ lastIndex = m.index;
101
+ re.lastIndex = m.index + 1;
102
+ }
103
+ if (lastIndex < text.length)
104
+ parts.push(text.slice(lastIndex));
105
+ return parts.length ? parts : [text];
106
+ }
107
+ function sliceBytes(s, maxBytes) {
108
+ const out = [];
109
+ let buf = Buffer.from(s, 'utf8');
110
+ while (buf.length > maxBytes) {
111
+ const slice = buf.subarray(0, maxBytes).toString('utf8');
112
+ out.push(slice);
113
+ buf = buf.subarray(Buffer.byteLength(slice, 'utf8'));
114
+ }
115
+ if (buf.length)
116
+ out.push(buf.toString('utf8'));
117
+ return out;
118
+ }
119
+ export function ensureProjectIndexed(cache, name, version, files, cwd) {
120
+ const existing = cache.db
121
+ .prepare('SELECT content_hash FROM packages WHERE name = ? AND version = ?')
122
+ .get(name, version);
123
+ if (existing)
124
+ return { hitCache: true, filesIngested: 0, chunksWritten: 0 };
125
+ const t0 = Date.now();
126
+ cache.db.exec('BEGIN IMMEDIATE');
127
+ try {
128
+ // Clear all old versions of this project
129
+ cache.db.prepare('DELETE FROM chunks WHERE name = ?').run(name);
130
+ cache.db.prepare('DELETE FROM packages WHERE name = ?').run(name);
131
+ const insertChunk = cache.db.prepare('INSERT INTO chunks (name, version, file_path, kind, content) VALUES (?, ?, ?, ?, ?)');
132
+ let filesIngested = 0;
133
+ let chunksWritten = 0;
134
+ for (const abs of files) {
135
+ let raw;
136
+ try {
137
+ raw = fs.readFileSync(abs, 'utf8');
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ const rel = path.relative(cwd, abs);
143
+ const chunks = chunkTs(raw, rel);
144
+ if (!chunks.length)
145
+ continue;
146
+ filesIngested++;
147
+ for (const c of chunks) {
148
+ insertChunk.run(name, version, rel, 'dts', c);
149
+ chunksWritten++;
150
+ }
151
+ }
152
+ cache.db
153
+ .prepare('INSERT OR REPLACE INTO packages (name, version, content_hash, indexed_at) VALUES (?, ?, ?, ?)')
154
+ .run(name, version, version, Date.now());
155
+ cache.db.exec('COMMIT');
156
+ return { hitCache: false, filesIngested, chunksWritten, indexingMs: Date.now() - t0 };
157
+ }
158
+ catch (err) {
159
+ cache.db.exec('ROLLBACK');
160
+ throw err;
161
+ }
162
+ }
163
+ export function projectDocsRaw(cache, cwd, query, retrieveChunksFn = defaultRetrieveChunks) {
164
+ const projectName = getProjectName(cwd);
165
+ const cacheKey = `project:${cwdKey(cwd)}`;
166
+ const files = getProjectFiles(cwd);
167
+ const version = getMaxMtime(files);
168
+ let indexResult;
169
+ try {
170
+ indexResult = ensureProjectIndexed(cache, cacheKey, version, files, cwd);
171
+ }
172
+ catch (err) {
173
+ return {
174
+ kind: 'error',
175
+ projectName,
176
+ message: `Indexing failed: ${err instanceof Error ? err.message : String(err)}`
177
+ };
178
+ }
179
+ const chunkCount = cache.db
180
+ .prepare('SELECT count(*) AS c FROM chunks WHERE name = ? AND version = ?')
181
+ .get(cacheKey, version)?.c ?? 0;
182
+ if (chunkCount === 0) {
183
+ return {
184
+ kind: 'no_chunks',
185
+ projectName,
186
+ cacheKey,
187
+ version,
188
+ hitCache: indexResult.hitCache,
189
+ filesIngested: indexResult.filesIngested
190
+ };
191
+ }
192
+ let chunks;
193
+ try {
194
+ chunks = retrieveChunksFn(cache, {
195
+ name: cacheKey,
196
+ version,
197
+ query,
198
+ limit: DEFAULT_LIMIT,
199
+ contentBudget: DEFAULT_BUDGET
200
+ });
201
+ }
202
+ catch (err) {
203
+ return {
204
+ kind: 'error',
205
+ projectName,
206
+ message: `Retrieval failed: ${err instanceof Error ? err.message : String(err)}`
207
+ };
208
+ }
209
+ if (chunks.length === 0) {
210
+ return {
211
+ kind: 'no_chunks',
212
+ projectName,
213
+ cacheKey,
214
+ version,
215
+ hitCache: indexResult.hitCache,
216
+ filesIngested: indexResult.filesIngested
217
+ };
218
+ }
219
+ return {
220
+ kind: 'ok',
221
+ projectName,
222
+ cacheKey,
223
+ version,
224
+ chunks,
225
+ hitCache: indexResult.hitCache,
226
+ filesIngested: indexResult.filesIngested,
227
+ chunksWritten: indexResult.chunksWritten,
228
+ indexingMs: indexResult.indexingMs
229
+ };
230
+ }
231
+ export function buildProjectPrompt(projectName, query, content) {
232
+ return (`You answer one question about a local project's source code, using only the provided content.\n`
233
+ + `\n`
234
+ + `Rules:\n`
235
+ + `1. Output ONLY two tags, in this order, with NO text outside them:\n`
236
+ + ` <answer>...your answer...</answer>\n`
237
+ + ` <excerpt>...verbatim quote from <project-content>...</excerpt>\n`
238
+ + `2. The <excerpt> MUST be copied character-for-character from <project-content>.\n`
239
+ + ` Do not paraphrase, translate, or summarise inside <excerpt>.\n`
240
+ + `3. Prefer type signatures, function declarations, and code blocks as evidence over prose.\n`
241
+ + `4. If the answer is unclear, ambiguous, or absent from <project-content>, write exactly:\n`
242
+ + ` <answer>unclear from this project</answer> and put the closest related text in <excerpt>.\n`
243
+ + ` Do not guess.\n`
244
+ + `5. Be terse. One short paragraph in <answer> max.\n`
245
+ + `\n`
246
+ + `<project>${projectName}</project>\n`
247
+ + `<question>${query}</question>\n`
248
+ + `<project-content>\n${content}\n</project-content>\n`);
249
+ }
@@ -1,4 +1,4 @@
1
- const DEFAULT_LIMIT = 8;
1
+ const DEFAULT_LIMIT = 50;
2
2
  const DEFAULT_BUDGET = 24_000;
3
3
  const MIN_TOKEN_LEN = 2;
4
4
  const FALLBACK_DTS_CHARS = 12_000;
@@ -6,6 +6,10 @@ export interface RunWorkerInput {
6
6
  spawn?: SpawnFn;
7
7
  /** Comma-separated tool whitelist passed to `pi --tools`. Defaults to read,grep,find,ls. */
8
8
  tools?: string;
9
+ /** Extension entry-point paths to load via `-e <path>` before CHILD_BASE_ARGS. */
10
+ extensions?: string[];
11
+ /** Called for each tool execution start and text-writing event inside the worker. */
12
+ onLine?: (line: string) => void;
9
13
  }
10
14
  export interface RunWorkerResult {
11
15
  text: string;
@@ -12,7 +12,8 @@ import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../s
12
12
  const DEFAULT_TOOLS = 'read,grep,find,ls';
13
13
  export async function runWorker(input) {
14
14
  const tools = input.tools ?? DEFAULT_TOOLS;
15
- const baseArgs = [...CHILD_BASE_ARGS, '--mode', 'json', '--tools', tools];
15
+ const extensionArgs = (input.extensions ?? []).flatMap(e => ['-e', e]);
16
+ const baseArgs = [...extensionArgs, ...CHILD_BASE_ARGS, '--mode', 'json', '--tools', tools];
16
17
  let hint = null;
17
18
  for (let attempt = 0;; attempt++) {
18
19
  const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
@@ -23,7 +24,8 @@ export async function runWorker(input) {
23
24
  const result = await runChildDefault(invocation, input.cwd, input.signal, {
24
25
  mode: 'json-events',
25
26
  onFirstByte: () => (tFirstByte = Date.now()),
26
- onToolCall: call => loopDetector.record(call)
27
+ onToolCall: call => loopDetector.record(call),
28
+ onLine: input.onLine
27
29
  }, input.spawn);
28
30
  const tEnd = Date.now();
29
31
  const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
@@ -1,19 +1,22 @@
1
1
  import { Type } from '@sinclair/typebox';
2
2
  import { Text } from '@earendil-works/pi-tui';
3
+ import { openCache as defaultOpenCache } from './docs-cache.js';
4
+ import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
3
5
  import { docsRaw, formatResultText, buildPrompt } from './docs-core.js';
4
6
  import { formatNpmVersionSection } from './npm-version.js';
5
7
  import { runChild, CHILD_BASE_ARGS } from '../shared/child-process.js';
6
8
  import { parseChildOutput, isExcerptInContent } from '../shared/child-output.js';
7
9
  import { getPiInvocation } from '../shared/pi-invocation.js';
8
10
  import { textResult } from './shared.js';
11
+ import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
9
12
  const CHILD_ARGS = [...CHILD_BASE_ARGS, '--no-tools'];
10
13
  const RENDER_QUERY_MAX = 100;
11
14
  const Params = Type.Object({
12
15
  module: Type.String({
13
- description: 'Bare npm module name (e.g. "zod", "@scope/name", "react/jsx-runtime"). Must be installed in the project\'s node_modules.'
16
+ description: 'Bare npm module name (e.g. "zod", "@scope/name", "react/jsx-runtime"), OR "." to look up the current project\'s own source code. npm packages must be installed in node_modules.'
14
17
  }),
15
18
  query: Type.String({
16
- description: 'What to extract from the module\'s docs. The child pi reads ranked chunks and returns ONLY content answering this.'
19
+ description: 'What to extract from the docs. The child pi reads ranked chunks and returns ONLY content answering this.'
17
20
  })
18
21
  });
19
22
  export function registerPiWorkerDocs(pi, internals = {}) {
@@ -35,11 +38,20 @@ export function registerPiWorkerDocs(pi, internals = {}) {
35
38
  + '~/.cache/pi-worker/docs.sqlite, keyed by exact installed version; the '
36
39
  + 'registry lookup is best-effort and silently absent when offline.\n'
37
40
  + '\n'
41
+ + 'Pass module: "." to look up the CURRENT PROJECT\'S OWN SOURCE CODE instead '
42
+ + 'of an npm package. USE THIS when asked about what a function, class, type, '
43
+ + 'or module in this project does or exports — e.g. "what does orchestrator.ts '
44
+ + 'export?", "how does the requireAuth middleware work?", "what props does '
45
+ + 'ListingCard accept?". The project source is indexed from git-tracked .ts/.tsx '
46
+ + 'files and cached by max file mtime — always reflects the current state of '
47
+ + 'the working tree.\n'
48
+ + '\n'
38
49
  + 'Good fits:\n'
39
50
  + '- "What does library X export?" / "How does function Y work?"\n'
40
51
  + '- Confirming generic shapes, overload sets, exported types\n'
41
52
  + '- Pulling README configuration prose without burning context on raw markdown\n'
42
53
  + '- Checking the current latest published version of a package\n'
54
+ + '- "What does src/X.ts export?" / "How does this project\'s Y function work?"\n'
43
55
  + '\n'
44
56
  + 'Skip when:\n'
45
57
  + '- You need docs for a specific newer version than what is installed — use pi-worker-fetch on the upstream docs site',
@@ -50,6 +62,65 @@ export function registerPiWorkerDocs(pi, internals = {}) {
50
62
  ?? (globalThis.Bun !== undefined ?
51
63
  globalThis.Bun.spawn
52
64
  : (await import('node:child_process')).spawn);
65
+ // ── Project source lookup ───────────────────────────────────────
66
+ if (params.module === '.') {
67
+ const openCache = internals.openCache ?? defaultOpenCache;
68
+ let cache;
69
+ let cacheError;
70
+ try {
71
+ cache = openCache();
72
+ }
73
+ catch (err) {
74
+ cacheError = err instanceof Error ? err.message : String(err);
75
+ }
76
+ if (!cache) {
77
+ return textResult(`Project docs unavailable: cache open failed (${cacheError}).`, {});
78
+ }
79
+ const retrieveChunks = internals.retrieveChunks ?? defaultRetrieveChunks;
80
+ const projectResult = projectDocsRaw(cache, ctx.cwd, params.query, retrieveChunks);
81
+ if (projectResult.kind === 'error') {
82
+ return textResult(`Project docs error: ${projectResult.message}`, {});
83
+ }
84
+ if (projectResult.kind === 'no_chunks') {
85
+ return textResult(`Project "${projectResult.projectName}" has no .ts/.tsx files indexed.`, { hitCache: projectResult.hitCache, indexedFiles: projectResult.filesIngested });
86
+ }
87
+ const { projectName, chunks, hitCache, filesIngested, indexingMs } = projectResult;
88
+ const baseDetails = {
89
+ hitCache,
90
+ chunksRetrieved: chunks.length,
91
+ indexedFiles: filesIngested,
92
+ indexingMs
93
+ };
94
+ const concatenated = chunks.map(c => c.content).join('\n\n');
95
+ const prompt = buildProjectPrompt(projectName, params.query, concatenated);
96
+ const invocation = getPiInvocation([...CHILD_ARGS, prompt]);
97
+ const child = await runChild(spawn, invocation, ctx.cwd, signal);
98
+ if (child.aborted) {
99
+ return textResult('Project docs lookup aborted.', {
100
+ ...baseDetails,
101
+ aborted: true,
102
+ childExitCode: child.exitCode
103
+ });
104
+ }
105
+ if (child.exitCode !== 0) {
106
+ const tail = child.stderr.trim().slice(-500) || '(no stderr)';
107
+ return textResult(`Worker exited ${child.exitCode}.\n${tail}`, {
108
+ ...baseDetails,
109
+ childExitCode: child.exitCode
110
+ });
111
+ }
112
+ const parsed = parseChildOutput(child.stdout);
113
+ const verified = parsed.excerpt ?
114
+ isExcerptInContent(parsed.excerpt, concatenated)
115
+ : undefined;
116
+ const text = formatResultText({ name: projectName, version: 'local', root: ctx.cwd, entryDts: null, readme: null }, parsed, verified);
117
+ return textResult(text, {
118
+ ...baseDetails,
119
+ childExitCode: 0,
120
+ excerptVerified: verified
121
+ });
122
+ }
123
+ // ── npm package lookup (existing path) ──────────────────────────
53
124
  const rawResult = await docsRaw({
54
125
  pkg: params.module,
55
126
  query: params.query,
@@ -137,8 +208,9 @@ export function registerPiWorkerDocs(pi, internals = {}) {
137
208
  renderCall(args, theme) {
138
209
  const query = args.query.replace(/\s+/g, ' ').trim();
139
210
  const truncated = query.length > RENDER_QUERY_MAX ? `${query.slice(0, RENDER_QUERY_MAX - 1)}…` : query;
211
+ const label = args.module === '.' ? 'project' : args.module;
140
212
  let text = theme.fg('toolTitle', theme.bold('pi-worker-docs '));
141
- text += theme.fg('accent', args.module);
213
+ text += theme.fg('accent', label);
142
214
  text += `\n${theme.fg('dim', ` query: ${truncated}`)}`;
143
215
  return new Text(text, 0, 0);
144
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",