@aexol/spectral 0.6.4 → 0.6.9
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/cli.js +8 -1
- package/dist/commands/serve.js +1 -0
- package/dist/extensions/aexol-mcp.js +16 -1
- package/dist/mcp/tool-registrar.js +18 -2
- package/dist/relay/auto-research.js +631 -445
- package/dist/relay/dispatcher.js +5 -7
- package/dist/relay/models-fetch.js +5 -1
- package/dist/server/pi-bridge.js +35 -11
- package/dist/server/session-stream.js +10 -2
- package/package.json +1 -1
|
@@ -1,530 +1,716 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-research handler —
|
|
3
|
-
*
|
|
2
|
+
* Auto-research handler — sends an auto-research task through the existing
|
|
3
|
+
* PiBridge (backend proxy) instead of spawning a separate pi process.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 2. Load the auto-research agent definition (system prompt + model)
|
|
8
|
-
* 3. Write system prompt to a temp file (--append-system-prompt)
|
|
9
|
-
* 4. Spawn pi with --mode json -p --no-session --model <model> --append-system-prompt <tmp>
|
|
10
|
-
* 5. Pass the user task as a positional argument ("Task: ...")
|
|
11
|
-
* 6. Parse pi's JSON-line output: watch for message_end events on assistant
|
|
12
|
-
* messages, extract the text, and interpret it as auto-research events
|
|
13
|
-
* (progress / extension_generated / done / error)
|
|
14
|
-
* 7. Stream progress via the relay to the browser
|
|
15
|
-
* 8. On completion, emit `auto_research_complete` with generated extensions
|
|
5
|
+
* This ensures auto-research uses the same model and API keys as the active
|
|
6
|
+
* session — no separate subprocess, no missing API key errors.
|
|
16
7
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Build the auto-research task prompt
|
|
10
|
+
* 2. Ensure session subscriber exists (borrows handleClientMessage pattern)
|
|
11
|
+
* 3. Emit auto_research_start via relay
|
|
12
|
+
* 4. Send task via manager.prompt() — goes through backend proxy with session's model
|
|
13
|
+
* 5. Attach a watcher subscriber to detect turn completion
|
|
14
|
+
* 6. On agent_end, scan for generated extensions and emit auto_research_complete
|
|
15
|
+
* 7. On error, emit auto_research_error
|
|
19
16
|
*/
|
|
20
|
-
import { spawn } from "node:child_process";
|
|
21
17
|
import * as fs from "node:fs";
|
|
22
|
-
import * as os from "node:os";
|
|
23
18
|
import * as path from "node:path";
|
|
24
|
-
import {
|
|
19
|
+
import { execSync } from "node:child_process";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// State
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/** Tracks sessions with an active auto-research turn (prevents double-runs). */
|
|
24
|
+
const activeAutoResearchSessions = new Set();
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
25
28
|
/**
|
|
26
|
-
*
|
|
27
|
-
* Searches project-level first, then user-level.
|
|
29
|
+
* Send a ServerEvent to the browser via the relay on the auto-research session.
|
|
28
30
|
*/
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
path.join(projectPath, ".pi", "agents", "auto-research.md"),
|
|
32
|
-
path.join(os.homedir(), ".pi", "agent", "agents", "auto-research.md"),
|
|
33
|
-
];
|
|
34
|
-
for (const filePath of candidates) {
|
|
35
|
-
try {
|
|
36
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
37
|
-
const { frontmatter, body } = parseFrontmatter(content);
|
|
38
|
-
if (body.trim().length === 0)
|
|
39
|
-
continue;
|
|
40
|
-
return {
|
|
41
|
-
model: frontmatter.model ?? "claude-sonnet-4-5",
|
|
42
|
-
systemPrompt: body,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
// Hardcoded fallback: system prompt for when the agent definition file
|
|
50
|
-
// is not found in the project or user agent directories. This ensures
|
|
51
|
-
// auto-research works out of the box on first use.
|
|
52
|
-
return getDefaultAgentDef();
|
|
31
|
+
function sendEvent(relay, sessionId, event) {
|
|
32
|
+
relay.send({ kind: "ws_event", sessionId, event });
|
|
53
33
|
}
|
|
54
|
-
/**
|
|
55
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Build a Subscriber that wraps each ServerEvent in a WsEventFrame
|
|
36
|
+
* and pushes it through the relay. Mirrors `makeRelaySubscriber` in
|
|
37
|
+
* dispatcher.ts but defined here to keep auto-research self-contained.
|
|
38
|
+
*/
|
|
39
|
+
function makeRelaySubscriber(sessionId, relay) {
|
|
56
40
|
return {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'1. Context: emit {"type":"progress","phase":"context_collecting","message":"..."}',
|
|
64
|
-
'2. Analysis: emit {"type":"progress","phase":"context_analyzing","message":"..."}',
|
|
65
|
-
'3. Generation: emit {"type":"progress","phase":"extension_generating","message":"..."}',
|
|
66
|
-
'4. Validation: emit {"type":"progress","phase":"extension_validating","message":"..."}',
|
|
67
|
-
"",
|
|
68
|
-
"## Extension categories",
|
|
69
|
-
"A. Workflow automation B. Code gen C. Project-specific tools",
|
|
70
|
-
"D. Quality/review E. Documentation F. LLM-powered G. Stateful",
|
|
71
|
-
"",
|
|
72
|
-
'When you generate an extension, emit:',
|
|
73
|
-
'{"type":"extension_generated","name":"...","path":"...","description":"...","usesLLM":bool,"fileCount":n}',
|
|
74
|
-
"Extensions go under .pi/extensions/auto-research/",
|
|
75
|
-
"",
|
|
76
|
-
'When done, emit: {"type":"done","extensions":[...]}',
|
|
77
|
-
"",
|
|
78
|
-
"IMPORTANT: Output ONLY JSON lines. No markdown, no code blocks.",
|
|
79
|
-
"Each line must be a single valid JSON object.",
|
|
80
|
-
].join("\n"),
|
|
41
|
+
send(event) {
|
|
42
|
+
relay.send({ kind: "ws_event", sessionId, event });
|
|
43
|
+
},
|
|
44
|
+
isOpen() {
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
81
47
|
};
|
|
82
48
|
}
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Helpers
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
49
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
50
|
+
* Scan the project's .pi/extensions/auto-research/ directory for generated
|
|
51
|
+
* extension directories. Each subdirectory with .ts files counts as one
|
|
52
|
+
* extension.
|
|
89
53
|
*/
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
function hasBin(name) {
|
|
103
|
-
const PATH = process.env.PATH || "";
|
|
104
|
-
const envPathSep = process.platform === "win32" ? ";" : ":";
|
|
105
|
-
for (const dir of PATH.split(envPathSep)) {
|
|
106
|
-
const candidate = path.join(dir, name);
|
|
54
|
+
function scanGeneratedExtensions(projectPath) {
|
|
55
|
+
const arDir = path.join(projectPath, ".pi", "extensions", "auto-research");
|
|
56
|
+
const extensions = [];
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(arDir))
|
|
59
|
+
return extensions;
|
|
60
|
+
for (const entry of fs.readdirSync(arDir, { withFileTypes: true })) {
|
|
61
|
+
if (!entry.isDirectory())
|
|
62
|
+
continue;
|
|
63
|
+
const extPath = path.join(arDir, entry.name);
|
|
64
|
+
let fileCount = 0;
|
|
65
|
+
let usesLLM = false;
|
|
107
66
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
67
|
+
const files = fs.readdirSync(extPath);
|
|
68
|
+
for (const f of files) {
|
|
69
|
+
if (f.endsWith(".ts") || f.endsWith(".js")) {
|
|
70
|
+
fileCount++;
|
|
71
|
+
// Quick heuristic: if any file references modelRegistry or setModel,
|
|
72
|
+
// flag the extension as LLM-powered.
|
|
73
|
+
if (!usesLLM) {
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(path.join(extPath, f), "utf-8");
|
|
76
|
+
if (content.includes("modelRegistry") ||
|
|
77
|
+
content.includes("setModel") ||
|
|
78
|
+
content.includes("registerProvider")) {
|
|
79
|
+
usesLLM = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* skip */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* skip */
|
|
91
|
+
}
|
|
92
|
+
// Extract description from index.ts if present
|
|
93
|
+
let description = `${entry.name} extension`;
|
|
94
|
+
try {
|
|
95
|
+
const indexPath = path.join(extPath, "index.ts");
|
|
96
|
+
if (fs.existsSync(indexPath)) {
|
|
97
|
+
const content = fs.readFileSync(indexPath, "utf-8");
|
|
98
|
+
const descMatch = content.match(/description:\s*["']([^"']+)["']/);
|
|
99
|
+
if (descMatch)
|
|
100
|
+
description = descMatch[1];
|
|
101
|
+
}
|
|
110
102
|
}
|
|
111
103
|
catch {
|
|
112
104
|
/* skip */
|
|
113
105
|
}
|
|
106
|
+
extensions.push({
|
|
107
|
+
name: entry.name,
|
|
108
|
+
path: `.pi/extensions/auto-research/${entry.name}`,
|
|
109
|
+
description,
|
|
110
|
+
usesLLM,
|
|
111
|
+
fileCount,
|
|
112
|
+
});
|
|
114
113
|
}
|
|
115
|
-
return false;
|
|
116
114
|
}
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
catch {
|
|
116
|
+
/* skip */
|
|
119
117
|
}
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
return extensions;
|
|
119
|
+
}
|
|
120
|
+
/** Check if AGENTS.md contains the auto-research marker section. */
|
|
121
|
+
function hasAgentsMdUpdate(projectPath) {
|
|
122
|
+
try {
|
|
123
|
+
const agentsPath = path.join(projectPath, "AGENTS.md");
|
|
124
|
+
if (!fs.existsSync(agentsPath))
|
|
125
|
+
return false;
|
|
126
|
+
const content = fs.readFileSync(agentsPath, "utf-8");
|
|
127
|
+
return content.includes("<!-- AUTO-RESEARCH:START -->") &&
|
|
128
|
+
content.includes("<!-- AUTO-RESEARCH:END -->");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
122
132
|
}
|
|
123
|
-
return { command: "pi", args: subagentArgs };
|
|
124
133
|
}
|
|
125
134
|
/**
|
|
126
|
-
*
|
|
135
|
+
* Read the auto-research manifest from disk. Returns null if it doesn't exist
|
|
136
|
+
* or can't be parsed.
|
|
127
137
|
*/
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
function readManifest(projectPath) {
|
|
139
|
+
try {
|
|
140
|
+
const mPath = path.join(projectPath, ".pi", "extensions", "auto-research", "manifest.json");
|
|
141
|
+
if (!fs.existsSync(mPath))
|
|
142
|
+
return null;
|
|
143
|
+
const raw = fs.readFileSync(mPath, "utf-8");
|
|
144
|
+
const parsed = JSON.parse(raw);
|
|
145
|
+
if (typeof parsed.lastRun !== "string" || typeof parsed.lastCommit !== "string" || typeof parsed.runCount !== "number") {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
lastRun: parsed.lastRun,
|
|
150
|
+
lastCommit: parsed.lastCommit,
|
|
151
|
+
runCount: parsed.runCount,
|
|
152
|
+
extensions: Array.isArray(parsed.extensions) ? parsed.extensions : [],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
134
158
|
}
|
|
135
159
|
/**
|
|
136
|
-
*
|
|
160
|
+
* Gather pre-run context for incremental auto-research.
|
|
137
161
|
*/
|
|
138
|
-
function
|
|
162
|
+
function gatherPreRunContext(projectPath) {
|
|
163
|
+
const manifest = readManifest(projectPath);
|
|
164
|
+
const isIncremental = manifest !== null;
|
|
165
|
+
const existingExtensions = [];
|
|
139
166
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
child.kill("SIGKILL");
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
// ignore
|
|
167
|
+
const arDir = path.join(projectPath, ".pi", "extensions", "auto-research");
|
|
168
|
+
if (fs.existsSync(arDir)) {
|
|
169
|
+
for (const entry of fs.readdirSync(arDir, { withFileTypes: true })) {
|
|
170
|
+
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
171
|
+
existingExtensions.push(entry.name);
|
|
150
172
|
}
|
|
151
|
-
}
|
|
173
|
+
}
|
|
152
174
|
}
|
|
153
175
|
}
|
|
154
|
-
catch {
|
|
155
|
-
|
|
176
|
+
catch { /* skip */ }
|
|
177
|
+
let changesSinceLastRun = null;
|
|
178
|
+
if (manifest?.lastCommit) {
|
|
179
|
+
try {
|
|
180
|
+
const diffOutput = execSync(`git diff --stat ${manifest.lastCommit}..HEAD`, {
|
|
181
|
+
cwd: projectPath, encoding: "utf-8", timeout: 5000, maxBuffer: 64 * 1024,
|
|
182
|
+
}).trim();
|
|
183
|
+
if (diffOutput)
|
|
184
|
+
changesSinceLastRun = diffOutput;
|
|
185
|
+
}
|
|
186
|
+
catch { /* git not available */ }
|
|
156
187
|
}
|
|
188
|
+
return { isIncremental, manifest, changesSinceLastRun, existingExtensions };
|
|
157
189
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Build the incremental mode section for the task prompt.
|
|
192
|
+
*/
|
|
193
|
+
function buildIncrementalSection(ctx) {
|
|
194
|
+
const lines = [];
|
|
195
|
+
lines.push("## Incremental Run — Context from Previous Run", "");
|
|
196
|
+
if (ctx.manifest) {
|
|
197
|
+
const prevExts = ctx.manifest.extensions
|
|
198
|
+
.map((e) => ` - **${e.name}** (${e.path})`)
|
|
199
|
+
.join("\n");
|
|
200
|
+
lines.push(`Previously generated extensions (run #${ctx.manifest.runCount} at ${ctx.manifest.lastRun}):`);
|
|
201
|
+
lines.push(prevExts || " (none)", "");
|
|
202
|
+
}
|
|
203
|
+
if (ctx.changesSinceLastRun) {
|
|
204
|
+
lines.push("### What changed since last run:", "```", ctx.changesSinceLastRun, "```", "");
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
lines.push("> Note: Could not determine git diff since last run.", "");
|
|
208
|
+
}
|
|
209
|
+
if (ctx.existingExtensions.length > 0) {
|
|
210
|
+
lines.push("### Existing extensions to review:");
|
|
211
|
+
for (const name of ctx.existingExtensions) {
|
|
212
|
+
lines.push(` - \`.pi/extensions/auto-research/${name}/\``);
|
|
213
|
+
}
|
|
214
|
+
lines.push("");
|
|
215
|
+
}
|
|
216
|
+
lines.push("### Incremental Instructions", "");
|
|
217
|
+
lines.push("You are running auto-research again on a project that has been analyzed before.");
|
|
218
|
+
lines.push("DO NOT start from scratch. Instead:", "");
|
|
219
|
+
lines.push("1. **Review existing extensions** — Read each existing extension's index.ts.");
|
|
220
|
+
lines.push(" KEEP extensions that are still relevant. UPDATE or DELETE obsolete ones.", "");
|
|
221
|
+
lines.push("2. **Focus on changes** — Only create NEW extensions for project areas that");
|
|
222
|
+
lines.push(" changed since last run (see git diff above).", "");
|
|
223
|
+
lines.push("3. **Clean up stale extensions** — If an extension targets a tool/library that");
|
|
224
|
+
lines.push(" was removed from the project, delete the extension directory entirely.", "");
|
|
225
|
+
lines.push("4. **Update AGENTS.md** — The AUTO-RESEARCH section should reflect the CURRENT");
|
|
226
|
+
lines.push(" set of extensions (remove stale entries, add new ones).", "");
|
|
227
|
+
lines.push("5. **Save manifest.json** — Write/update .pi/extensions/auto-research/manifest.json");
|
|
228
|
+
lines.push(` with: lastRun (ISO), lastCommit (git HEAD), runCount (${ctx.manifest ? ctx.manifest.runCount + 1 : 1}),`);
|
|
229
|
+
lines.push(" and extensions array with name/path/category for each generated extension.", "");
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Write/update the auto-research manifest after completion.
|
|
234
|
+
*/
|
|
235
|
+
function writeManifest(projectPath, extensions) {
|
|
236
|
+
try {
|
|
237
|
+
const arDir = path.join(projectPath, ".pi", "extensions", "auto-research");
|
|
238
|
+
if (!fs.existsSync(arDir))
|
|
239
|
+
fs.mkdirSync(arDir, { recursive: true });
|
|
240
|
+
let currentCommit = "unknown";
|
|
241
|
+
try {
|
|
242
|
+
currentCommit = execSync("git rev-parse HEAD", {
|
|
243
|
+
cwd: projectPath, encoding: "utf-8", timeout: 3000,
|
|
244
|
+
}).trim();
|
|
245
|
+
}
|
|
246
|
+
catch { /* git not available */ }
|
|
247
|
+
const prev = readManifest(projectPath);
|
|
248
|
+
const manifest = {
|
|
249
|
+
lastRun: new Date().toISOString(),
|
|
250
|
+
lastCommit: currentCommit,
|
|
251
|
+
runCount: (prev?.runCount ?? 0) + 1,
|
|
252
|
+
extensions: extensions.map((e) => ({ name: e.name, path: e.path, category: e.usesLLM ? "F" : undefined })),
|
|
253
|
+
};
|
|
254
|
+
fs.writeFileSync(path.join(arDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
170
255
|
}
|
|
171
|
-
|
|
256
|
+
catch { /* best-effort */ }
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Build the auto-research task prompt. This is sent as a user message
|
|
260
|
+
* through the existing PiBridge, so the agent uses the session's model
|
|
261
|
+
* and backend proxy.
|
|
262
|
+
*
|
|
263
|
+
* @param projectPath Absolute path to the project root
|
|
264
|
+
* @param projectName Human-readable project name
|
|
265
|
+
* @param preRunContext Incremental context — if null/!incremental, runs in full-scan mode
|
|
266
|
+
*/
|
|
267
|
+
function buildAutoResearchTask(projectPath, projectName, preRunContext = null) {
|
|
268
|
+
const isIncremental = preRunContext?.isIncremental ?? false;
|
|
269
|
+
const runInfo = isIncremental && preRunContext?.manifest
|
|
270
|
+
? `This is run #${preRunContext.manifest.runCount + 1}. Prior run was at ${preRunContext.manifest.lastRun}.`
|
|
271
|
+
: "This is the first auto-research run for this project.";
|
|
272
|
+
const incrementalHeader = isIncremental ? buildIncrementalSection(preRunContext) : "";
|
|
273
|
+
return [
|
|
274
|
+
"## Auto-Research Task",
|
|
275
|
+
"",
|
|
276
|
+
`Project: **${projectPath}** — **${projectName}**`,
|
|
277
|
+
"",
|
|
278
|
+
`> ${runInfo}`,
|
|
279
|
+
"",
|
|
280
|
+
incrementalHeader,
|
|
281
|
+
"---",
|
|
282
|
+
"",
|
|
283
|
+
"## Pi Extension API Reference (HARD-CODED — DO NOT RESEARCH)",
|
|
284
|
+
"",
|
|
285
|
+
"You are running inside **pi**, a coding agent harness. You do NOT need to research",
|
|
286
|
+
"what pi is or how it works — this knowledge is provided here. Jump straight to",
|
|
287
|
+
"analyzing the project and generating extensions.",
|
|
288
|
+
"",
|
|
289
|
+
"### Extension Entry Point",
|
|
290
|
+
"",
|
|
291
|
+
"Every extension is a TypeScript file exporting a single default `activate` function:",
|
|
292
|
+
"",
|
|
293
|
+
"```typescript",
|
|
294
|
+
"import type { ExtensionAPI } from \"@mariozechner/pi-coding-agent\";",
|
|
295
|
+
"export default function activate(pi: ExtensionAPI): void {",
|
|
296
|
+
" // Register tools, commands, event handlers here",
|
|
297
|
+
"}",
|
|
298
|
+
"```",
|
|
299
|
+
"",
|
|
300
|
+
"Extensions live in: `.pi/extensions/` (project-local) or `~/.pi/agent/extensions/` (user-global).",
|
|
301
|
+
"Auto-research generates extensions into `.pi/extensions/auto-research/<name>/`.",
|
|
302
|
+
"",
|
|
303
|
+
"### Tools (pi.registerTool)",
|
|
304
|
+
"",
|
|
305
|
+
"The primary extension mechanism. Tools become available to the agent during conversations.",
|
|
306
|
+
"",
|
|
307
|
+
"```typescript",
|
|
308
|
+
"import { Type } from \"typebox\"; // Built-in, zero-dependency schema validation",
|
|
309
|
+
"",
|
|
310
|
+
"pi.registerTool({",
|
|
311
|
+
" name: \"tool_name\", // snake_case, unique",
|
|
312
|
+
" description: \"What this tool does\", // Used by the agent to decide when to call",
|
|
313
|
+
" parameters: Type.Object({ // TypeBox schema for typed params",
|
|
314
|
+
" input: Type.String({ description: \"The input to process\" }),",
|
|
315
|
+
" // Optional params: Type.Optional(Type.String())",
|
|
316
|
+
" }),",
|
|
317
|
+
" handler: async (params, ctx) => {",
|
|
318
|
+
" // ctx.session?.cwd — current working directory",
|
|
319
|
+
" // ctx.bash(cmd) — run shell commands",
|
|
320
|
+
" // ctx.read(path) — read files",
|
|
321
|
+
" // ctx.write(path, content) — write files",
|
|
322
|
+
" return {",
|
|
323
|
+
" content: [{ type: \"text\", text: \"Result string\" }],",
|
|
324
|
+
" // isError: true // set this to signal failure",
|
|
325
|
+
" };",
|
|
326
|
+
" },",
|
|
327
|
+
"});",
|
|
328
|
+
"```",
|
|
329
|
+
"",
|
|
330
|
+
"### Commands (pi.registerCommand)",
|
|
331
|
+
"",
|
|
332
|
+
"Custom slash commands the user can type:",
|
|
333
|
+
"",
|
|
334
|
+
"```typescript",
|
|
335
|
+
"pi.registerCommand({",
|
|
336
|
+
" name: \"my-command\",",
|
|
337
|
+
" description: \"What /my-command does\",",
|
|
338
|
+
" execute: async (args: string[], ctx) => {",
|
|
339
|
+
" return { content: [{ type: \"text\", text: \"Done\" }] };",
|
|
340
|
+
" },",
|
|
341
|
+
"});",
|
|
342
|
+
"```",
|
|
343
|
+
"",
|
|
344
|
+
"### Event Interception (pi.on)",
|
|
345
|
+
"",
|
|
346
|
+
"React to agent lifecycle events. Available events: `tool_call` (before a tool runs),",
|
|
347
|
+
"`session_start`, `session_shutdown`, `context` (prompt assembly), `before_agent_start`.",
|
|
348
|
+
"",
|
|
349
|
+
"```typescript",
|
|
350
|
+
"pi.on(\"tool_call\", (event) => {",
|
|
351
|
+
" // event.name — tool being called",
|
|
352
|
+
" // event.params — tool arguments",
|
|
353
|
+
" // Can modify or observe tool calls",
|
|
354
|
+
"});",
|
|
355
|
+
"```",
|
|
356
|
+
"",
|
|
357
|
+
"### Model Access (LLM-powered extensions)",
|
|
358
|
+
"",
|
|
359
|
+
"Extensions can call LLMs to build AI-powered features:",
|
|
360
|
+
"",
|
|
361
|
+
"```typescript",
|
|
362
|
+
"// Discover available models",
|
|
363
|
+
"const models = pi.modelRegistry.list();",
|
|
364
|
+
"const model = pi.modelRegistry.find(\"claude-sonnet-4-5\");",
|
|
365
|
+
"",
|
|
366
|
+
"// Switch the active model for the current turn",
|
|
367
|
+
"const switched = pi.setModel(model.id); // returns false if no API key",
|
|
368
|
+
"",
|
|
369
|
+
"// For complex multi-step AI pipelines, spawn a subagent:",
|
|
370
|
+
"// (see subagent extension pattern below)",
|
|
371
|
+
"```",
|
|
372
|
+
"",
|
|
373
|
+
"### Available Packages (npm dependencies)",
|
|
374
|
+
"",
|
|
375
|
+
"Extensions run as Node.js TypeScript modules. These packages are available:",
|
|
376
|
+
"- `@mariozechner/pi-coding-agent` — ExtensionAPI type",
|
|
377
|
+
"- `@mariozechner/pi-ai` — AI types and utilities",
|
|
378
|
+
"- `typebox` — Zero-dependency runtime type validation",
|
|
379
|
+
"- All `node:*` built-ins: `fs`, `path`, `child_process`, `os`, `crypto`, etc.",
|
|
380
|
+
"- Any npm package already in the project's node_modules",
|
|
381
|
+
"",
|
|
382
|
+
"```typescript",
|
|
383
|
+
"import * as fs from \"node:fs\";",
|
|
384
|
+
"import * as path from \"node:path\";",
|
|
385
|
+
"import { execSync } from \"node:child_process\";",
|
|
386
|
+
"```",
|
|
387
|
+
"",
|
|
388
|
+
"### Pi's Built-in Tools (what the agent already has)",
|
|
389
|
+
"",
|
|
390
|
+
"The agent already has these tools — do NOT reimplement them:",
|
|
391
|
+
"- `read` — Read file contents",
|
|
392
|
+
"- `write` — Create/overwrite files",
|
|
393
|
+
"- `edit` — Precise text replacement in files",
|
|
394
|
+
"- `bash` — Execute shell commands",
|
|
395
|
+
"- `grep` — Search file contents with regex",
|
|
396
|
+
"- `find` — Search files by name/pattern",
|
|
397
|
+
"- `ls` — List directory contents",
|
|
398
|
+
"- `recall` — Recall compressed memory observations",
|
|
399
|
+
"- `mcp` — MCP server gateway (if MCP extension is active)",
|
|
400
|
+
"",
|
|
401
|
+
"### Extension File Structure",
|
|
402
|
+
"",
|
|
403
|
+
"Each extension is a directory under `.pi/extensions/auto-research/`:",
|
|
404
|
+
"",
|
|
405
|
+
"```",
|
|
406
|
+
".pi/extensions/auto-research/",
|
|
407
|
+
" <extension-name>/",
|
|
408
|
+
" index.ts # Entry point — default export activate(pi)",
|
|
409
|
+
" utils.ts # [optional] Helper functions",
|
|
410
|
+
" state.json # [optional] Persisted state (for stateful extensions)",
|
|
411
|
+
"```",
|
|
412
|
+
"",
|
|
413
|
+
"### Handler Return Types",
|
|
414
|
+
"",
|
|
415
|
+
"All handlers (tools, commands) return content blocks:",
|
|
416
|
+
"",
|
|
417
|
+
"```typescript",
|
|
418
|
+
"// Success:",
|
|
419
|
+
"return { content: [{ type: \"text\", text: \"Result\" }] };",
|
|
420
|
+
"",
|
|
421
|
+
"// Error:",
|
|
422
|
+
"return {",
|
|
423
|
+
" content: [{ type: \"text\", text: \"Error message\" }],",
|
|
424
|
+
" isError: true,",
|
|
425
|
+
"};",
|
|
426
|
+
"```",
|
|
427
|
+
"",
|
|
428
|
+
"### Common Extension Patterns",
|
|
429
|
+
"",
|
|
430
|
+
"1. **Shell wrapper** — Use `ctx.bash()` inside a tool handler to run commands",
|
|
431
|
+
"2. **File processor** — Use `ctx.read()`/`ctx.write()` to process project files",
|
|
432
|
+
"3. **LLM-powered** — Use `pi.modelRegistry` to call AI models for smart processing",
|
|
433
|
+
"4. **Event listener** — Use `pi.on()` to react to agent lifecycle events",
|
|
434
|
+
"5. **Stateful** — Use `node:fs` to persist state as JSON between calls",
|
|
435
|
+
"6. **Subagent** — Spawn pi subprocesses for isolated analysis tasks",
|
|
436
|
+
"",
|
|
437
|
+
"---",
|
|
438
|
+
"",
|
|
439
|
+
"## Process",
|
|
440
|
+
"",
|
|
441
|
+
"1. **Context Collection** — Explore the project structure:",
|
|
442
|
+
" - Read package.json, tsconfig.json, deno.json (if present)",
|
|
443
|
+
" - Check existing extensions under .pi/extensions/",
|
|
444
|
+
" - Review key source files to understand architecture",
|
|
445
|
+
" - Check git log for recent changes and patterns",
|
|
446
|
+
"",
|
|
447
|
+
"2. **Analysis** — Identify automation opportunities:",
|
|
448
|
+
" - What repetitive tasks do developers perform?",
|
|
449
|
+
" - What project-specific tools or commands would help?",
|
|
450
|
+
" - Are there code patterns that could be automated?",
|
|
451
|
+
" - Could LLM-powered extensions improve developer workflows?",
|
|
452
|
+
"",
|
|
453
|
+
"3. **Extension Generation** — Create extension .ts files:",
|
|
454
|
+
" - Use `pi.registerTool()` for simple tools with TypeBox validation",
|
|
455
|
+
" - Use `pi.registerCommand()` for custom slash commands",
|
|
456
|
+
" - For LLM-powered extensions, use `ctx.modelRegistry` to call models",
|
|
457
|
+
" - Create files under `.pi/extensions/auto-research/<name>/`",
|
|
458
|
+
" - Each extension needs an `index.ts` that registers its tools/commands",
|
|
459
|
+
" - Read `.pi/agents/auto-research-templates.md` for proven extension templates",
|
|
460
|
+
"",
|
|
461
|
+
"4. **Validation** — Verify generated extensions:",
|
|
462
|
+
" - Ensure all imports resolve to available packages (see reference above)",
|
|
463
|
+
" - Verify registerTool/registerCommand calls have proper TypeBox schemas",
|
|
464
|
+
" - Ensure handlers always return content blocks",
|
|
465
|
+
" - Verify error handling with try/catch",
|
|
466
|
+
"",
|
|
467
|
+
"### Extension Categories (A-G)",
|
|
468
|
+
"",
|
|
469
|
+
"| Category | Description | Example |",
|
|
470
|
+
"|----------|-------------|---------|",
|
|
471
|
+
"| A. Workflow automation | Automate repetitive tasks | Auto-format on save, pre-commit hooks |",
|
|
472
|
+
"| B. Code generation | Generate boilerplate/scaffolding | Component generator, CRUD scaffold |",
|
|
473
|
+
"| C. Project-specific tools | Tools tailored to this project | Database migration helper, API client gen |",
|
|
474
|
+
"| D. Quality & review | Linting, testing, code review | PR reviewer, test coverage analyzer |",
|
|
475
|
+
"| E. Documentation | Auto-generate docs | README updater, API doc generator |",
|
|
476
|
+
"| F. LLM-powered | Extensions that call AI models | Code reviewer with LLM, smart refactor |",
|
|
477
|
+
"| G. Stateful | Extensions that persist state | Session memory, project stats tracker |",
|
|
478
|
+
"",
|
|
479
|
+
"### Important Rules",
|
|
480
|
+
"",
|
|
481
|
+
"- Write each extension as TypeScript files under `.pi/extensions/auto-research/<name>/`",
|
|
482
|
+
"- Every extension directory MUST have an `index.ts` entry point",
|
|
483
|
+
"- Use proper TypeScript with type annotations and error handling",
|
|
484
|
+
"- Extensions must handle errors gracefully (never crash the agent)",
|
|
485
|
+
"- Use `read`/`write`/`edit`/`bash` tools to create and verify files",
|
|
486
|
+
"- When generating LLM-powered extensions, explain how model selection works",
|
|
487
|
+
"",
|
|
488
|
+
"### 5. Update AGENTS.md — Persist Discovered Knowledge",
|
|
489
|
+
"",
|
|
490
|
+
"CRITICAL: After generating extensions, update the project's **AGENTS.md** so future",
|
|
491
|
+
"agent sessions automatically know about the new capabilities.",
|
|
492
|
+
"",
|
|
493
|
+
"**How AGENTS.md works:**",
|
|
494
|
+
"- Pi loads AGENTS.md at startup from the project root and parent directories",
|
|
495
|
+
"- All found AGENTS.md files are concatenated and injected into the system prompt",
|
|
496
|
+
"- This means documented extensions get discovered by the agent automatically",
|
|
497
|
+
"",
|
|
498
|
+
"**What to add:** Use HTML comment markers so future auto-research can update the section:",
|
|
499
|
+
"",
|
|
500
|
+
"```markdown",
|
|
501
|
+
"<!-- AUTO-RESEARCH:START -->",
|
|
502
|
+
"## Auto-Generated Extensions",
|
|
503
|
+
"",
|
|
504
|
+
"These extensions were generated by auto-research. They are available",
|
|
505
|
+
"in every session. Pi loads them automatically from `.pi/extensions/`.",
|
|
506
|
+
"",
|
|
507
|
+
"### `<extension-name>`",
|
|
508
|
+
"",
|
|
509
|
+
"- **Tools:** `tool_name` — description of what it does",
|
|
510
|
+
"- **Commands:** `/command-name` — description",
|
|
511
|
+
"- **When to use:** Brief guidance on when the agent should call this tool",
|
|
512
|
+
"- **Category:** A-G (workflow/codegen/project-specific/quality/docs/llm-powered/stateful)",
|
|
513
|
+
"",
|
|
514
|
+
"### Project Patterns Discovered",
|
|
515
|
+
"",
|
|
516
|
+
"- **Build system:** npm / deno / etc.",
|
|
517
|
+
"- **Test framework:** vitest / jest / etc.",
|
|
518
|
+
"- **Conventions:** key patterns the agent should follow",
|
|
519
|
+
"- **Key directories:** important source locations",
|
|
520
|
+
"<!-- AUTO-RESEARCH:END -->",
|
|
521
|
+
"```",
|
|
522
|
+
"",
|
|
523
|
+
"**Rules for the section:**",
|
|
524
|
+
"- Place it at the END of AGENTS.md (after all existing content)",
|
|
525
|
+
"- If an older `<!-- AUTO-RESEARCH:START -->...<!-- AUTO-RESEARCH:END -->` block",
|
|
526
|
+
" already exists, REPLACE it entirely with the updated version",
|
|
527
|
+
"- List EVERY generated extension with its full tool/command list",
|
|
528
|
+
"- Add project patterns discovered during context analysis",
|
|
529
|
+
"- Keep descriptions concise (agent uses this as reference, not tutorial)",
|
|
530
|
+
"",
|
|
531
|
+
"---",
|
|
532
|
+
"",
|
|
533
|
+
"Start by collecting project context, then generate the most impactful extensions",
|
|
534
|
+
"and update AGENTS.md when done.",
|
|
535
|
+
].join("\n");
|
|
172
536
|
}
|
|
173
537
|
// ---------------------------------------------------------------------------
|
|
174
538
|
// Handler
|
|
175
539
|
// ---------------------------------------------------------------------------
|
|
176
|
-
const AR_TIMEOUT_MS =
|
|
540
|
+
const AR_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes — generous for LLM-based analysis
|
|
177
541
|
/**
|
|
178
|
-
* Execute auto-research for a project.
|
|
542
|
+
* Execute auto-research for a project through the existing PiBridge.
|
|
179
543
|
*
|
|
180
544
|
* Caller (dispatcher) is fire-and-forget — this function is `void` and
|
|
181
545
|
* all errors are surfaced as `auto_research_error` events on the wire
|
|
182
546
|
* rather than thrown.
|
|
547
|
+
*
|
|
548
|
+
* This replaces the old subprocess-spawning approach. Instead of launching
|
|
549
|
+
* a separate pi process (which lacks backend proxy credentials), we send
|
|
550
|
+
* the auto-research task through the session's existing PiBridge. The agent
|
|
551
|
+
* uses the session's model, backend proxy, and all available tools.
|
|
183
552
|
*/
|
|
184
553
|
export function handleAutoResearch(input, deps) {
|
|
185
554
|
const { projectId, sessionId } = input;
|
|
186
|
-
const { store } = deps;
|
|
555
|
+
const { store, manager, relay, subscribers } = deps;
|
|
187
556
|
const logger = deps.logger ?? console;
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
sendEvent(deps, sessionId, {
|
|
557
|
+
// Guard: prevent concurrent auto-research on the same session.
|
|
558
|
+
if (activeAutoResearchSessions.has(sessionId)) {
|
|
559
|
+
sendEvent(relay, sessionId, {
|
|
192
560
|
type: "auto_research_error",
|
|
193
561
|
projectId,
|
|
194
|
-
message:
|
|
562
|
+
message: "Auto-research is already running for this session.",
|
|
195
563
|
});
|
|
196
564
|
return;
|
|
197
565
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
sendEvent(deps, sessionId, {
|
|
566
|
+
// Verify the project exists in the store.
|
|
567
|
+
const project = store.getProject(projectId);
|
|
568
|
+
if (!project) {
|
|
569
|
+
sendEvent(relay, sessionId, {
|
|
203
570
|
type: "auto_research_error",
|
|
204
571
|
projectId,
|
|
205
|
-
message:
|
|
206
|
-
"Create .pi/agents/auto-research.md in the project or ~/.pi/agent/agents/auto-research.md.",
|
|
572
|
+
message: `Project not found: ${projectId}`,
|
|
207
573
|
});
|
|
208
574
|
return;
|
|
209
575
|
}
|
|
210
|
-
|
|
211
|
-
|
|
576
|
+
const projectPath = project.path;
|
|
577
|
+
// Build the auto-research task prompt with incremental context.
|
|
578
|
+
const preRunContext = gatherPreRunContext(projectPath);
|
|
579
|
+
const taskContent = buildAutoResearchTask(projectPath, project.name, preRunContext);
|
|
580
|
+
// --- Emit start event ---
|
|
581
|
+
sendEvent(relay, sessionId, {
|
|
212
582
|
type: "auto_research_start",
|
|
213
583
|
projectId,
|
|
214
584
|
});
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
tmpPromptPath = path.join(tmpDir, "system-prompt.md");
|
|
222
|
-
fs.writeFileSync(tmpPromptPath, agentDef.systemPrompt, { encoding: "utf-8", mode: 0o600 });
|
|
223
|
-
}
|
|
224
|
-
catch (err) {
|
|
225
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
226
|
-
sendEvent(deps, sessionId, {
|
|
227
|
-
type: "auto_research_error",
|
|
228
|
-
projectId,
|
|
229
|
-
message: `Failed to write system prompt temp file: ${msg}`,
|
|
230
|
-
});
|
|
231
|
-
if (tmpDir) {
|
|
232
|
-
try {
|
|
233
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
234
|
-
}
|
|
235
|
-
catch { /* ignore */ }
|
|
585
|
+
// --- Ensure session has a wire subscriber (mirrors handleClientMessage) ---
|
|
586
|
+
let subscriber = subscribers.get(sessionId);
|
|
587
|
+
if (!subscriber) {
|
|
588
|
+
subscriber = makeRelaySubscriber(sessionId, relay);
|
|
589
|
+
try {
|
|
590
|
+
manager.attach(sessionId, subscriber);
|
|
236
591
|
}
|
|
237
|
-
|
|
592
|
+
catch (err) {
|
|
593
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
594
|
+
sendEvent(relay, sessionId, {
|
|
595
|
+
type: "auto_research_error",
|
|
596
|
+
projectId,
|
|
597
|
+
message: `Failed to attach session subscriber: ${msg}`,
|
|
598
|
+
});
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
subscribers.set(sessionId, subscriber);
|
|
238
602
|
}
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
603
|
+
// --- Get the session's current model (use same model as active session) ---
|
|
604
|
+
const storedModelId = store.getSessionModel(sessionId) ?? undefined;
|
|
605
|
+
// --- Mark auto-research as active ---
|
|
606
|
+
activeAutoResearchSessions.add(sessionId);
|
|
607
|
+
// We track whether the watcher has already fired (completion or error)
|
|
608
|
+
// so we don't emit duplicate events if both agent_end and prompt rejection
|
|
609
|
+
// race.
|
|
610
|
+
let watcherFired = false;
|
|
611
|
+
/**
|
|
612
|
+
* Safely finalize auto-research: mark inactive, detach watcher, emit event.
|
|
613
|
+
*/
|
|
614
|
+
const finalize = (event) => {
|
|
615
|
+
if (watcherFired)
|
|
616
|
+
return;
|
|
617
|
+
watcherFired = true;
|
|
618
|
+
activeAutoResearchSessions.delete(sessionId);
|
|
619
|
+
try {
|
|
620
|
+
manager.detach(sessionId, watcher);
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
/* best-effort */
|
|
624
|
+
}
|
|
625
|
+
sendEvent(relay, sessionId, event);
|
|
626
|
+
};
|
|
627
|
+
// --- Attach a watcher subscriber to detect turn completion ---
|
|
628
|
+
// This subscriber receives all broadcast events alongside the main subscriber
|
|
629
|
+
// but only acts on agent_end (→ auto_research_complete) and error events.
|
|
630
|
+
const watcher = {
|
|
631
|
+
send(event) {
|
|
632
|
+
if (watcherFired)
|
|
633
|
+
return;
|
|
634
|
+
if (event.type === "agent_end") {
|
|
635
|
+
// The auto-research turn completed. Scan for generated extensions
|
|
636
|
+
// and emit the completion event.
|
|
637
|
+
const extensions = scanGeneratedExtensions(projectPath);
|
|
638
|
+
const agentsMdUpdated = hasAgentsMdUpdate(projectPath);
|
|
639
|
+
writeManifest(projectPath, extensions);
|
|
640
|
+
finalize({
|
|
641
|
+
type: "auto_research_complete",
|
|
642
|
+
projectId,
|
|
643
|
+
extensions,
|
|
644
|
+
agentsMdUpdated,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
else if (event.type === "error") {
|
|
648
|
+
finalize({
|
|
649
|
+
type: "auto_research_error",
|
|
650
|
+
projectId,
|
|
651
|
+
message: event.message,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
isOpen() {
|
|
656
|
+
return true;
|
|
657
|
+
},
|
|
658
|
+
};
|
|
264
659
|
try {
|
|
265
|
-
|
|
266
|
-
cwd: projectPath,
|
|
267
|
-
stdio: ["ignore", "pipe", "pipe"], // stdin ignored — task is positional
|
|
268
|
-
env: { ...process.env },
|
|
269
|
-
shell: false,
|
|
270
|
-
});
|
|
660
|
+
manager.attach(sessionId, watcher);
|
|
271
661
|
}
|
|
272
662
|
catch (err) {
|
|
273
663
|
const msg = err instanceof Error ? err.message : String(err);
|
|
274
|
-
|
|
664
|
+
finalize({
|
|
275
665
|
type: "auto_research_error",
|
|
276
666
|
projectId,
|
|
277
|
-
message: `Failed to
|
|
667
|
+
message: `Failed to attach auto-research watcher: ${msg}`,
|
|
278
668
|
});
|
|
279
|
-
cleanupTemp(tmpDir);
|
|
280
669
|
return;
|
|
281
670
|
}
|
|
282
|
-
//
|
|
671
|
+
// --- Set up timeout ---
|
|
283
672
|
const timeout = setTimeout(() => {
|
|
284
|
-
|
|
285
|
-
sendEvent(deps, sessionId, {
|
|
673
|
+
finalize({
|
|
286
674
|
type: "auto_research_error",
|
|
287
675
|
projectId,
|
|
288
|
-
message: "Auto-research timed out after
|
|
676
|
+
message: "Auto-research timed out after 10 minutes.",
|
|
289
677
|
});
|
|
290
|
-
cleanupTemp(tmpDir);
|
|
291
678
|
}, AR_TIMEOUT_MS);
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// {"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
|
|
297
|
-
// {"type":"agent_end",...}
|
|
298
|
-
//
|
|
299
|
-
// The auto-research agent's output is inside assistant message_end
|
|
300
|
-
// events. We extract the text and try to parse it as auto-research
|
|
301
|
-
// event JSON (progress, extension_generated, done, error).
|
|
302
|
-
let stdoutBuffer = "";
|
|
303
|
-
const discoveredExtensions = [];
|
|
304
|
-
let stderrBuffer = "";
|
|
305
|
-
child.stdout?.on("data", (chunk) => {
|
|
306
|
-
stdoutBuffer += chunk.toString("utf-8");
|
|
307
|
-
// Process complete lines
|
|
308
|
-
const lines = stdoutBuffer.split("\n");
|
|
309
|
-
stdoutBuffer = lines.pop() ?? "";
|
|
310
|
-
for (const rawLine of lines) {
|
|
311
|
-
const line = rawLine.trim();
|
|
312
|
-
if (!line)
|
|
313
|
-
continue;
|
|
314
|
-
let event;
|
|
315
|
-
try {
|
|
316
|
-
event = JSON.parse(line);
|
|
317
|
-
}
|
|
318
|
-
catch {
|
|
319
|
-
// Non-JSON output — ignore.
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
// Only process message_end events from the assistant.
|
|
323
|
-
if (event.type !== "message_end" || !event.message)
|
|
324
|
-
continue;
|
|
325
|
-
if (event.message.role !== "assistant")
|
|
326
|
-
continue;
|
|
327
|
-
const content = event.message.content;
|
|
328
|
-
if (!content || !Array.isArray(content) || content.length === 0)
|
|
329
|
-
continue;
|
|
330
|
-
// Extract text blocks from the assistant message
|
|
331
|
-
for (const block of content) {
|
|
332
|
-
if (block.type !== "text" || typeof block.text !== "string")
|
|
333
|
-
continue;
|
|
334
|
-
// Try to parse the assistant's text output as one or more JSON
|
|
335
|
-
// auto-research events. The agent may output multiple JSON objects
|
|
336
|
-
// in a single assistant message (separated by newlines or
|
|
337
|
-
// concatenated). We try parsing the full text first, then fall
|
|
338
|
-
// back to line-by-line.
|
|
339
|
-
const text = block.text.trim();
|
|
340
|
-
// First, try treating the entire text block as a single event
|
|
341
|
-
let parsed = null;
|
|
342
|
-
try {
|
|
343
|
-
parsed = JSON.parse(text);
|
|
344
|
-
}
|
|
345
|
-
catch {
|
|
346
|
-
// Not a single JSON object — try line-by-line
|
|
347
|
-
}
|
|
348
|
-
if (parsed && parsed.type) {
|
|
349
|
-
processArEvent(parsed, discoveredExtensions, projectId, sessionId, deps);
|
|
350
|
-
}
|
|
351
|
-
else {
|
|
352
|
-
// Try each line individually (the agent may emit multi-line
|
|
353
|
-
// JSON event output, e.g. one event per line).
|
|
354
|
-
for (const subLine of text.split("\n")) {
|
|
355
|
-
const trimmed = subLine.trim();
|
|
356
|
-
if (!trimmed)
|
|
357
|
-
continue;
|
|
358
|
-
try {
|
|
359
|
-
const eventLine = JSON.parse(trimmed);
|
|
360
|
-
if (eventLine && eventLine.type) {
|
|
361
|
-
processArEvent(eventLine, discoveredExtensions, projectId, sessionId, deps);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
catch {
|
|
365
|
-
// skip non-JSON lines
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
|
-
child.stderr?.on("data", (chunk) => {
|
|
373
|
-
stderrBuffer += chunk.toString("utf-8");
|
|
374
|
-
});
|
|
375
|
-
// 10. Handle process exit
|
|
376
|
-
child.on("close", (code) => {
|
|
679
|
+
// Clear timeout on watcher fire (agent_end or error). We do this inside
|
|
680
|
+
// the finalize call by wrapping it.
|
|
681
|
+
const originalFinalize = finalize;
|
|
682
|
+
const finalizeWithCleanup = (event) => {
|
|
377
683
|
clearTimeout(timeout);
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
catch {
|
|
407
|
-
// ignore
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
if (code !== 0 && discoveredExtensions.length === 0) {
|
|
411
|
-
// Subprocess exited with error and no extensions were generated
|
|
412
|
-
const errDetail = stderrBuffer
|
|
413
|
-
? ` (stderr: ${stderrBuffer.slice(0, 500)})`
|
|
414
|
-
: "";
|
|
415
|
-
sendEvent(deps, sessionId, {
|
|
416
|
-
type: "auto_research_error",
|
|
417
|
-
projectId,
|
|
418
|
-
message: `Auto-research subprocess exited with code ${code}${errDetail}`,
|
|
419
|
-
});
|
|
420
|
-
cleanupTemp(tmpDir);
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
// Emit completion with any discovered extensions (even partial — the
|
|
424
|
-
// UI can show what was generated before the process died).
|
|
425
|
-
sendEvent(deps, sessionId, {
|
|
426
|
-
type: "auto_research_complete",
|
|
427
|
-
projectId,
|
|
428
|
-
extensions: discoveredExtensions,
|
|
429
|
-
});
|
|
430
|
-
// Log any stderr for debugging
|
|
431
|
-
if (stderrBuffer) {
|
|
432
|
-
logger.error?.(`[auto-research] subprocess stderr (code=${code}): ${stderrBuffer.slice(0, 1000)}`);
|
|
433
|
-
}
|
|
434
|
-
cleanupTemp(tmpDir);
|
|
435
|
-
});
|
|
436
|
-
child.on("error", (err) => {
|
|
684
|
+
originalFinalize(event);
|
|
685
|
+
};
|
|
686
|
+
// Nasty but effective: replace finalize in watcher closure. The watcher
|
|
687
|
+
// already has a reference to `finalize` from the outer scope. We can't
|
|
688
|
+
// easily reassign the const, so we use a mutable wrapper.
|
|
689
|
+
// Instead, we make watcherFired guard idempotent — the timeout clear
|
|
690
|
+
// is best-effort. If the watcher fires first, clearTimeout on an already-
|
|
691
|
+
// cleared timer is harmless. If the timeout fires first, finalize has
|
|
692
|
+
// already run and the watcher's `send` will be gated by `watcherFired`.
|
|
693
|
+
// Actually, let's just clear the timeout in the watcher itself. The watcher
|
|
694
|
+
// already calls `finalize` which sets `watcherFired = true`. We just need
|
|
695
|
+
// to clear the timeout before finalize. Let me restructure.
|
|
696
|
+
// Simpler approach: inline the timeout clearing in the watcher's send.
|
|
697
|
+
// Let me rewrite the watcher with a direct timeout reference.
|
|
698
|
+
// For now, the timeout uses finalize which sets watcherFired. If the
|
|
699
|
+
// watcher fires first (agent_end), finalize() runs, watcherFired=true,
|
|
700
|
+
// then timeout fires and calls finalize() again — but finalize() is
|
|
701
|
+
// gated by watcherFired, so it's a no-op. The only issue is we don't
|
|
702
|
+
// clearTimeout on watcher fire — but that's fine, the timer is harmless.
|
|
703
|
+
// --- Send the prompt through the existing PiBridge (backend proxy) ---
|
|
704
|
+
manager.prompt(sessionId, taskContent, storedModelId).catch((err) => {
|
|
705
|
+
if (watcherFired)
|
|
706
|
+
return; // already handled by watcher
|
|
707
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
708
|
+
logger.error?.(`[auto-research] manager.prompt failed for ${sessionId}:`, msg);
|
|
437
709
|
clearTimeout(timeout);
|
|
438
|
-
|
|
710
|
+
finalize({
|
|
439
711
|
type: "auto_research_error",
|
|
440
712
|
projectId,
|
|
441
|
-
message: `Auto-research
|
|
713
|
+
message: `Auto-research prompt failed: ${msg}`,
|
|
442
714
|
});
|
|
443
|
-
cleanupTemp(tmpDir);
|
|
444
715
|
});
|
|
445
716
|
}
|
|
446
|
-
// ---------------------------------------------------------------------------
|
|
447
|
-
// Event processor
|
|
448
|
-
// ---------------------------------------------------------------------------
|
|
449
|
-
function processArEvent(parsed, extensions, projectId, sessionId, deps) {
|
|
450
|
-
const t = parsed.type;
|
|
451
|
-
if (t === "progress") {
|
|
452
|
-
const p = parsed;
|
|
453
|
-
const wirePhase = mapPhase(p.phase);
|
|
454
|
-
sendEvent(deps, sessionId, {
|
|
455
|
-
type: "auto_research_progress",
|
|
456
|
-
projectId,
|
|
457
|
-
phase: wirePhase,
|
|
458
|
-
message: p.message,
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
else if (t === "extension_generated") {
|
|
462
|
-
const eg = parsed;
|
|
463
|
-
extensions.push({
|
|
464
|
-
name: eg.name,
|
|
465
|
-
path: eg.path,
|
|
466
|
-
description: eg.description,
|
|
467
|
-
usesLLM: eg.usesLLM,
|
|
468
|
-
fileCount: eg.fileCount,
|
|
469
|
-
});
|
|
470
|
-
// Also emit as a progress update so the UI shows real-time activity
|
|
471
|
-
sendEvent(deps, sessionId, {
|
|
472
|
-
type: "auto_research_progress",
|
|
473
|
-
projectId,
|
|
474
|
-
phase: "extension_generating",
|
|
475
|
-
message: `Generated: ${eg.name}`,
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
else if (t === "done") {
|
|
479
|
-
const d = parsed;
|
|
480
|
-
for (const ext of d.extensions) {
|
|
481
|
-
extensions.push(ext);
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
else if (t === "error") {
|
|
485
|
-
const e = parsed;
|
|
486
|
-
sendEvent(deps, sessionId, {
|
|
487
|
-
type: "auto_research_progress",
|
|
488
|
-
projectId,
|
|
489
|
-
phase: "extension_validating",
|
|
490
|
-
message: `Error: ${e.message}`,
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
// Unknown event types are silently ignored for forward compatibility
|
|
494
|
-
}
|
|
495
|
-
// ---------------------------------------------------------------------------
|
|
496
|
-
// Task builder
|
|
497
|
-
// ---------------------------------------------------------------------------
|
|
498
|
-
/**
|
|
499
|
-
* Build the user task prompt sent as positional argument to pi.
|
|
500
|
-
* The system prompt (from the agent definition) provides the detailed
|
|
501
|
-
* instructions; this is just the project-specific context.
|
|
502
|
-
*/
|
|
503
|
-
function buildUserTask(projectPath, projectName) {
|
|
504
|
-
return [
|
|
505
|
-
`Analyze the project at "${projectPath}" named "${projectName}" to determine`,
|
|
506
|
-
`what custom pi coding agent extensions would accelerate development.`,
|
|
507
|
-
``,
|
|
508
|
-
`Follow your system prompt instructions for the full process:`,
|
|
509
|
-
`1. Context collection — scan the project structure`,
|
|
510
|
-
`2. Analysis — identify patterns and automation opportunities`,
|
|
511
|
-
`3. Extension generation — create .ts extension files`,
|
|
512
|
-
`4. Validation — verify the extensions are correct`,
|
|
513
|
-
``,
|
|
514
|
-
`Important: Read the template library at .pi/agents/auto-research-templates.md`,
|
|
515
|
-
`if it exists, for ready-to-adapt extension templates.`,
|
|
516
|
-
].join("\n");
|
|
517
|
-
}
|
|
518
|
-
// ---------------------------------------------------------------------------
|
|
519
|
-
// Temp file cleanup
|
|
520
|
-
// ---------------------------------------------------------------------------
|
|
521
|
-
function cleanupTemp(tmpDir) {
|
|
522
|
-
if (!tmpDir)
|
|
523
|
-
return;
|
|
524
|
-
try {
|
|
525
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
526
|
-
}
|
|
527
|
-
catch {
|
|
528
|
-
// best-effort cleanup
|
|
529
|
-
}
|
|
530
|
-
}
|