@aexol/spectral 0.6.3 → 0.6.8
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/relay/auto-research.js +634 -416
- package/dist/relay/dispatcher.js +5 -7
- package/dist/relay/models-fetch.js +5 -1
- package/dist/server/pi-bridge.js +35 -11
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -138,7 +138,14 @@ function delegateToPi(args) {
|
|
|
138
138
|
const piBin = resolvePiBin();
|
|
139
139
|
const child = spawn(process.execPath, [piBin, ...args], {
|
|
140
140
|
stdio: "inherit",
|
|
141
|
-
env:
|
|
141
|
+
env: {
|
|
142
|
+
...process.env,
|
|
143
|
+
// Prevent git from opening an interactive editor when the agent
|
|
144
|
+
// runs commands like `git rebase --continue`, `git commit` (without -m),
|
|
145
|
+
// or `git merge`. Without this, the editor hangs forever waiting for
|
|
146
|
+
// TTY input that doesn't exist in the agent's non-interactive shell.
|
|
147
|
+
GIT_EDITOR: "true",
|
|
148
|
+
},
|
|
142
149
|
});
|
|
143
150
|
// Forward common termination signals to pi so its TUI can clean up.
|
|
144
151
|
const signals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
|
package/dist/commands/serve.js
CHANGED
|
@@ -1,498 +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 {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
const candidates = [
|
|
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
|
-
return null;
|
|
50
|
-
}
|
|
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();
|
|
51
25
|
// ---------------------------------------------------------------------------
|
|
52
26
|
// Helpers
|
|
53
27
|
// ---------------------------------------------------------------------------
|
|
54
28
|
/**
|
|
55
|
-
*
|
|
56
|
-
* Mirrors the logic in `agent/index.ts#getPiInvocation`.
|
|
29
|
+
* Send a ServerEvent to the browser via the relay on the auto-research session.
|
|
57
30
|
*/
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
31
|
+
function sendEvent(relay, sessionId, event) {
|
|
32
|
+
relay.send({ kind: "ws_event", sessionId, event });
|
|
33
|
+
}
|
|
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) {
|
|
40
|
+
return {
|
|
41
|
+
send(event) {
|
|
42
|
+
relay.send({ kind: "ws_event", sessionId, event });
|
|
43
|
+
},
|
|
44
|
+
isOpen() {
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
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.
|
|
53
|
+
*/
|
|
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;
|
|
75
66
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
|
78
88
|
}
|
|
79
89
|
catch {
|
|
80
90
|
/* skip */
|
|
81
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
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* skip */
|
|
105
|
+
}
|
|
106
|
+
extensions.push({
|
|
107
|
+
name: entry.name,
|
|
108
|
+
path: `.pi/extensions/auto-research/${entry.name}`,
|
|
109
|
+
description,
|
|
110
|
+
usesLLM,
|
|
111
|
+
fileCount,
|
|
112
|
+
});
|
|
82
113
|
}
|
|
83
|
-
return false;
|
|
84
114
|
}
|
|
85
|
-
|
|
86
|
-
|
|
115
|
+
catch {
|
|
116
|
+
/* skip */
|
|
87
117
|
}
|
|
88
|
-
|
|
89
|
-
|
|
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;
|
|
90
132
|
}
|
|
91
|
-
return { command: "pi", args: subagentArgs };
|
|
92
133
|
}
|
|
93
134
|
/**
|
|
94
|
-
*
|
|
135
|
+
* Read the auto-research manifest from disk. Returns null if it doesn't exist
|
|
136
|
+
* or can't be parsed.
|
|
95
137
|
*/
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
}
|
|
102
158
|
}
|
|
103
159
|
/**
|
|
104
|
-
*
|
|
160
|
+
* Gather pre-run context for incremental auto-research.
|
|
105
161
|
*/
|
|
106
|
-
function
|
|
162
|
+
function gatherPreRunContext(projectPath) {
|
|
163
|
+
const manifest = readManifest(projectPath);
|
|
164
|
+
const isIncremental = manifest !== null;
|
|
165
|
+
const existingExtensions = [];
|
|
107
166
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
child.kill("SIGKILL");
|
|
114
|
-
}
|
|
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);
|
|
115
172
|
}
|
|
116
|
-
|
|
117
|
-
// ignore
|
|
118
|
-
}
|
|
119
|
-
}, 2000);
|
|
173
|
+
}
|
|
120
174
|
}
|
|
121
175
|
}
|
|
122
|
-
catch {
|
|
123
|
-
|
|
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 */ }
|
|
124
187
|
}
|
|
188
|
+
return { isIncremental, manifest, changesSinceLastRun, existingExtensions };
|
|
125
189
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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)", "");
|
|
138
202
|
}
|
|
139
|
-
|
|
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");
|
|
255
|
+
}
|
|
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");
|
|
140
536
|
}
|
|
141
537
|
// ---------------------------------------------------------------------------
|
|
142
538
|
// Handler
|
|
143
539
|
// ---------------------------------------------------------------------------
|
|
144
|
-
const AR_TIMEOUT_MS =
|
|
540
|
+
const AR_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes — generous for LLM-based analysis
|
|
145
541
|
/**
|
|
146
|
-
* Execute auto-research for a project.
|
|
542
|
+
* Execute auto-research for a project through the existing PiBridge.
|
|
147
543
|
*
|
|
148
544
|
* Caller (dispatcher) is fire-and-forget — this function is `void` and
|
|
149
545
|
* all errors are surfaced as `auto_research_error` events on the wire
|
|
150
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.
|
|
151
552
|
*/
|
|
152
553
|
export function handleAutoResearch(input, deps) {
|
|
153
554
|
const { projectId, sessionId } = input;
|
|
154
|
-
const { store } = deps;
|
|
555
|
+
const { store, manager, relay, subscribers } = deps;
|
|
155
556
|
const logger = deps.logger ?? console;
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
sendEvent(deps, sessionId, {
|
|
557
|
+
// Guard: prevent concurrent auto-research on the same session.
|
|
558
|
+
if (activeAutoResearchSessions.has(sessionId)) {
|
|
559
|
+
sendEvent(relay, sessionId, {
|
|
160
560
|
type: "auto_research_error",
|
|
161
561
|
projectId,
|
|
162
|
-
message:
|
|
562
|
+
message: "Auto-research is already running for this session.",
|
|
163
563
|
});
|
|
164
564
|
return;
|
|
165
565
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
sendEvent(deps, sessionId, {
|
|
566
|
+
// Verify the project exists in the store.
|
|
567
|
+
const project = store.getProject(projectId);
|
|
568
|
+
if (!project) {
|
|
569
|
+
sendEvent(relay, sessionId, {
|
|
171
570
|
type: "auto_research_error",
|
|
172
571
|
projectId,
|
|
173
|
-
message:
|
|
174
|
-
"Create .pi/agents/auto-research.md in the project or ~/.pi/agent/agents/auto-research.md.",
|
|
572
|
+
message: `Project not found: ${projectId}`,
|
|
175
573
|
});
|
|
176
574
|
return;
|
|
177
575
|
}
|
|
178
|
-
|
|
179
|
-
|
|
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, {
|
|
180
582
|
type: "auto_research_start",
|
|
181
583
|
projectId,
|
|
182
584
|
});
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
tmpPromptPath = path.join(tmpDir, "system-prompt.md");
|
|
190
|
-
fs.writeFileSync(tmpPromptPath, agentDef.systemPrompt, { encoding: "utf-8", mode: 0o600 });
|
|
191
|
-
}
|
|
192
|
-
catch (err) {
|
|
193
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
194
|
-
sendEvent(deps, sessionId, {
|
|
195
|
-
type: "auto_research_error",
|
|
196
|
-
projectId,
|
|
197
|
-
message: `Failed to write system prompt temp file: ${msg}`,
|
|
198
|
-
});
|
|
199
|
-
if (tmpDir) {
|
|
200
|
-
try {
|
|
201
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
202
|
-
}
|
|
203
|
-
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);
|
|
204
591
|
}
|
|
205
|
-
|
|
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);
|
|
206
602
|
}
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
};
|
|
232
659
|
try {
|
|
233
|
-
|
|
234
|
-
cwd: projectPath,
|
|
235
|
-
stdio: ["ignore", "pipe", "pipe"], // stdin ignored — task is positional
|
|
236
|
-
env: { ...process.env },
|
|
237
|
-
shell: false,
|
|
238
|
-
});
|
|
660
|
+
manager.attach(sessionId, watcher);
|
|
239
661
|
}
|
|
240
662
|
catch (err) {
|
|
241
663
|
const msg = err instanceof Error ? err.message : String(err);
|
|
242
|
-
|
|
664
|
+
finalize({
|
|
243
665
|
type: "auto_research_error",
|
|
244
666
|
projectId,
|
|
245
|
-
message: `Failed to
|
|
667
|
+
message: `Failed to attach auto-research watcher: ${msg}`,
|
|
246
668
|
});
|
|
247
|
-
cleanupTemp(tmpDir);
|
|
248
669
|
return;
|
|
249
670
|
}
|
|
250
|
-
//
|
|
671
|
+
// --- Set up timeout ---
|
|
251
672
|
const timeout = setTimeout(() => {
|
|
252
|
-
|
|
253
|
-
sendEvent(deps, sessionId, {
|
|
673
|
+
finalize({
|
|
254
674
|
type: "auto_research_error",
|
|
255
675
|
projectId,
|
|
256
|
-
message: "Auto-research timed out after
|
|
676
|
+
message: "Auto-research timed out after 10 minutes.",
|
|
257
677
|
});
|
|
258
|
-
cleanupTemp(tmpDir);
|
|
259
678
|
}, AR_TIMEOUT_MS);
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// {"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
|
|
265
|
-
// {"type":"agent_end",...}
|
|
266
|
-
//
|
|
267
|
-
// The auto-research agent's output is inside assistant message_end
|
|
268
|
-
// events. We extract the text and try to parse it as auto-research
|
|
269
|
-
// event JSON (progress, extension_generated, done, error).
|
|
270
|
-
let stdoutBuffer = "";
|
|
271
|
-
const discoveredExtensions = [];
|
|
272
|
-
let stderrBuffer = "";
|
|
273
|
-
child.stdout?.on("data", (chunk) => {
|
|
274
|
-
stdoutBuffer += chunk.toString("utf-8");
|
|
275
|
-
// Process complete lines
|
|
276
|
-
const lines = stdoutBuffer.split("\n");
|
|
277
|
-
stdoutBuffer = lines.pop() ?? "";
|
|
278
|
-
for (const rawLine of lines) {
|
|
279
|
-
const line = rawLine.trim();
|
|
280
|
-
if (!line)
|
|
281
|
-
continue;
|
|
282
|
-
let event;
|
|
283
|
-
try {
|
|
284
|
-
event = JSON.parse(line);
|
|
285
|
-
}
|
|
286
|
-
catch {
|
|
287
|
-
// Non-JSON output — ignore.
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
// Only process message_end events from the assistant.
|
|
291
|
-
if (event.type !== "message_end" || !event.message)
|
|
292
|
-
continue;
|
|
293
|
-
if (event.message.role !== "assistant")
|
|
294
|
-
continue;
|
|
295
|
-
const content = event.message.content;
|
|
296
|
-
if (!content || !Array.isArray(content) || content.length === 0)
|
|
297
|
-
continue;
|
|
298
|
-
// Extract text blocks from the assistant message
|
|
299
|
-
for (const block of content) {
|
|
300
|
-
if (block.type !== "text" || typeof block.text !== "string")
|
|
301
|
-
continue;
|
|
302
|
-
// Try to parse the assistant's text output as one or more JSON
|
|
303
|
-
// auto-research events. The agent may output multiple JSON objects
|
|
304
|
-
// in a single assistant message (separated by newlines or
|
|
305
|
-
// concatenated). We try parsing the full text first, then fall
|
|
306
|
-
// back to line-by-line.
|
|
307
|
-
const text = block.text.trim();
|
|
308
|
-
// First, try treating the entire text block as a single event
|
|
309
|
-
let parsed = null;
|
|
310
|
-
try {
|
|
311
|
-
parsed = JSON.parse(text);
|
|
312
|
-
}
|
|
313
|
-
catch {
|
|
314
|
-
// Not a single JSON object — try line-by-line
|
|
315
|
-
}
|
|
316
|
-
if (parsed && parsed.type) {
|
|
317
|
-
processArEvent(parsed, discoveredExtensions, projectId, sessionId, deps);
|
|
318
|
-
}
|
|
319
|
-
else {
|
|
320
|
-
// Try each line individually (the agent may emit multi-line
|
|
321
|
-
// JSON event output, e.g. one event per line).
|
|
322
|
-
for (const subLine of text.split("\n")) {
|
|
323
|
-
const trimmed = subLine.trim();
|
|
324
|
-
if (!trimmed)
|
|
325
|
-
continue;
|
|
326
|
-
try {
|
|
327
|
-
const eventLine = JSON.parse(trimmed);
|
|
328
|
-
if (eventLine && eventLine.type) {
|
|
329
|
-
processArEvent(eventLine, discoveredExtensions, projectId, sessionId, deps);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
catch {
|
|
333
|
-
// skip non-JSON lines
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
});
|
|
340
|
-
child.stderr?.on("data", (chunk) => {
|
|
341
|
-
stderrBuffer += chunk.toString("utf-8");
|
|
342
|
-
});
|
|
343
|
-
// 10. Handle process exit
|
|
344
|
-
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) => {
|
|
345
683
|
clearTimeout(timeout);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
catch {
|
|
375
|
-
// ignore
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
if (code !== 0 && discoveredExtensions.length === 0) {
|
|
379
|
-
// Subprocess exited with error and no extensions were generated
|
|
380
|
-
const errDetail = stderrBuffer
|
|
381
|
-
? ` (stderr: ${stderrBuffer.slice(0, 500)})`
|
|
382
|
-
: "";
|
|
383
|
-
sendEvent(deps, sessionId, {
|
|
384
|
-
type: "auto_research_error",
|
|
385
|
-
projectId,
|
|
386
|
-
message: `Auto-research subprocess exited with code ${code}${errDetail}`,
|
|
387
|
-
});
|
|
388
|
-
cleanupTemp(tmpDir);
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
// Emit completion with any discovered extensions (even partial — the
|
|
392
|
-
// UI can show what was generated before the process died).
|
|
393
|
-
sendEvent(deps, sessionId, {
|
|
394
|
-
type: "auto_research_complete",
|
|
395
|
-
projectId,
|
|
396
|
-
extensions: discoveredExtensions,
|
|
397
|
-
});
|
|
398
|
-
// Log any stderr for debugging
|
|
399
|
-
if (stderrBuffer) {
|
|
400
|
-
logger.error?.(`[auto-research] subprocess stderr (code=${code}): ${stderrBuffer.slice(0, 1000)}`);
|
|
401
|
-
}
|
|
402
|
-
cleanupTemp(tmpDir);
|
|
403
|
-
});
|
|
404
|
-
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);
|
|
405
709
|
clearTimeout(timeout);
|
|
406
|
-
|
|
710
|
+
finalize({
|
|
407
711
|
type: "auto_research_error",
|
|
408
712
|
projectId,
|
|
409
|
-
message: `Auto-research
|
|
713
|
+
message: `Auto-research prompt failed: ${msg}`,
|
|
410
714
|
});
|
|
411
|
-
cleanupTemp(tmpDir);
|
|
412
715
|
});
|
|
413
716
|
}
|
|
414
|
-
// ---------------------------------------------------------------------------
|
|
415
|
-
// Event processor
|
|
416
|
-
// ---------------------------------------------------------------------------
|
|
417
|
-
function processArEvent(parsed, extensions, projectId, sessionId, deps) {
|
|
418
|
-
const t = parsed.type;
|
|
419
|
-
if (t === "progress") {
|
|
420
|
-
const p = parsed;
|
|
421
|
-
const wirePhase = mapPhase(p.phase);
|
|
422
|
-
sendEvent(deps, sessionId, {
|
|
423
|
-
type: "auto_research_progress",
|
|
424
|
-
projectId,
|
|
425
|
-
phase: wirePhase,
|
|
426
|
-
message: p.message,
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
else if (t === "extension_generated") {
|
|
430
|
-
const eg = parsed;
|
|
431
|
-
extensions.push({
|
|
432
|
-
name: eg.name,
|
|
433
|
-
path: eg.path,
|
|
434
|
-
description: eg.description,
|
|
435
|
-
usesLLM: eg.usesLLM,
|
|
436
|
-
fileCount: eg.fileCount,
|
|
437
|
-
});
|
|
438
|
-
// Also emit as a progress update so the UI shows real-time activity
|
|
439
|
-
sendEvent(deps, sessionId, {
|
|
440
|
-
type: "auto_research_progress",
|
|
441
|
-
projectId,
|
|
442
|
-
phase: "extension_generating",
|
|
443
|
-
message: `Generated: ${eg.name}`,
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
else if (t === "done") {
|
|
447
|
-
const d = parsed;
|
|
448
|
-
for (const ext of d.extensions) {
|
|
449
|
-
extensions.push(ext);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
else if (t === "error") {
|
|
453
|
-
const e = parsed;
|
|
454
|
-
sendEvent(deps, sessionId, {
|
|
455
|
-
type: "auto_research_progress",
|
|
456
|
-
projectId,
|
|
457
|
-
phase: "extension_validating",
|
|
458
|
-
message: `Error: ${e.message}`,
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
// Unknown event types are silently ignored for forward compatibility
|
|
462
|
-
}
|
|
463
|
-
// ---------------------------------------------------------------------------
|
|
464
|
-
// Task builder
|
|
465
|
-
// ---------------------------------------------------------------------------
|
|
466
|
-
/**
|
|
467
|
-
* Build the user task prompt sent as positional argument to pi.
|
|
468
|
-
* The system prompt (from the agent definition) provides the detailed
|
|
469
|
-
* instructions; this is just the project-specific context.
|
|
470
|
-
*/
|
|
471
|
-
function buildUserTask(projectPath, projectName) {
|
|
472
|
-
return [
|
|
473
|
-
`Analyze the project at "${projectPath}" named "${projectName}" to determine`,
|
|
474
|
-
`what custom pi coding agent extensions would accelerate development.`,
|
|
475
|
-
``,
|
|
476
|
-
`Follow your system prompt instructions for the full process:`,
|
|
477
|
-
`1. Context collection — scan the project structure`,
|
|
478
|
-
`2. Analysis — identify patterns and automation opportunities`,
|
|
479
|
-
`3. Extension generation — create .ts extension files`,
|
|
480
|
-
`4. Validation — verify the extensions are correct`,
|
|
481
|
-
``,
|
|
482
|
-
`Important: Read the template library at .pi/agents/auto-research-templates.md`,
|
|
483
|
-
`if it exists, for ready-to-adapt extension templates.`,
|
|
484
|
-
].join("\n");
|
|
485
|
-
}
|
|
486
|
-
// ---------------------------------------------------------------------------
|
|
487
|
-
// Temp file cleanup
|
|
488
|
-
// ---------------------------------------------------------------------------
|
|
489
|
-
function cleanupTemp(tmpDir) {
|
|
490
|
-
if (!tmpDir)
|
|
491
|
-
return;
|
|
492
|
-
try {
|
|
493
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
494
|
-
}
|
|
495
|
-
catch {
|
|
496
|
-
// best-effort cleanup
|
|
497
|
-
}
|
|
498
|
-
}
|
package/dist/relay/dispatcher.js
CHANGED
|
@@ -633,17 +633,15 @@ export function detachAllSubscribers(manager, subscribers) {
|
|
|
633
633
|
subscribers.clear();
|
|
634
634
|
}
|
|
635
635
|
/**
|
|
636
|
-
* Dispatch an `auto_research` frame.
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
* 4. Hot-reloads pi so new extensions take effect immediately
|
|
636
|
+
* Dispatch an `auto_research` frame. Sends the auto-research task through
|
|
637
|
+
* the existing PiBridge (backend proxy) instead of spawning a separate pi
|
|
638
|
+
* subprocess. This ensures auto-research uses the same model and API keys
|
|
639
|
+
* as the active session.
|
|
641
640
|
*
|
|
642
641
|
* Progress is streamed back as `ws_event` frames carrying
|
|
643
642
|
* `auto_research_*` ServerEvent types on the `sessionId` channel.
|
|
644
643
|
*
|
|
645
|
-
* Errors are surfaced as `auto_research_error` events
|
|
646
|
-
* subprocess is killed on error to prevent zombie processes.
|
|
644
|
+
* Errors are surfaced as `auto_research_error` events.
|
|
647
645
|
*/
|
|
648
646
|
export function handleAutoResearchFrame(frame, deps) {
|
|
649
647
|
handleAutoResearch({
|
|
@@ -28,7 +28,7 @@ const cache = new Map();
|
|
|
28
28
|
export function clearAllowedModelsCache() {
|
|
29
29
|
cache.clear();
|
|
30
30
|
}
|
|
31
|
-
const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow } }`;
|
|
31
|
+
const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow supportsImages } }`;
|
|
32
32
|
/**
|
|
33
33
|
* Fetch the whitelist of allowed base models. Throws on any failure with a
|
|
34
34
|
* message tailored for an operator running `spectral serve` — the caller
|
|
@@ -101,6 +101,9 @@ export async function fetchAllowedModels(opts) {
|
|
|
101
101
|
const contextWindow = typeof row?.contextWindow === "number" && row.contextWindow > 0
|
|
102
102
|
? row.contextWindow
|
|
103
103
|
: null;
|
|
104
|
+
const supportsImages = typeof row?.supportsImages === "boolean"
|
|
105
|
+
? row.supportsImages
|
|
106
|
+
: null;
|
|
104
107
|
const model = {
|
|
105
108
|
modelId: name,
|
|
106
109
|
displayName: name,
|
|
@@ -111,6 +114,7 @@ export async function fetchAllowedModels(opts) {
|
|
|
111
114
|
creditCacheReadPer1M: asOptionalNumber(row?.creditCacheReadPer1M),
|
|
112
115
|
creditCacheWritePer1M: asOptionalNumber(row?.creditCacheWritePer1M),
|
|
113
116
|
contextWindow,
|
|
117
|
+
supportsImages,
|
|
114
118
|
};
|
|
115
119
|
if (typeof row?.userModelId === "string") {
|
|
116
120
|
model.userModelId = row.userModelId;
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -610,7 +610,7 @@ export class PiBridge {
|
|
|
610
610
|
provider: SPECTRAL_PROXY_ANTHROPIC,
|
|
611
611
|
baseUrl,
|
|
612
612
|
reasoning: supportsReasoning(m.modelId),
|
|
613
|
-
input: ["text", "image"],
|
|
613
|
+
input: m.supportsImages !== false ? ["text", "image"] : ["text"],
|
|
614
614
|
// Real pricing so pi can compute accurate token costs.
|
|
615
615
|
cost: pricing
|
|
616
616
|
? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
|
|
@@ -640,7 +640,7 @@ export class PiBridge {
|
|
|
640
640
|
provider: SPECTRAL_PROXY_OPENAI,
|
|
641
641
|
baseUrl,
|
|
642
642
|
reasoning: supportsReasoning(m.modelId),
|
|
643
|
-
input: ["text", "image"],
|
|
643
|
+
input: m.supportsImages !== false ? ["text", "image"] : ["text"],
|
|
644
644
|
// Real pricing so pi can compute accurate token costs.
|
|
645
645
|
cost: pricing
|
|
646
646
|
? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
|
|
@@ -671,7 +671,7 @@ export class PiBridge {
|
|
|
671
671
|
provider: SPECTRAL_PROXY_USER_MODEL,
|
|
672
672
|
baseUrl,
|
|
673
673
|
reasoning: supportsReasoning(m.modelId),
|
|
674
|
-
input: ["text", "image"],
|
|
674
|
+
input: m.supportsImages !== false ? ["text", "image"] : ["text"],
|
|
675
675
|
cost: pricing
|
|
676
676
|
? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
|
|
677
677
|
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
@@ -802,22 +802,46 @@ export class PiBridge {
|
|
|
802
802
|
*
|
|
803
803
|
* When `images` is non-empty, each base64-encoded attachment is converted
|
|
804
804
|
* to a pi `ImageContent` block and passed as `options.images` to
|
|
805
|
-
* `session.prompt()`.
|
|
806
|
-
*
|
|
807
|
-
*
|
|
805
|
+
* `session.prompt()`. If the current model does not support image inputs,
|
|
806
|
+
* images are instead converted to text placeholders so the conversation
|
|
807
|
+
* can continue without errors.
|
|
808
808
|
*/
|
|
809
809
|
async prompt(text, images) {
|
|
810
810
|
if (!this.session)
|
|
811
811
|
throw new Error("PiBridge.start() not called");
|
|
812
|
+
// Check whether the currently active model supports image input.
|
|
813
|
+
// When `supportsImages` is null/undefined (unknown), we are conservative
|
|
814
|
+
// and convert images to text rather than risking a 400 error.
|
|
815
|
+
const currentModel = this.lastAppliedModelId
|
|
816
|
+
? this.allowedModels?.find((m) => m.modelId === this.lastAppliedModelId)
|
|
817
|
+
: undefined;
|
|
818
|
+
const modelSupportsImages = currentModel?.supportsImages === true;
|
|
812
819
|
try {
|
|
813
|
-
|
|
814
|
-
|
|
820
|
+
if (images && images.length > 0 && modelSupportsImages) {
|
|
821
|
+
const imageContents = images.map((img) => ({
|
|
815
822
|
type: "image",
|
|
816
823
|
data: img.data,
|
|
817
824
|
mimeType: img.mimeType,
|
|
818
|
-
}))
|
|
819
|
-
:
|
|
820
|
-
|
|
825
|
+
}));
|
|
826
|
+
await this.session.prompt(text, { images: imageContents });
|
|
827
|
+
}
|
|
828
|
+
else if (images && images.length > 0 && !modelSupportsImages) {
|
|
829
|
+
// Model doesn't support images — convert them to text descriptions
|
|
830
|
+
// so the conversation can continue instead of hanging.
|
|
831
|
+
const imageDescriptions = images
|
|
832
|
+
.map((img, i) => `[Image ${i + 1}: ${img.mimeType}, ${img.data.length.toLocaleString()} bytes base64]`)
|
|
833
|
+
.join("\n");
|
|
834
|
+
const augmentedText = `${text}\n\n---\nThe following image(s) were attached but the current model does not support image input:\n${imageDescriptions}\n(Describe what you see or ask the user to switch to a model that supports images.)`;
|
|
835
|
+
this.opts.emit({
|
|
836
|
+
type: "agent_notification",
|
|
837
|
+
message: `The current model does not support image input. ${images.length} image(s) were converted to text descriptions.`,
|
|
838
|
+
level: "warning",
|
|
839
|
+
});
|
|
840
|
+
await this.session.prompt(augmentedText);
|
|
841
|
+
}
|
|
842
|
+
else {
|
|
843
|
+
await this.session.prompt(text);
|
|
844
|
+
}
|
|
821
845
|
}
|
|
822
846
|
catch (err) {
|
|
823
847
|
const e = err instanceof Error ? err : new Error(String(err));
|