@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.
- package/dist/task/child-runner.d.ts +2 -0
- package/dist/task/orchestrator.js +11 -5
- package/dist/task/phases.js +17 -2
- package/dist/task/prompts.js +3 -1
- package/dist/workers/docs-extension.d.ts +2 -0
- package/dist/workers/docs-extension.js +4 -0
- package/dist/workers/docs-project.d.ts +38 -0
- package/dist/workers/docs-project.js +249 -0
- package/dist/workers/docs-retrieve.js +1 -1
- package/dist/workers/pi-worker-core.d.ts +4 -0
- package/dist/workers/pi-worker-core.js +4 -2
- package/dist/workers/pi-worker-docs.js +75 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
206
|
-
|
|
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;
|
package/dist/task/phases.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
}
|
package/dist/task/prompts.js
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -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
|
|
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").
|
|
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
|
|
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',
|
|
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.
|
|
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",
|