@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.
@@ -1,530 +1,716 @@
1
1
  /**
2
- * Auto-research handler — spawns an isolated pi subprocess to analyze a
3
- * project and generate custom extensions.
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
- * Flow:
6
- * 1. Resolve project path from the SQLite store
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
- * This mirrors the subagent extension's spawn pattern (agent/index.ts) so
18
- * pi receives the task and system prompt in the same format it expects.
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 { parseFrontmatter } from "@mariozechner/pi-coding-agent";
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
- * Locate the auto-research agent definition markdown file.
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 findAgentDef(projectPath) {
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
- // 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
- /** Default agent definition used as a fallback when no agent .md file exists. */
55
- function getDefaultAgentDef() {
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
- model: "claude-sonnet-4-5",
58
- systemPrompt: [
59
- "You are an auto-research agent. Analyze the project and generate custom",
60
- "pi coding agent extensions. Output ONLY JSON lines (one per line).",
61
- "",
62
- "## Process",
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
- * Detect the pi/spectral binary for spawning subprocesses.
88
- * Mirrors the logic in `agent/index.ts#getPiInvocation`.
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 getPiInvocation(subagentArgs) {
91
- const currentScript = process.argv[1];
92
- // Case 1: running via tsx with an existing script file
93
- if (currentScript && fs.existsSync(currentScript)) {
94
- return { command: process.execPath, args: [currentScript, ...subagentArgs] };
95
- }
96
- // Case 2: check if this is the spectral/aexol wrapper binary
97
- const execName = path.basename(process.execPath).toLowerCase();
98
- if (execName === "spectral" || execName === "aexol") {
99
- return { command: process.execPath, args: subagentArgs };
100
- }
101
- // Case 3: generic node — try spectral first, then pi
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
- if (fs.existsSync(candidate))
109
- return true;
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
- if (hasBin("spectral")) {
118
- return { command: "spectral", args: subagentArgs };
115
+ catch {
116
+ /* skip */
119
117
  }
120
- if (hasBin("pi")) {
121
- return { command: "pi", args: subagentArgs };
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
- * Send a ServerEvent to the browser via the relay on the auto-research session.
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 sendEvent(deps, sessionId, event) {
129
- deps.relay.send({
130
- kind: "ws_event",
131
- sessionId,
132
- event,
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
- * Safely kill a child process. Best-effort — errors are silently swallowed.
160
+ * Gather pre-run context for incremental auto-research.
137
161
  */
138
- function killProcess(child) {
162
+ function gatherPreRunContext(projectPath) {
163
+ const manifest = readManifest(projectPath);
164
+ const isIncremental = manifest !== null;
165
+ const existingExtensions = [];
139
166
  try {
140
- if (!child.killed && child.exitCode === null) {
141
- child.kill("SIGTERM");
142
- setTimeout(() => {
143
- try {
144
- if (!child.killed && child.exitCode === null) {
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
- }, 2000);
173
+ }
152
174
  }
153
175
  }
154
- catch {
155
- // ignore
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
- // Phase mapping
160
- // ---------------------------------------------------------------------------
161
- const VALID_PHASES = new Set([
162
- "context_collecting",
163
- "context_analyzing",
164
- "extension_generating",
165
- "extension_validating",
166
- ]);
167
- function mapPhase(agentPhase) {
168
- if (VALID_PHASES.has(agentPhase)) {
169
- return agentPhase;
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
- return "context_analyzing";
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 = 5 * 60 * 1000; // 5 minutes — generous for LLM-based analysis
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
- // 1. Resolve the project from the store.
189
- const project = store.getProject(projectId);
190
- if (!project) {
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: `Project not found: ${projectId}`,
562
+ message: "Auto-research is already running for this session.",
195
563
  });
196
564
  return;
197
565
  }
198
- const projectPath = project.path;
199
- // 2. Load the auto-research agent definition.
200
- const agentDef = findAgentDef(projectPath);
201
- if (!agentDef) {
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: "Auto-research agent definition not found. " +
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
- // 3. Emit start event
211
- sendEvent(deps, sessionId, {
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
- // 4. Write the system prompt to a temp file so pi can load it via
216
- // --append-system-prompt (mirrors the subagent extension pattern).
217
- let tmpDir = null;
218
- let tmpPromptPath = null;
219
- try {
220
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "spectral-auto-research-"));
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
- return;
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
- // 5. Build the user task. This becomes a positional argument to pi,
240
- // matching how the subagent extension passes tasks.
241
- const task = buildUserTask(projectPath, project.name);
242
- // 6. Build spawn arguments (mirroring agent/index.ts subprocess spawn).
243
- const args = [
244
- "--mode", "json",
245
- "-p",
246
- "--no-session",
247
- "--model", agentDef.model,
248
- "--append-system-prompt", tmpPromptPath,
249
- ];
250
- // Add tools if defined in the agent definition. The auto-research agent
251
- // uses read/grep/find/ls/bash/write/edit — its frontmatter should declare
252
- // them so pi allows those tools.
253
- // We don't extract tools from frontmatter currently because parseFrontmatter
254
- // returns a generic Record. For now, auto-research always uses the default
255
- // tool set (pi's full tool set is available by default in -p mode).
256
- // TODO: when agent def frontmatter parsing is unified with agents.ts, also
257
- // pass --tools here.
258
- // The user task is the last positional argument — pi treats it as the
259
- // initial prompt in -p mode.
260
- args.push(`Task: ${task}`);
261
- // 7. Spawn pi subprocess.
262
- const invocation = getPiInvocation(args);
263
- let child;
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
- child = spawn(invocation.command, invocation.args, {
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
- sendEvent(deps, sessionId, {
664
+ finalize({
275
665
  type: "auto_research_error",
276
666
  projectId,
277
- message: `Failed to spawn pi subprocess: ${msg}`,
667
+ message: `Failed to attach auto-research watcher: ${msg}`,
278
668
  });
279
- cleanupTemp(tmpDir);
280
669
  return;
281
670
  }
282
- // 8. Set up timeout
671
+ // --- Set up timeout ---
283
672
  const timeout = setTimeout(() => {
284
- killProcess(child);
285
- sendEvent(deps, sessionId, {
673
+ finalize({
286
674
  type: "auto_research_error",
287
675
  projectId,
288
- message: "Auto-research timed out after 5 minutes",
676
+ message: "Auto-research timed out after 10 minutes.",
289
677
  });
290
- cleanupTemp(tmpDir);
291
678
  }, AR_TIMEOUT_MS);
292
- // 9. Collect stdout and parse pi's JSON-line output format.
293
- // pi in --mode json emits one JSON line per event:
294
- // {"type":"message_start",...}
295
- // {"type":"text_delta","content":"..."}
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
- // Process any remaining buffered stdout (drain the buffer)
379
- if (stdoutBuffer.trim()) {
380
- try {
381
- const event = JSON.parse(stdoutBuffer.trim());
382
- if (event.type === "message_end" && event.message?.role === "assistant") {
383
- const content = event.message?.content;
384
- if (Array.isArray(content)) {
385
- for (const block of content) {
386
- if (block.type === "text" && typeof block.text === "string") {
387
- for (const subLine of block.text.split("\n")) {
388
- const trimmed = subLine.trim();
389
- if (!trimmed)
390
- continue;
391
- try {
392
- const ar = JSON.parse(trimmed);
393
- if (ar && ar.type) {
394
- processArEvent(ar, discoveredExtensions, projectId, sessionId, deps);
395
- }
396
- }
397
- catch {
398
- /* skip */
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
- sendEvent(deps, sessionId, {
710
+ finalize({
439
711
  type: "auto_research_error",
440
712
  projectId,
441
- message: `Auto-research subprocess error: ${err.message}`,
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
- }