@mjasnikovs/pi-task 0.10.3 → 0.12.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.
@@ -92,6 +92,8 @@ const RESEARCH_APIS_PROMPT = (refined) => `You are doing targeted research for a
92
92
 
93
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.
94
94
 
95
+ PROJECT SOURCE — use pi-worker-docs with module ".", NOT file reads: for any function, class, type, or interface defined in THIS project's own .ts/.tsx source (e.g. "what does requireAuth check?", "what does CreateListingSchema look like?", "what does the listings query module export?"), call \`pi-worker-docs(".", query)\` instead of reading the file. The tool indexes all git-tracked source files and returns only the relevant chunks — far cheaper than reading whole files.
96
+
95
97
  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.
96
98
 
97
99
  RELEVANCE — read carefully: list ONLY the symbols the agent will call, implement, modify, or directly depend on for THIS task. Do NOT enumerate the project's entire public surface or dump every exported function in a touched file. A symbol unrelated to the task does not belong here just because it sits in the same module. Keep the smallest sufficient set: include every symbol the task actually exercises and nothing more. There is no fixed limit — list as many as the task truly needs and no padding beyond that.
@@ -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;
@@ -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.3",
3
+ "version": "0.12.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",