@mjasnikovs/pi-task 0.13.26 → 0.13.28

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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-559%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-731%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -49,7 +49,8 @@ pi install npm:@mjasnikovs/pi-task
49
49
  | `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
50
50
  | `/task-auto-resume` | Resume the active `/task-auto` run at the next unfinished task. |
51
51
  | `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
52
- | `/remote` | Show the QR code & URLs for the always-on web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
52
+ | `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, and orientation. |
53
+ | `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
53
54
 
54
55
  ## The pipeline
55
56
 
@@ -77,10 +78,11 @@ A real feature is usually several tasks, not one. `/task-auto` is a thin planner
77
78
  - **Clarify first.** It asks the few clarifying questions whose answers change how the feature splits, then decomposes the answers into an ordered list of task titles written to `.pi-tasks/TASK_AUTO_NNNN.md`.
78
79
  - **Sequential, blocking.** Each title runs through `/task` to a spec, the spec is implemented, and the loop waits for that to finish before starting the next title. No overlap.
79
80
  - **Crash- and cancel-safe.** Progress is the markdown checkboxes in the AUTO file. `/task-auto-resume` (no id) automatically picks up the active run at the first unchecked title. If a title's `/task` run fails, the loop stops and leaves the run resumable.
81
+ - **One commit per task.** When **auto-commit** is on (the default) and you're in a git repo, the working tree is snapshotted into a single commit after each title passes, so the run produces a clean per-task history. It's best-effort: outside a repo, with nothing to commit, or on any git error, the loop reports the reason and keeps going. Toggle it in `/task-config`.
80
82
 
81
83
  ## Remote — drive a task from your phone
82
84
 
83
- The remote server is **always on** — it starts automatically with each session, with nothing taking up screen space. Run `/remote` any time to pop a QR code and the connection URLs: a **Tailscale** line and a **LAN** line when both are available (the QR encodes the Tailscale-preferred one). Open the URL on any device that can reach the host and you get a live view of the session: streaming output, tool calls, and the `/task` status block (phase, elapsed, context). It's bidirectional — the browser can:
85
+ The remote server is **on by default** — it starts automatically with each session, with nothing taking up screen space (disable it in `/task-config`). Run `/remote` any time to pop a QR code and the connection URLs: a **Tailscale** line and a **LAN** line when both are available (the QR encodes the Tailscale-preferred one). Open the URL on any device that can reach the host and you get a live view of the session: streaming output, tool calls, and the `/task` status block (phase, elapsed, context). It's bidirectional — the browser can:
84
86
 
85
87
  - **Answer grill / `/task-auto` clarify questions.** Each question appears as a card with the recommended default pre-filled (Accept), a free-text box (Submit), Skip, or Cancel task.
86
88
  - **Start and control tasks.** Type `/task …`, `/task-auto …`, `/task-cancel`, `/task-resume`, etc. — they run on the host.
@@ -136,6 +138,17 @@ Resolves an installed npm package, indexes its `.d.ts` files and README into a l
136
138
  - The first call for a `(package, version)` pair pays a one-time ingestion cost; later calls are FTS-only.
137
139
  - Cache lives at `${XDG_CACHE_HOME:-~/.cache}/pi-worker/docs.sqlite` — delete it to reset.
138
140
 
141
+ ## Settings — `/task-config`
142
+
143
+ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings persist to `~/.config/pi-task/config.json` and all default to **on**:
144
+
145
+ | Setting | What it does |
146
+ | --- | --- |
147
+ | **remote** | The remote UI server (QR code, phone access). Turn off to never start it. |
148
+ | **compress reasoning** | After each message, compresses the model's `<think>` blocks down to the decisions/constraints/facts that matter later — keeping long local-model runs from drowning their own context in self-talk. |
149
+ | **auto-commit** | Snapshots the working tree into one git commit per `/task-auto` sub-task (see above). |
150
+ | **orientation** | Pre-reads the project's core files (manifest, config, domain types, schema, entrypoints, API surface) once and hands the contents to the read-heavy research workers, so they skip re-discovering the same files cold. Bounded by a hard byte budget; applied only where it helps (FILES/APIS workers). |
151
+
139
152
  ## Configuration
140
153
 
141
154
  | Variable | Used by | Notes |
@@ -153,7 +166,7 @@ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your
153
166
 
154
167
  ```sh
155
168
  bun install
156
- bun test src/ # 559 tests across 46 files
169
+ bun test src/ # 731 tests across 57 files
157
170
  bun run lint # prettier + eslint + tsc --noEmit
158
171
  bun run build # tsc → dist/
159
172
  ```
@@ -2,6 +2,7 @@ export interface PiTaskConfig {
2
2
  remote: boolean;
3
3
  compressReasoning: boolean;
4
4
  autoCommit: boolean;
5
+ orientation: boolean;
5
6
  }
6
7
  export declare function getConfig(): PiTaskConfig;
7
8
  export declare function saveConfig(config: PiTaskConfig): Promise<void>;
@@ -5,7 +5,8 @@ import * as os from 'node:os';
5
5
  const DEFAULTS = {
6
6
  remote: true,
7
7
  compressReasoning: true,
8
- autoCommit: true
8
+ autoCommit: true,
9
+ orientation: true
9
10
  };
10
11
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
11
12
  const _g = globalThis;
@@ -12,6 +12,11 @@ const ITEMS = [
12
12
  id: 'autoCommit',
13
13
  label: 'auto-commit',
14
14
  description: 'git commit around each /task-auto sub-task (checkpoint before, snapshot after)'
15
+ },
16
+ {
17
+ id: 'orientation',
18
+ label: 'orientation',
19
+ description: 'Pre-supply the project core (manifest, types, schema…) to the read-heavy research workers'
15
20
  }
16
21
  ];
17
22
  function makeTheme(theme) {
@@ -48,7 +53,7 @@ async function handleTaskConfig(_args, ctx) {
48
53
  }
49
54
  export function registerConfig(pi) {
50
55
  registerBridgeCommand(pi, 'task-config', {
51
- description: 'Configure pi-task settings (remote, compress reasoning, auto-commit).',
56
+ description: 'Configure pi-task settings (remote, compress reasoning, auto-commit, orientation).',
52
57
  handler: handleTaskConfig
53
58
  });
54
59
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ /* Live A/B: orientation OFF vs ON, real pi + local model, real mx5 repo. */
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { runWorker } from '../workers/pi-worker-core.js';
5
+ import { getFileInventory } from './file-inventory.js';
6
+ import { buildOrientation } from './orientation.js';
7
+ import { appendNoThink, RESEARCH_FILES_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_TOOLING_PROMPT } from './prompts.js';
8
+ import { scopedToolingGoal } from './phases.js';
9
+ const DOCS_EXT = new URL('../workers/docs-extension.js', import.meta.url).pathname;
10
+ const SINGLE_READ_EXT = new URL('../workers/single-read-extension.js', import.meta.url).pathname;
11
+ const CWD = '/home/edgars/hub/mx5';
12
+ process.env.PI_BIN ??= '/home/edgars/.local/share/mise/installs/node/26.2.0/bin/pi';
13
+ const refined = await readFile('/tmp/refined.txt', 'utf8');
14
+ const inventoryRaw = await getFileInventory(CWD);
15
+ const invPaths = inventoryRaw.split('\n').filter(l => l.trim());
16
+ const inventoryHeader = `PROJECT FILE INVENTORY\n${inventoryRaw}\n\n`;
17
+ const orientation = await buildOrientation(invPaths, async (p) => {
18
+ try {
19
+ return await readFile(resolve(CWD, p), 'utf8');
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ });
25
+ console.error(`orientation: ${orientation.supplied.size} files, ${(orientation.block.length / 1024).toFixed(1)}KB`);
26
+ const suppliedRel = new Set([...orientation.supplied].map(p => resolve(CWD, p)));
27
+ const workers = {
28
+ FILES: { build: RESEARCH_FILES_PROMPT, tools: undefined, ext: [] },
29
+ APIS: {
30
+ build: RESEARCH_APIS_PROMPT,
31
+ tools: 'read,grep,find,ls,pi-worker-docs',
32
+ ext: [DOCS_EXT]
33
+ },
34
+ CONTEXT: { build: RESEARCH_CONTEXT_PROMPT, tools: 'read,grep', ext: [] },
35
+ TOOLING: {
36
+ build: (r) => RESEARCH_TOOLING_PROMPT(scopedToolingGoal(r)),
37
+ tools: undefined,
38
+ ext: [SINGLE_READ_EXT]
39
+ }
40
+ };
41
+ async function run(worker, on) {
42
+ const header = (on ? orientation.block : '') + inventoryHeader;
43
+ const prompt = appendNoThink(header + workers[worker].build(refined));
44
+ const reads = [];
45
+ const r = await runWorker({
46
+ prompt,
47
+ cwd: CWD,
48
+ ...(workers[worker].tools ? { tools: workers[worker].tools } : {}),
49
+ ...(workers[worker].ext.length ? { extensions: workers[worker].ext } : {}),
50
+ onLine: line => {
51
+ const m = /read: (\S+)/.exec(line);
52
+ if (m)
53
+ reads.push(resolve(CWD, m[1]));
54
+ }
55
+ });
56
+ const reReadSupplied = reads.filter(p => suppliedRel.has(p)).length;
57
+ const label = `${worker} ${on ? 'ON ' : 'OFF'}`;
58
+ console.log(`${label} exit=${r.exitCode} loop=${r.loopHit ? 'Y' : 'n'} time=${r.loopHit || r.timedOut ? '(runaway)' : ''}` +
59
+ ` workMs=${r.workMs} reads=${reads.length} reads_of_core=${reReadSupplied} answerChars=${r.text.trim().length}`);
60
+ return { reads: reads.length, reReadSupplied, workMs: r.workMs, exit: r.exitCode };
61
+ }
62
+ for (const w of ['FILES', 'APIS', 'CONTEXT', 'TOOLING']) {
63
+ const off = await run(w, false);
64
+ const on = await run(w, true);
65
+ const saved = off.reads - on.reads;
66
+ console.log(` -> ${w}: reads ${off.reads}->${on.reads} (${saved >= 0 ? '-' : '+'}${Math.abs(saved)}), ` +
67
+ `workMs ${off.workMs}->${on.workMs}, core re-read while ON: ${on.reReadSupplied}\n`);
68
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Project orientation core — a bounded snapshot of the few files every research
3
+ * worker re-reads cold to learn "what is this project" (manifest, config, domain
4
+ * types, schema, entrypoints, API surface). The four workers run as separate
5
+ * child processes with no shared memory, so today each one independently
6
+ * `read()`s the same hot files: across the recorded mx5 run package.json was
7
+ * read 87×, src/types/index.ts 82×, and 55% of all worker reads were repeats of
8
+ * a file another worker in the same task had already read.
9
+ *
10
+ * This module picks that orientation core from the file inventory (repo-agnostic,
11
+ * by path convention), reads it ONCE in the parent, and the caller folds it into
12
+ * the shared prompt header — so every worker already has those files and skips
13
+ * the cold read. It is purely additive: nothing is blocked, so a worker can still
14
+ * read anything it wants; orientation only removes the need to.
15
+ *
16
+ * Bounded by design — the snapshot can never overflow the prompt regardless of
17
+ * repo size: a hard total byte budget, a per-file cap (one huge file can't eat
18
+ * the budget), and a candidate cap. Files that don't fit are simply not
19
+ * pre-supplied; the worker reads them as before. Selection is the pure, tested
20
+ * core; reading takes an injectable reader so it can be exercised without a repo.
21
+ */
22
+ /**
23
+ * Total bytes of file content the orientation block may contain (~10k tokens).
24
+ * This is the real overflow guard: it bounds the block regardless of repo size,
25
+ * and because the snapshot is prepended to each read-heavy worker it also caps
26
+ * the prefill those workers pay. Selection stops as soon as adding a file would
27
+ * exceed it.
28
+ */
29
+ export declare const ORIENTATION_BYTE_BUDGET: number;
30
+ /** A single file larger than this is skipped (read by the worker as before). */
31
+ export declare const ORIENTATION_PER_FILE_MAX: number;
32
+ /**
33
+ * Backstop file-count cap — a guard against a pathological repo with hundreds of
34
+ * tiny core files packing the byte budget into noise, NOT a normal-case limit.
35
+ * It is deliberately set high enough that the byte budget binds first on real
36
+ * repos (verified: mx5/aiz-server hit the budget at ~16 files; a 5000-file repo
37
+ * yields only ~6 orientation candidates because leaf source is filtered out). An
38
+ * earlier cap of 16 wrongly bound before the budget — e.g. it dropped 5 core
39
+ * files on a 165-file repo while leaving 17KB of budget unused.
40
+ */
41
+ export declare const ORIENTATION_MAX_FILES = 40;
42
+ /**
43
+ * Priority tier for an orientation candidate — lower is more fundamental. The
44
+ * tiers mirror the questions a worker re-derives on every task: what is this
45
+ * project (manifest) → how is it built (config) → what is its domain model
46
+ * (types/schema) → where does it start (entrypoints) → what is its surface (api)
47
+ * → what does it say about itself (docs). Returns null for paths that aren't
48
+ * orientation material, so they're dropped entirely.
49
+ */
50
+ export declare function orientationTier(path: string): number | null;
51
+ /**
52
+ * Rank inventory paths into orientation priority order: by tier, then shallower
53
+ * paths first (a root entrypoint beats a deeply-nested one), then alphabetical
54
+ * for a fully deterministic order. Non-orientation paths are dropped. The result
55
+ * is the *candidate* order; the byte budget is applied later when reading.
56
+ */
57
+ export declare function selectOrientationFiles(inventoryPaths: string[]): string[];
58
+ export interface OrientationResult {
59
+ /** Formatted PROJECT ORIENTATION block to prepend to the worker header, or ''. */
60
+ block: string;
61
+ /** Resolved-relative paths whose full content was pre-supplied in the block. */
62
+ supplied: Set<string>;
63
+ }
64
+ /**
65
+ * Read the orientation core within the byte budget and format it as a header
66
+ * block. `readFile` returns a file's text or null (missing/unreadable/binary) —
67
+ * a null or over-cap file is skipped, not fatal. Greedy in priority order: take
68
+ * each file whose content fits the per-file cap and the remaining total budget;
69
+ * skip the rest. Returns an empty block (and empty set) when nothing qualifies,
70
+ * so the caller falls back to today's behavior.
71
+ */
72
+ export declare function buildOrientation(inventoryPaths: string[], readFile: (path: string) => Promise<string | null>, opts?: {
73
+ byteBudget?: number;
74
+ perFileMax?: number;
75
+ maxFiles?: number;
76
+ }): Promise<OrientationResult>;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Project orientation core — a bounded snapshot of the few files every research
3
+ * worker re-reads cold to learn "what is this project" (manifest, config, domain
4
+ * types, schema, entrypoints, API surface). The four workers run as separate
5
+ * child processes with no shared memory, so today each one independently
6
+ * `read()`s the same hot files: across the recorded mx5 run package.json was
7
+ * read 87×, src/types/index.ts 82×, and 55% of all worker reads were repeats of
8
+ * a file another worker in the same task had already read.
9
+ *
10
+ * This module picks that orientation core from the file inventory (repo-agnostic,
11
+ * by path convention), reads it ONCE in the parent, and the caller folds it into
12
+ * the shared prompt header — so every worker already has those files and skips
13
+ * the cold read. It is purely additive: nothing is blocked, so a worker can still
14
+ * read anything it wants; orientation only removes the need to.
15
+ *
16
+ * Bounded by design — the snapshot can never overflow the prompt regardless of
17
+ * repo size: a hard total byte budget, a per-file cap (one huge file can't eat
18
+ * the budget), and a candidate cap. Files that don't fit are simply not
19
+ * pre-supplied; the worker reads them as before. Selection is the pure, tested
20
+ * core; reading takes an injectable reader so it can be exercised without a repo.
21
+ */
22
+ /**
23
+ * Total bytes of file content the orientation block may contain (~10k tokens).
24
+ * This is the real overflow guard: it bounds the block regardless of repo size,
25
+ * and because the snapshot is prepended to each read-heavy worker it also caps
26
+ * the prefill those workers pay. Selection stops as soon as adding a file would
27
+ * exceed it.
28
+ */
29
+ export const ORIENTATION_BYTE_BUDGET = 40 * 1024;
30
+ /** A single file larger than this is skipped (read by the worker as before). */
31
+ export const ORIENTATION_PER_FILE_MAX = 12 * 1024;
32
+ /**
33
+ * Backstop file-count cap — a guard against a pathological repo with hundreds of
34
+ * tiny core files packing the byte budget into noise, NOT a normal-case limit.
35
+ * It is deliberately set high enough that the byte budget binds first on real
36
+ * repos (verified: mx5/aiz-server hit the budget at ~16 files; a 5000-file repo
37
+ * yields only ~6 orientation candidates because leaf source is filtered out). An
38
+ * earlier cap of 16 wrongly bound before the budget — e.g. it dropped 5 core
39
+ * files on a 165-file repo while leaving 17KB of budget unused.
40
+ */
41
+ export const ORIENTATION_MAX_FILES = 40;
42
+ /** Code/config/doc extensions worth pre-reading; everything else is ignored. */
43
+ const ORIENTATION_EXTENSIONS = new Set([
44
+ 'ts',
45
+ 'tsx',
46
+ 'js',
47
+ 'jsx',
48
+ 'mjs',
49
+ 'cjs',
50
+ 'json',
51
+ 'sql',
52
+ 'prisma',
53
+ 'go',
54
+ 'rs',
55
+ 'py',
56
+ 'rb',
57
+ 'java',
58
+ 'kt',
59
+ 'md',
60
+ 'toml',
61
+ 'graphql',
62
+ 'gql'
63
+ ]);
64
+ /** Root manifests — the single highest-signal "what is this project" file. */
65
+ const MANIFEST_BASENAMES = new Set([
66
+ 'package.json',
67
+ 'go.mod',
68
+ 'cargo.toml',
69
+ 'pyproject.toml',
70
+ 'requirements.txt',
71
+ 'pom.xml',
72
+ 'build.gradle',
73
+ 'gemfile',
74
+ 'composer.json',
75
+ 'deno.json'
76
+ ]);
77
+ function basename(p) {
78
+ const i = p.lastIndexOf('/');
79
+ return i === -1 ? p : p.slice(i + 1);
80
+ }
81
+ function extension(p) {
82
+ const b = basename(p);
83
+ const i = b.lastIndexOf('.');
84
+ return i === -1 ? '' : b.slice(i + 1).toLowerCase();
85
+ }
86
+ function depth(p) {
87
+ let n = 0;
88
+ for (const c of p)
89
+ if (c === '/')
90
+ n++;
91
+ return n;
92
+ }
93
+ /**
94
+ * Priority tier for an orientation candidate — lower is more fundamental. The
95
+ * tiers mirror the questions a worker re-derives on every task: what is this
96
+ * project (manifest) → how is it built (config) → what is its domain model
97
+ * (types/schema) → where does it start (entrypoints) → what is its surface (api)
98
+ * → what does it say about itself (docs). Returns null for paths that aren't
99
+ * orientation material, so they're dropped entirely.
100
+ */
101
+ export function orientationTier(path) {
102
+ const lower = path.toLowerCase();
103
+ // Never orient on vendored/build output or tests: a dependency's manifest or
104
+ // a big *.test.ts would otherwise outrank the project's own files and eat the
105
+ // budget (validated against the mx5 traces — node_modules/hono/package.json
106
+ // and zod-schemas.test.ts both crowded out genuinely hot project files). A
107
+ // git-ls-files inventory won't list these anyway; this is belt-and-braces.
108
+ if (/(^|\/)(node_modules|dist|build|out|vendor|\.git|coverage)\//.test(lower))
109
+ return null;
110
+ if (/\.(test|spec)\.[a-z]+$/.test(lower) || /(^|\/)(__tests__|e2e)\//.test(lower))
111
+ return null;
112
+ if (path.startsWith('/'))
113
+ return null; // absolute path escaped the repo root
114
+ const base = basename(lower);
115
+ const ext = extension(lower);
116
+ if (MANIFEST_BASENAMES.has(base))
117
+ return 0;
118
+ if (!ORIENTATION_EXTENSIONS.has(ext))
119
+ return null;
120
+ if (base === 'tsconfig.json' || /\.config\.(ts|js|mjs|cjs|json)$/.test(base))
121
+ return 1;
122
+ const segs = lower.split('/');
123
+ const stem = base.replace(/\.[^.]+$/, '');
124
+ if (ext === 'sql'
125
+ || ext === 'prisma'
126
+ || ext === 'graphql'
127
+ || ext === 'gql'
128
+ || base.endsWith('.d.ts')
129
+ || segs.includes('types')
130
+ || stem === 'types'
131
+ || /schema|zod/.test(lower))
132
+ return 2;
133
+ if (['index', 'main', 'app', 'server', 'mod'].includes(stem))
134
+ return 3;
135
+ if (/(^|\/)api(\/|s?\.|$)/.test(lower) || segs.includes('api'))
136
+ return 4;
137
+ if (stem === 'readme')
138
+ return 5;
139
+ return null;
140
+ }
141
+ /**
142
+ * Rank inventory paths into orientation priority order: by tier, then shallower
143
+ * paths first (a root entrypoint beats a deeply-nested one), then alphabetical
144
+ * for a fully deterministic order. Non-orientation paths are dropped. The result
145
+ * is the *candidate* order; the byte budget is applied later when reading.
146
+ */
147
+ export function selectOrientationFiles(inventoryPaths) {
148
+ return inventoryPaths
149
+ .map(p => ({ p, tier: orientationTier(p) }))
150
+ .filter((x) => x.tier !== null)
151
+ .sort((a, b) => a.tier - b.tier || depth(a.p) - depth(b.p) || (a.p < b.p ? -1 : 1))
152
+ .map(x => x.p);
153
+ }
154
+ /**
155
+ * Read the orientation core within the byte budget and format it as a header
156
+ * block. `readFile` returns a file's text or null (missing/unreadable/binary) —
157
+ * a null or over-cap file is skipped, not fatal. Greedy in priority order: take
158
+ * each file whose content fits the per-file cap and the remaining total budget;
159
+ * skip the rest. Returns an empty block (and empty set) when nothing qualifies,
160
+ * so the caller falls back to today's behavior.
161
+ */
162
+ export async function buildOrientation(inventoryPaths, readFile, opts = {}) {
163
+ const byteBudget = opts.byteBudget ?? ORIENTATION_BYTE_BUDGET;
164
+ const perFileMax = opts.perFileMax ?? ORIENTATION_PER_FILE_MAX;
165
+ const maxFiles = opts.maxFiles ?? ORIENTATION_MAX_FILES;
166
+ const candidates = selectOrientationFiles(inventoryPaths).slice(0, maxFiles * 4);
167
+ const supplied = new Set();
168
+ const parts = [];
169
+ // Budget the *emitted* block, not just the raw content: the per-file fence
170
+ // (`--- path ---\n` + the `\n\n` join) and the fixed wrapper add up across
171
+ // many files and would otherwise let the block overrun the budget (a 27-file
172
+ // repo overran a content-only budget by >1KB). Seed `used` with the wrapper
173
+ // so the final block is guaranteed ≤ byteBudget.
174
+ const HEADER = `PROJECT ORIENTATION (full contents of the project's core files — `
175
+ + `already provided, do not re-read these)\n`;
176
+ const TRAILER = '\n\n';
177
+ let used = Buffer.byteLength(HEADER + TRAILER, 'utf8');
178
+ for (const path of candidates) {
179
+ if (supplied.size >= maxFiles)
180
+ break;
181
+ const text = await readFile(path);
182
+ if (text === null)
183
+ continue;
184
+ if (Buffer.byteLength(text, 'utf8') > perFileMax)
185
+ continue;
186
+ const piece = `--- ${path} ---\n${text.replace(/\n+$/, '')}`;
187
+ // Each piece after the first is preceded by the `\n\n` join separator.
188
+ const pieceBytes = Buffer.byteLength(piece, 'utf8') + (parts.length > 0 ? 2 : 0);
189
+ if (used + pieceBytes > byteBudget)
190
+ continue;
191
+ used += pieceBytes;
192
+ supplied.add(path);
193
+ parts.push(piece);
194
+ }
195
+ if (parts.length === 0)
196
+ return { block: '', supplied };
197
+ return { block: HEADER + parts.join('\n\n') + TRAILER, supplied };
198
+ }
@@ -9,6 +9,10 @@ import { runWorker } from '../workers/pi-worker-core.js';
9
9
  import { search as defaultSearch } from '../workers/search-core.js';
10
10
  import { extractEnrichTargets } from './enrichment.js';
11
11
  import { getFileInventory } from './file-inventory.js';
12
+ import { buildOrientation } from './orientation.js';
13
+ import { getConfig } from '../config/config.js';
14
+ import { readFile } from 'node:fs/promises';
15
+ import { resolve } from 'node:path';
12
16
  import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
13
17
  import { gatherExternalContext } from './external-context.js';
14
18
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS, appendNoThink } from './prompts.js';
@@ -194,6 +198,37 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
194
198
  // behavior.
195
199
  const inventoryRaw = await fileInventoryFn(deps.cwd, deps.signal).catch(() => '');
196
200
  const inventoryHeader = inventoryRaw.length > 0 ? `PROJECT FILE INVENTORY\n${inventoryRaw}\n\n` : '';
201
+ // Pre-read the project's orientation core (manifest, config, domain types,
202
+ // schema, entrypoints, API surface) ONCE and hand the full contents to the
203
+ // READ-HEAVY workers in their header. The workers run as separate child
204
+ // processes, so without this each one that explores re-reads the same hot
205
+ // files cold. Bounded by a hard byte budget so it can't overflow on a large
206
+ // repo; purely additive (nothing is blocked) so it can only remove a
207
+ // redundant read, never hide a file.
208
+ //
209
+ // Applied to FILES and APIS only — NOT CONTEXT/TOOLING. Verified with a live
210
+ // A/B on the local model (real pi, real repo): FILES and APIS are bimodal —
211
+ // they sometimes answer from the inventory but sometimes spiral into heavy
212
+ // reads (APIS hit 37 reads / 221s, a near-runaway), and pre-supplying the core
213
+ // collapses that to 0 reads / ~3.5s with the model honoring "do not re-read"
214
+ // (0 core re-reads in every ON run). CONTEXT works from inventory+grep and
215
+ // reads ~0 files regardless, so the block was pure prefill and made it slower
216
+ // in 5/5 reps; TOOLING is already scoped + single-read-guarded and saw no
217
+ // benefit. So orientation only goes where reads actually happen.
218
+ const orientationPaths = getConfig().orientation && inventoryRaw.length > 0 ?
219
+ inventoryRaw.split('\n').filter(l => l.trim().length > 0)
220
+ : [];
221
+ const orientation = await buildOrientation(orientationPaths, async (path) => {
222
+ try {
223
+ return await readFile(resolve(deps.cwd, path), 'utf8');
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }).catch(() => ({ block: '', supplied: new Set() }));
229
+ if (orientation.supplied.size > 0) {
230
+ deps.logDebug?.(`orientation: pre-supplied ${orientation.supplied.size} core files`);
231
+ }
197
232
  const promptHeader = externalContext + inventoryHeader;
198
233
  let doneCount = 0;
199
234
  const updateProgress = () => {
@@ -235,12 +270,14 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
235
270
  {
236
271
  section: 'FILES',
237
272
  label: 'worker:files',
238
- prompt: appendNoThink(promptHeader + RESEARCH_FILES_PROMPT(refined))
273
+ // Read-heavy: gets the orientation core (see note above).
274
+ prompt: appendNoThink(orientation.block + promptHeader + RESEARCH_FILES_PROMPT(refined))
239
275
  },
240
276
  {
241
277
  section: 'APIS',
242
278
  label: 'worker:apis',
243
- prompt: appendNoThink(promptHeader + RESEARCH_APIS_PROMPT(refined)),
279
+ // Read-heavy: gets the orientation core (see note above).
280
+ prompt: appendNoThink(orientation.block + promptHeader + RESEARCH_APIS_PROMPT(refined)),
244
281
  tools: 'read,grep,find,ls,pi-worker-docs',
245
282
  extensions: [DOCS_EXTENSION_PATH]
246
283
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.26",
3
+ "version": "0.13.28",
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",