@mjasnikovs/pi-task 0.13.6 → 0.13.7
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/remote/push.d.ts +12 -3
- package/dist/remote/push.js +63 -9
- package/dist/remote/ui-script.d.ts +3 -0
- package/dist/remote/ui-script.js +804 -0
- package/dist/remote/ui-styles.d.ts +1 -0
- package/dist/remote/ui-styles.js +202 -0
- package/dist/remote/ui.js +4 -1000
- package/dist/shared/child-process.d.ts +27 -0
- package/dist/shared/child-process.js +151 -139
- package/dist/task/auto-orchestrator.js +3 -6
- package/dist/task/child-runner.js +1 -1
- package/dist/task/context-usage.d.ts +16 -0
- package/dist/task/context-usage.js +22 -0
- package/dist/task/external-context.d.ts +27 -0
- package/dist/task/external-context.js +93 -0
- package/dist/task/failure-classifier.js +1 -1
- package/dist/task/orchestrator.js +7 -13
- package/dist/task/parsers.d.ts +1 -15
- package/dist/task/parsers.js +17 -84
- package/dist/task/phases.d.ts +5 -7
- package/dist/task/phases.js +29 -84
- package/dist/task/prompts.d.ts +1 -0
- package/dist/task/prompts.js +9 -0
- package/dist/task/spec-validation.d.ts +23 -0
- package/dist/task/spec-validation.js +90 -0
- package/dist/task/widget.d.ts +1 -1
- package/dist/task/widget.js +1 -1
- package/dist/workers/pi-worker-docs.js +69 -58
- package/dist/workers/pi-worker-fetch.js +25 -21
- package/dist/workers/pi-worker-search.js +7 -13
- package/dist/workers/pi-worker.js +8 -14
- package/dist/workers/shared.d.ts +40 -0
- package/dist/workers/shared.js +31 -0
- package/package.json +1 -1
|
@@ -60,6 +60,33 @@ export interface RunChildJsonEventsOptions {
|
|
|
60
60
|
onFirstByte?: () => void;
|
|
61
61
|
}
|
|
62
62
|
export type RunChildOptions = RunChildTextOptions | RunChildJsonEventsOptions;
|
|
63
|
+
/**
|
|
64
|
+
* Parses a child's `--mode json` event stream into assistant text plus side
|
|
65
|
+
* effects (caller callbacks, loop-kill). It holds the cross-chunk line buffer
|
|
66
|
+
* and the text-assembly state, so event interpretation is independent of the
|
|
67
|
+
* spawn/kill machinery in runChild — and therefore unit-testable without a real
|
|
68
|
+
* child: construct one, `feed()` raw lines, assert on `text` / the callbacks /
|
|
69
|
+
* the onLoopKill signal.
|
|
70
|
+
*/
|
|
71
|
+
export declare class JsonEventSink {
|
|
72
|
+
private readonly opts;
|
|
73
|
+
/** Invoked when onToolCall reports a loop hit — runChild kills the child. */
|
|
74
|
+
private readonly onLoopKill;
|
|
75
|
+
/** Final assistant text from the agent_end event, if one arrived. */
|
|
76
|
+
finalText: string;
|
|
77
|
+
private textDeltaAccum;
|
|
78
|
+
private buf;
|
|
79
|
+
constructor(opts: RunChildJsonEventsOptions,
|
|
80
|
+
/** Invoked when onToolCall reports a loop hit — runChild kills the child. */
|
|
81
|
+
onLoopKill: () => void);
|
|
82
|
+
/** Feed a raw stdout chunk: parse every complete line, buffer the partial tail. */
|
|
83
|
+
feed(chunk: string): void;
|
|
84
|
+
/** Flush a trailing event that wasn't newline-terminated (call on close). */
|
|
85
|
+
flush(): void;
|
|
86
|
+
/** Extracted assistant text: the agent_end text if present, else the deltas. */
|
|
87
|
+
get text(): string;
|
|
88
|
+
private handleEvent;
|
|
89
|
+
}
|
|
63
90
|
export declare function runChild(spawn: SpawnFn, invocation: {
|
|
64
91
|
command: string;
|
|
65
92
|
args: ReadonlyArray<string>;
|
|
@@ -10,31 +10,162 @@ export const CHILD_BASE_ARGS = [
|
|
|
10
10
|
'--no-context-files',
|
|
11
11
|
'--no-session'
|
|
12
12
|
];
|
|
13
|
+
// ─── JSON event-stream sink ──────────────────────────────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* Parses a child's `--mode json` event stream into assistant text plus side
|
|
16
|
+
* effects (caller callbacks, loop-kill). It holds the cross-chunk line buffer
|
|
17
|
+
* and the text-assembly state, so event interpretation is independent of the
|
|
18
|
+
* spawn/kill machinery in runChild — and therefore unit-testable without a real
|
|
19
|
+
* child: construct one, `feed()` raw lines, assert on `text` / the callbacks /
|
|
20
|
+
* the onLoopKill signal.
|
|
21
|
+
*/
|
|
22
|
+
export class JsonEventSink {
|
|
23
|
+
opts;
|
|
24
|
+
onLoopKill;
|
|
25
|
+
/** Final assistant text from the agent_end event, if one arrived. */
|
|
26
|
+
finalText = '';
|
|
27
|
+
textDeltaAccum = '';
|
|
28
|
+
// json-events lines can split across data chunks; this holds the trailing
|
|
29
|
+
// partial line between feeds so events spanning a boundary still parse. We
|
|
30
|
+
// deliberately do NOT accumulate the full raw stream: a long-running child
|
|
31
|
+
// emits hundreds of MB of events and buffering it would overflow V8's max
|
|
32
|
+
// string length (≈512MB). We keep only the parsed text.
|
|
33
|
+
buf = '';
|
|
34
|
+
constructor(opts,
|
|
35
|
+
/** Invoked when onToolCall reports a loop hit — runChild kills the child. */
|
|
36
|
+
onLoopKill) {
|
|
37
|
+
this.opts = opts;
|
|
38
|
+
this.onLoopKill = onLoopKill;
|
|
39
|
+
}
|
|
40
|
+
/** Feed a raw stdout chunk: parse every complete line, buffer the partial tail. */
|
|
41
|
+
feed(chunk) {
|
|
42
|
+
this.buf += chunk;
|
|
43
|
+
let nl;
|
|
44
|
+
while ((nl = this.buf.indexOf('\n')) !== -1) {
|
|
45
|
+
const line = this.buf.slice(0, nl).trim();
|
|
46
|
+
this.buf = this.buf.slice(nl + 1);
|
|
47
|
+
if (line.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
const evt = JSON.parse(line);
|
|
51
|
+
if (evt && typeof evt === 'object')
|
|
52
|
+
this.handleEvent(evt);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Non-JSON line (startup banner, etc.) — ignore.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Flush a trailing event that wasn't newline-terminated (call on close). */
|
|
60
|
+
flush() {
|
|
61
|
+
if (this.buf.trim().length > 0)
|
|
62
|
+
this.feed('\n');
|
|
63
|
+
this.buf = '';
|
|
64
|
+
}
|
|
65
|
+
/** Extracted assistant text: the agent_end text if present, else the deltas. */
|
|
66
|
+
get text() {
|
|
67
|
+
return (this.finalText || this.textDeltaAccum).trim();
|
|
68
|
+
}
|
|
69
|
+
handleEvent(evt) {
|
|
70
|
+
const opts = this.opts;
|
|
71
|
+
const t = typeof evt.type === 'string' ? evt.type : '';
|
|
72
|
+
if (t === 'context_usage' && opts.onContextUsage) {
|
|
73
|
+
const tokens = Number(evt.tokens ?? 0);
|
|
74
|
+
const contextWindow = Number(evt.contextWindow ?? 0);
|
|
75
|
+
const percent = Number(evt.percent ?? 0);
|
|
76
|
+
if (tokens > 0 || contextWindow > 0) {
|
|
77
|
+
opts.onContextUsage({ tokens, contextWindow, percent });
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (t === 'message_end' && opts.onContextUsage) {
|
|
82
|
+
const msg = evt.message;
|
|
83
|
+
if (msg?.role === 'assistant') {
|
|
84
|
+
const usage = msg.usage;
|
|
85
|
+
if (usage) {
|
|
86
|
+
const tokens = Number(usage.input ?? 0)
|
|
87
|
+
+ Number(usage.cacheRead ?? 0)
|
|
88
|
+
+ Number(usage.cacheWrite ?? 0)
|
|
89
|
+
+ Number(usage.output ?? 0);
|
|
90
|
+
if (tokens > 0) {
|
|
91
|
+
opts.onContextUsage({ tokens, contextWindow: 0, percent: 0 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (t === 'agent_end' && Array.isArray(evt.messages)) {
|
|
98
|
+
for (let i = evt.messages.length - 1; i >= 0; i--) {
|
|
99
|
+
const m = evt.messages[i];
|
|
100
|
+
if (m && m.role === 'assistant' && Array.isArray(m.content)) {
|
|
101
|
+
const texts = [];
|
|
102
|
+
for (const c of m.content) {
|
|
103
|
+
if (c?.type === 'text' && typeof c.text === 'string') {
|
|
104
|
+
texts.push(c.text);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (texts.length > 0) {
|
|
108
|
+
this.finalText = texts.join('');
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (t === 'message_update') {
|
|
116
|
+
const ame = evt.assistantMessageEvent;
|
|
117
|
+
const ameType = ame && typeof ame.type === 'string' ? ame.type : '';
|
|
118
|
+
if (ameType === 'text_start') {
|
|
119
|
+
this.textDeltaAccum = '';
|
|
120
|
+
if (opts.onLine)
|
|
121
|
+
opts.onLine('writing answer…');
|
|
122
|
+
}
|
|
123
|
+
else if (ameType === 'text_delta' && typeof ame.delta === 'string') {
|
|
124
|
+
this.textDeltaAccum += ame.delta;
|
|
125
|
+
}
|
|
126
|
+
else if (ameType === 'thinking_start' && opts.onLine) {
|
|
127
|
+
opts.onLine('thinking…');
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (t === 'tool_execution_start') {
|
|
132
|
+
const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
|
|
133
|
+
if (opts.onLine) {
|
|
134
|
+
const detail = summarizeToolArgs(tn, evt.args);
|
|
135
|
+
opts.onLine(detail ? `${tn}: ${detail}` : tn);
|
|
136
|
+
}
|
|
137
|
+
if (opts.onToolCall) {
|
|
138
|
+
const hit = opts.onToolCall({ name: tn, args: evt.args });
|
|
139
|
+
if (hit)
|
|
140
|
+
this.onLoopKill();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
13
145
|
// ─── Unified runChild ────────────────────────────────────────────────────────
|
|
14
146
|
export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
15
147
|
return new Promise(resolve => {
|
|
16
148
|
let stdout = '';
|
|
17
149
|
let stderr = '';
|
|
18
150
|
let aborted = false;
|
|
19
|
-
const isJsonEvents = opts?.mode === 'json-events';
|
|
20
151
|
const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
|
|
21
|
-
// State for json-events mode
|
|
22
|
-
let finalText = '';
|
|
23
|
-
let textDeltaAccum = '';
|
|
24
152
|
const proc = spawn(invocation.command, invocation.args, {
|
|
25
153
|
cwd,
|
|
26
154
|
shell: false,
|
|
27
155
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
28
156
|
});
|
|
157
|
+
// One kill path, shared by user-abort and loop-kill: SIGTERM, then
|
|
158
|
+
// SIGKILL after a grace period if the child ignored the term.
|
|
159
|
+
const killProc = () => {
|
|
160
|
+
aborted = true;
|
|
161
|
+
proc.kill('SIGTERM');
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
if (!proc.killed)
|
|
164
|
+
proc.kill('SIGKILL');
|
|
165
|
+
}, KILL_GRACE_MS);
|
|
166
|
+
};
|
|
167
|
+
const sink = opts?.mode === 'json-events' ? new JsonEventSink(opts, killProc) : null;
|
|
29
168
|
let firstByteFired = false;
|
|
30
|
-
// json-events lines can split across data chunks; this holds the trailing
|
|
31
|
-
// partial line between chunks so events spanning a boundary still parse.
|
|
32
|
-
// In json-events mode we deliberately do NOT accumulate the full raw
|
|
33
|
-
// stream: a long-running child emits hundreds of MB of events and
|
|
34
|
-
// `stdout += chunk` would overflow V8's max string length (≈512MB),
|
|
35
|
-
// crashing the process. We only need the parsed text, so we drop the raw
|
|
36
|
-
// bytes once drained.
|
|
37
|
-
let lineBuffer = '';
|
|
38
169
|
proc.stdout?.on('data', (d) => {
|
|
39
170
|
if (!firstByteFired) {
|
|
40
171
|
firstByteFired = true;
|
|
@@ -43,147 +174,28 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
43
174
|
if (discardStdout)
|
|
44
175
|
return;
|
|
45
176
|
const chunk = d.toString();
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
else {
|
|
177
|
+
if (sink)
|
|
178
|
+
sink.feed(chunk);
|
|
179
|
+
else
|
|
50
180
|
stdout += chunk;
|
|
51
|
-
}
|
|
52
181
|
});
|
|
53
182
|
proc.stderr?.on('data', (d) => {
|
|
54
183
|
stderr += d.toString();
|
|
55
184
|
});
|
|
56
185
|
proc.on('close', (code) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
lineBuffer = '';
|
|
61
|
-
}
|
|
62
|
-
const text = isJsonEvents ? (finalText || textDeltaAccum).trim() : undefined;
|
|
186
|
+
if (sink)
|
|
187
|
+
sink.flush();
|
|
188
|
+
const text = sink ? sink.text : undefined;
|
|
63
189
|
resolve({ stdout, stderr, exitCode: code ?? 0, aborted, text });
|
|
64
190
|
});
|
|
65
191
|
proc.on('error', () => {
|
|
66
192
|
resolve({ stdout, stderr, exitCode: 1, aborted });
|
|
67
193
|
});
|
|
68
194
|
if (signal) {
|
|
69
|
-
const kill = () => {
|
|
70
|
-
aborted = true;
|
|
71
|
-
proc.kill('SIGTERM');
|
|
72
|
-
setTimeout(() => {
|
|
73
|
-
if (!proc.killed)
|
|
74
|
-
proc.kill('SIGKILL');
|
|
75
|
-
}, KILL_GRACE_MS);
|
|
76
|
-
};
|
|
77
195
|
if (signal.aborted)
|
|
78
|
-
|
|
196
|
+
killProc();
|
|
79
197
|
else
|
|
80
|
-
signal.addEventListener('abort',
|
|
81
|
-
}
|
|
82
|
-
// ─── JSON event-stream processing ──────────────────────────────────
|
|
83
|
-
/**
|
|
84
|
-
* Parse every complete (newline-terminated) JSON line in `buf` and
|
|
85
|
-
* return the trailing partial line, which the caller carries into the
|
|
86
|
-
* next chunk. This avoids both losing events that span a chunk boundary
|
|
87
|
-
* and buffering the entire stream.
|
|
88
|
-
*/
|
|
89
|
-
function drainJsonEvents(buf, jsonOpts) {
|
|
90
|
-
let nl;
|
|
91
|
-
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
92
|
-
const line = buf.slice(0, nl).trim();
|
|
93
|
-
buf = buf.slice(nl + 1);
|
|
94
|
-
if (line.length === 0)
|
|
95
|
-
continue;
|
|
96
|
-
try {
|
|
97
|
-
const evt = JSON.parse(line);
|
|
98
|
-
if (evt && typeof evt === 'object') {
|
|
99
|
-
handleEvent(evt, jsonOpts);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
// Non-JSON line (startup banner, etc.) — ignore.
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return buf;
|
|
107
|
-
}
|
|
108
|
-
function handleEvent(evt, jsonOpts) {
|
|
109
|
-
const t = typeof evt.type === 'string' ? evt.type : '';
|
|
110
|
-
if (t === 'context_usage' && jsonOpts.onContextUsage) {
|
|
111
|
-
const tokens = Number(evt.tokens ?? 0);
|
|
112
|
-
const contextWindow = Number(evt.contextWindow ?? 0);
|
|
113
|
-
const percent = Number(evt.percent ?? 0);
|
|
114
|
-
if (tokens > 0 || contextWindow > 0) {
|
|
115
|
-
jsonOpts.onContextUsage({ tokens, contextWindow, percent });
|
|
116
|
-
}
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (t === 'message_end' && jsonOpts.onContextUsage) {
|
|
120
|
-
const msg = evt.message;
|
|
121
|
-
if (msg?.role === 'assistant') {
|
|
122
|
-
const usage = msg.usage;
|
|
123
|
-
if (usage) {
|
|
124
|
-
const tokens = Number(usage.input ?? 0)
|
|
125
|
-
+ Number(usage.cacheRead ?? 0)
|
|
126
|
-
+ Number(usage.cacheWrite ?? 0)
|
|
127
|
-
+ Number(usage.output ?? 0);
|
|
128
|
-
if (tokens > 0) {
|
|
129
|
-
jsonOpts.onContextUsage({ tokens, contextWindow: 0, percent: 0 });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (t === 'agent_end' && Array.isArray(evt.messages)) {
|
|
136
|
-
for (let i = evt.messages.length - 1; i >= 0; i--) {
|
|
137
|
-
const m = evt.messages[i];
|
|
138
|
-
if (m && m.role === 'assistant' && Array.isArray(m.content)) {
|
|
139
|
-
const texts = [];
|
|
140
|
-
for (const c of m.content) {
|
|
141
|
-
if (c?.type === 'text' && typeof c.text === 'string') {
|
|
142
|
-
texts.push(c.text);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
if (texts.length > 0) {
|
|
146
|
-
finalText = texts.join('');
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
if (t === 'message_update') {
|
|
154
|
-
const ame = evt.assistantMessageEvent;
|
|
155
|
-
const ameType = ame && typeof ame.type === 'string' ? ame.type : '';
|
|
156
|
-
if (ameType === 'text_start') {
|
|
157
|
-
textDeltaAccum = '';
|
|
158
|
-
if (jsonOpts.onLine)
|
|
159
|
-
jsonOpts.onLine('writing answer…');
|
|
160
|
-
}
|
|
161
|
-
else if (ameType === 'text_delta' && typeof ame.delta === 'string') {
|
|
162
|
-
textDeltaAccum += ame.delta;
|
|
163
|
-
}
|
|
164
|
-
else if (ameType === 'thinking_start' && jsonOpts.onLine) {
|
|
165
|
-
jsonOpts.onLine('thinking…');
|
|
166
|
-
}
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (t === 'tool_execution_start') {
|
|
170
|
-
const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
|
|
171
|
-
if (jsonOpts.onLine) {
|
|
172
|
-
const detail = summarizeToolArgs(tn, evt.args);
|
|
173
|
-
jsonOpts.onLine(detail ? `${tn}: ${detail}` : tn);
|
|
174
|
-
}
|
|
175
|
-
if (jsonOpts.onToolCall) {
|
|
176
|
-
const hit = jsonOpts.onToolCall({ name: tn, args: evt.args });
|
|
177
|
-
if (hit) {
|
|
178
|
-
aborted = true;
|
|
179
|
-
proc.kill('SIGTERM');
|
|
180
|
-
setTimeout(() => {
|
|
181
|
-
if (!proc.killed)
|
|
182
|
-
proc.kill('SIGKILL');
|
|
183
|
-
}, KILL_GRACE_MS);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
198
|
+
signal.addEventListener('abort', killProc, { once: true });
|
|
187
199
|
}
|
|
188
200
|
});
|
|
189
201
|
}
|
|
@@ -18,6 +18,7 @@ import { runPhaseChild, USER_CANCELLED } from './child-runner.js';
|
|
|
18
18
|
import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
|
|
19
19
|
import { getConfig } from '../config/config.js';
|
|
20
20
|
import { startAutoLoader } from './widget.js';
|
|
21
|
+
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
21
22
|
// Matches pi's @-file completion token (a path after @, until whitespace).
|
|
22
23
|
const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
|
|
23
24
|
/**
|
|
@@ -136,7 +137,7 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
136
137
|
// output line and context usage, exactly like the single-task phase widget.
|
|
137
138
|
let lastLine;
|
|
138
139
|
let contextUsage;
|
|
139
|
-
const parentContextWindow = ctx
|
|
140
|
+
const parentContextWindow = getParentContextWindow(ctx);
|
|
140
141
|
const phaseDeps = {
|
|
141
142
|
cwd,
|
|
142
143
|
taskId: '',
|
|
@@ -145,11 +146,7 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
145
146
|
lastLine = line;
|
|
146
147
|
},
|
|
147
148
|
onContextUsage: snapshot => {
|
|
148
|
-
|
|
149
|
-
snapshot.contextWindow
|
|
150
|
-
: contextUsage?.contextWindow || parentContextWindow;
|
|
151
|
-
const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
|
|
152
|
-
contextUsage = { tokens: snapshot.tokens, contextWindow: cw, percent };
|
|
149
|
+
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
153
150
|
}
|
|
154
151
|
};
|
|
155
152
|
return {
|
|
@@ -10,7 +10,7 @@ import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
|
10
10
|
import { runChild as runChildUnified, CHILD_BASE_ARGS } from '../shared/child-process.js';
|
|
11
11
|
import { LoopDetector } from './loop-detector.js';
|
|
12
12
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
13
|
-
import { readSection, setTaskSection } from './task-
|
|
13
|
+
import { readSection, setTaskSection } from './task-io.js';
|
|
14
14
|
// ─── Loop detection constants ────────────────────────────────────────────────
|
|
15
15
|
// Defined here (not in phases.ts) to avoid a circular dependency:
|
|
16
16
|
// phases.ts → child-runner.ts → phases.ts
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-usage resolution — shared by the single-task widget (TaskRunner) and
|
|
3
|
+
* the /task-auto planning loader (defaultDeps), which both mirror a child's
|
|
4
|
+
* context_usage events into a display snapshot with identical math.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import type { ContextSnapshot } from '../shared/child-process.js';
|
|
8
|
+
/** The parent session's context window, or 0 when the model doesn't expose it. */
|
|
9
|
+
export declare function getParentContextWindow(ctx: ExtensionCommandContext): number;
|
|
10
|
+
/**
|
|
11
|
+
* Fold a raw context_usage snapshot into a display snapshot: prefer the child's
|
|
12
|
+
* own contextWindow, else the last known one, else the parent session's; then
|
|
13
|
+
* derive percent against it — falling back to the child's reported percent when
|
|
14
|
+
* no window is known at all.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveContextUsage(snapshot: ContextSnapshot, prev: ContextSnapshot | undefined, parentContextWindow: number): ContextSnapshot;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-usage resolution — shared by the single-task widget (TaskRunner) and
|
|
3
|
+
* the /task-auto planning loader (defaultDeps), which both mirror a child's
|
|
4
|
+
* context_usage events into a display snapshot with identical math.
|
|
5
|
+
*/
|
|
6
|
+
/** The parent session's context window, or 0 when the model doesn't expose it. */
|
|
7
|
+
export function getParentContextWindow(ctx) {
|
|
8
|
+
return (ctx.model?.contextWindow ?? 0);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Fold a raw context_usage snapshot into a display snapshot: prefer the child's
|
|
12
|
+
* own contextWindow, else the last known one, else the parent session's; then
|
|
13
|
+
* derive percent against it — falling back to the child's reported percent when
|
|
14
|
+
* no window is known at all.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveContextUsage(snapshot, prev, parentContextWindow) {
|
|
17
|
+
const cw = snapshot.contextWindow > 0 ?
|
|
18
|
+
snapshot.contextWindow
|
|
19
|
+
: prev?.contextWindow || parentContextWindow;
|
|
20
|
+
const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
|
|
21
|
+
return { tokens: snapshot.tokens, contextWindow: cw, percent };
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External-context enrichment — extract packages / URLs / services from the
|
|
3
|
+
* refined spec, fan out to docs / fetch / search workers, and assemble the
|
|
4
|
+
* `EXTERNAL CONTEXT` block the research phase prepends to every worker prompt.
|
|
5
|
+
*
|
|
6
|
+
* Split out of phases.ts so the research phase reads as "gather context → run
|
|
7
|
+
* probes → assemble", and so this fan-out has its own test surface separate
|
|
8
|
+
* from the four research workers. `enrichment.ts` stays a pure parser; the I/O
|
|
9
|
+
* lives here.
|
|
10
|
+
*/
|
|
11
|
+
import { docsRaw } from '../workers/docs-core.js';
|
|
12
|
+
import { fetchRaw } from '../workers/fetch-core.js';
|
|
13
|
+
import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
|
|
14
|
+
import type { PhaseDeps } from './child-runner.js';
|
|
15
|
+
/** Injectable workers so enrichment is testable without spawning real lookups. */
|
|
16
|
+
export interface ExternalContextDeps {
|
|
17
|
+
docsRaw?: typeof docsRaw;
|
|
18
|
+
fetchRaw?: typeof fetchRaw;
|
|
19
|
+
searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
|
|
20
|
+
}
|
|
21
|
+
type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep'>;
|
|
22
|
+
/**
|
|
23
|
+
* Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''` when
|
|
24
|
+
* there is nothing to enrich (no targets, or every lookup failed).
|
|
25
|
+
*/
|
|
26
|
+
export declare function gatherExternalContext(refined: string, deps: GatherDeps, researchDeps?: ExternalContextDeps): Promise<string>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External-context enrichment — extract packages / URLs / services from the
|
|
3
|
+
* refined spec, fan out to docs / fetch / search workers, and assemble the
|
|
4
|
+
* `EXTERNAL CONTEXT` block the research phase prepends to every worker prompt.
|
|
5
|
+
*
|
|
6
|
+
* Split out of phases.ts so the research phase reads as "gather context → run
|
|
7
|
+
* probes → assemble", and so this fan-out has its own test surface separate
|
|
8
|
+
* from the four research workers. `enrichment.ts` stays a pure parser; the I/O
|
|
9
|
+
* lives here.
|
|
10
|
+
*/
|
|
11
|
+
import { docsRaw } from '../workers/docs-core.js';
|
|
12
|
+
import { fetchRaw } from '../workers/fetch-core.js';
|
|
13
|
+
import { formatNpmVersionSection } from '../workers/npm-version.js';
|
|
14
|
+
import { search as defaultSearch } from '../workers/search-core.js';
|
|
15
|
+
import { extractEnrichTargets } from './enrichment.js';
|
|
16
|
+
import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-blocks.js';
|
|
17
|
+
/**
|
|
18
|
+
* Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''` when
|
|
19
|
+
* there is nothing to enrich (no targets, or every lookup failed).
|
|
20
|
+
*/
|
|
21
|
+
export async function gatherExternalContext(refined, deps, researchDeps = {}) {
|
|
22
|
+
const docsRawFn = researchDeps.docsRaw ?? docsRaw;
|
|
23
|
+
const fetchRawFn = researchDeps.fetchRaw ?? fetchRaw;
|
|
24
|
+
const searchFn = researchDeps.searchFn ?? defaultSearch;
|
|
25
|
+
const enrichTargets = extractEnrichTargets(refined);
|
|
26
|
+
if (enrichTargets.packages.length === 0
|
|
27
|
+
&& enrichTargets.urls.length === 0
|
|
28
|
+
&& enrichTargets.services.length === 0) {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
const enrichSections = [];
|
|
32
|
+
const tEnrichStart = Date.now();
|
|
33
|
+
const [docsResults, fetchResults, serviceResults] = await Promise.all([
|
|
34
|
+
Promise.all(enrichTargets.packages.map(pkg => docsRawFn({
|
|
35
|
+
pkg,
|
|
36
|
+
query: refined.split('\n').find(l => l.trim()) ?? refined,
|
|
37
|
+
cwd: deps.cwd,
|
|
38
|
+
signal: deps.signal
|
|
39
|
+
}).catch(() => null))),
|
|
40
|
+
Promise.all(enrichTargets.urls.map(url => fetchRawFn({ url, signal: deps.signal }).catch(() => null))),
|
|
41
|
+
Promise.all(enrichTargets.services.map(s => searchFn({
|
|
42
|
+
query: `${s.name} ${s.query}`,
|
|
43
|
+
count: 3,
|
|
44
|
+
signal: deps.signal
|
|
45
|
+
}).catch(() => null)))
|
|
46
|
+
]);
|
|
47
|
+
// npm version blocks come from docsRaw's bundled lookup and lead the
|
|
48
|
+
// section so the model anchors on live version data before reading
|
|
49
|
+
// the docs body.
|
|
50
|
+
for (let i = 0; i < enrichTargets.packages.length; i++) {
|
|
51
|
+
const v = docsResults[i]?.npmVersion;
|
|
52
|
+
if (v)
|
|
53
|
+
enrichSections.push(formatNpmVersionSection(v));
|
|
54
|
+
}
|
|
55
|
+
for (let i = 0; i < enrichTargets.packages.length; i++) {
|
|
56
|
+
const r = docsResults[i];
|
|
57
|
+
if (r?.kind === 'ok' && r.chunks.length > 0) {
|
|
58
|
+
const body = r.chunks
|
|
59
|
+
.map(c => c.content)
|
|
60
|
+
.join('\n\n')
|
|
61
|
+
.slice(0, 4000);
|
|
62
|
+
enrichSections.push(`### docs: ${enrichTargets.packages[i]}\n${body}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < enrichTargets.urls.length; i++) {
|
|
66
|
+
const r = fetchResults[i];
|
|
67
|
+
if (r) {
|
|
68
|
+
enrichSections.push(`### url: ${enrichTargets.urls[i]}\n${r.markdown.slice(0, 4000)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const skipped = [];
|
|
72
|
+
for (let i = 0; i < enrichTargets.services.length; i++) {
|
|
73
|
+
const s = enrichTargets.services[i];
|
|
74
|
+
const r = serviceResults[i];
|
|
75
|
+
if (r === null)
|
|
76
|
+
continue;
|
|
77
|
+
if (r.kind === 'no_key') {
|
|
78
|
+
skipped.push(s.name);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (r.kind === 'error')
|
|
82
|
+
continue;
|
|
83
|
+
// kind === 'ok'
|
|
84
|
+
enrichSections.push(formatServiceBlock(s.name, `${s.name} ${s.query}`, r.results));
|
|
85
|
+
}
|
|
86
|
+
if (skipped.length > 0) {
|
|
87
|
+
enrichSections.push(formatFreshnessSkippedBlock(skipped));
|
|
88
|
+
}
|
|
89
|
+
deps.recordSubStep?.('enrichment', Date.now() - tEnrichStart);
|
|
90
|
+
if (enrichSections.length === 0)
|
|
91
|
+
return '';
|
|
92
|
+
return `EXTERNAL CONTEXT\n${enrichSections.join('\n\n')}\n\n`;
|
|
93
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Failure classification — map runtime errors to task state transitions,
|
|
3
3
|
* widget flash messages, and user notifications.
|
|
4
4
|
*/
|
|
5
|
-
import { updateTaskFrontMatter } from './task-
|
|
5
|
+
import { updateTaskFrontMatter } from './task-io.js';
|
|
6
6
|
import { flashTerminalWidget } from './widget.js';
|
|
7
7
|
import { LoopExhaustedError, LeakedToolCallError, USER_CANCELLED } from './child-runner.js';
|
|
8
8
|
// ─── Classifier ──────────────────────────────────────────────────────────────
|
|
@@ -19,11 +19,14 @@ import * as fsp from 'node:fs/promises';
|
|
|
19
19
|
import * as path from 'node:path';
|
|
20
20
|
import { PHASES, postCommitPhase } from './phases.js';
|
|
21
21
|
import { handleFailure } from './failure-classifier.js';
|
|
22
|
-
import { PHASE_INDEX, PHASE_ORDER,
|
|
22
|
+
import { PHASE_INDEX, PHASE_ORDER, RESUMABLE_STATES } from './task-types.js';
|
|
23
|
+
import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parsers.js';
|
|
24
|
+
import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
|
|
23
25
|
import { startWidget } from './widget.js';
|
|
24
26
|
import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
|
|
25
|
-
import { parseVerifyBlock } from './
|
|
27
|
+
import { parseVerifyBlock } from './spec-validation.js';
|
|
26
28
|
import { formatTimings } from './timings.js';
|
|
29
|
+
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
27
30
|
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
28
31
|
let activeTask = null;
|
|
29
32
|
/** Set the module-level active task (avoids `this` aliasing in TaskRunner.run). */
|
|
@@ -77,7 +80,7 @@ export class TaskRunner {
|
|
|
77
80
|
phase: 'refine',
|
|
78
81
|
startedAt: this._startedAt
|
|
79
82
|
};
|
|
80
|
-
const parentContextWindow = ctx
|
|
83
|
+
const parentContextWindow = getParentContextWindow(ctx);
|
|
81
84
|
this._deps = {
|
|
82
85
|
cwd,
|
|
83
86
|
taskId: '',
|
|
@@ -87,16 +90,7 @@ export class TaskRunner {
|
|
|
87
90
|
this._widgetState.lastLine = line;
|
|
88
91
|
},
|
|
89
92
|
onContextUsage: snapshot => {
|
|
90
|
-
|
|
91
|
-
const cw = snapshot.contextWindow > 0 ?
|
|
92
|
-
snapshot.contextWindow
|
|
93
|
-
: prev?.contextWindow || parentContextWindow;
|
|
94
|
-
const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
|
|
95
|
-
this._widgetState.contextUsage = {
|
|
96
|
-
tokens: snapshot.tokens,
|
|
97
|
-
contextWindow: cw,
|
|
98
|
-
percent
|
|
99
|
-
};
|
|
93
|
+
this._widgetState.contextUsage = resolveContextUsage(snapshot, this._widgetState.contextUsage, parentContextWindow);
|
|
100
94
|
},
|
|
101
95
|
recordSubStep: (label, ms) => {
|
|
102
96
|
if (this._currentPhaseChildren) {
|
package/dist/task/parsers.d.ts
CHANGED
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Pure functions that parse raw model output into structured data.
|
|
5
5
|
*/
|
|
6
|
-
export interface VerifyCommand {
|
|
7
|
-
raw: string;
|
|
8
|
-
}
|
|
9
6
|
export type AutoAnswer = {
|
|
10
7
|
kind: 'answered';
|
|
11
8
|
text: string;
|
|
@@ -23,9 +20,9 @@ export interface ClarifyQuestion {
|
|
|
23
20
|
}
|
|
24
21
|
export declare const GRILL_LINE_RE: RegExp;
|
|
25
22
|
export declare const SUGGESTED_LINE_RE: RegExp;
|
|
26
|
-
export declare function parseVerifyBlock(spec: string): VerifyCommand[] | null;
|
|
27
23
|
export declare function parseGrillQuestions(raw: string): string[];
|
|
28
24
|
export declare function parseClarifyList(raw: string): ClarifyQuestion[];
|
|
25
|
+
export declare function autoAnswerHasTag(raw: string): boolean;
|
|
29
26
|
export declare function parseAutoAnswer(raw: string): AutoAnswer;
|
|
30
27
|
export declare function parseVerifyToolingOutput(output: string): {
|
|
31
28
|
verified: string[];
|
|
@@ -34,15 +31,4 @@ export declare function parseVerifyToolingOutput(output: string): {
|
|
|
34
31
|
reason: string;
|
|
35
32
|
}>;
|
|
36
33
|
};
|
|
37
|
-
export declare function isCritiqueClean(text: string): boolean;
|
|
38
|
-
/**
|
|
39
|
-
* Drop any preamble the model emitted before the spec's GOAL header. The
|
|
40
|
-
* thinking model sometimes narrates ("Now I have all the context. Here's the
|
|
41
|
-
* rewritten spec:") before GOAL — the prompts forbid it, but the critique
|
|
42
|
-
* validator only checks for a VERIFY block, so it leaked into the delivered
|
|
43
|
-
* spec. We slice from the first line that begins a GOAL section so the spec
|
|
44
|
-
* starts at GOAL. No GOAL line → returned unchanged (validation then flags it).
|
|
45
|
-
*/
|
|
46
|
-
export declare function stripSpecPreamble(spec: string): string;
|
|
47
|
-
export declare function validateSpecShape(spec: string): string | null;
|
|
48
34
|
export declare function deriveTitle(refined: string): string;
|