@aexol/spectral 0.6.2 → 0.6.3

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.
@@ -4,20 +4,50 @@
4
4
  *
5
5
  * Flow:
6
6
  * 1. Resolve project path from the SQLite store
7
- * 2. Spawn pi with --mode json -p running the auto-research agent
8
- * 3. Parse JSON-line output from the subprocess
9
- * 4. Map subprocess events to `auto_research_*` ServerEvent types
10
- * 5. Stream progress via the relay to the browser
11
- * 6. On completion, emit `auto_research_complete` with generated extensions
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
12
16
  *
13
- * The subprocess runs with `cwd` set to the project root so all file
14
- * reads/writes are relative to the project. The agent definition markdown
15
- * file lives alongside other agent defs and gets loaded by pi's subagent
16
- * infrastructure.
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.
17
19
  */
18
20
  import { spawn } from "node:child_process";
19
21
  import * as fs from "node:fs";
22
+ import * as os from "node:os";
20
23
  import * as path from "node:path";
24
+ import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
25
+ /**
26
+ * Locate the auto-research agent definition markdown file.
27
+ * Searches project-level first, then user-level.
28
+ */
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
+ return null;
50
+ }
21
51
  // ---------------------------------------------------------------------------
22
52
  // Helpers
23
53
  // ---------------------------------------------------------------------------
@@ -77,7 +107,6 @@ function killProcess(child) {
77
107
  try {
78
108
  if (!child.killed && child.exitCode === null) {
79
109
  child.kill("SIGTERM");
80
- // Give it 2 seconds to clean up, then force-kill
81
110
  setTimeout(() => {
82
111
  try {
83
112
  if (!child.killed && child.exitCode === null) {
@@ -95,6 +124,21 @@ function killProcess(child) {
95
124
  }
96
125
  }
97
126
  // ---------------------------------------------------------------------------
127
+ // Phase mapping
128
+ // ---------------------------------------------------------------------------
129
+ const VALID_PHASES = new Set([
130
+ "context_collecting",
131
+ "context_analyzing",
132
+ "extension_generating",
133
+ "extension_validating",
134
+ ]);
135
+ function mapPhase(agentPhase) {
136
+ if (VALID_PHASES.has(agentPhase)) {
137
+ return agentPhase;
138
+ }
139
+ return "context_analyzing";
140
+ }
141
+ // ---------------------------------------------------------------------------
98
142
  // Handler
99
143
  // ---------------------------------------------------------------------------
100
144
  const AR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous for LLM-based analysis
@@ -107,7 +151,7 @@ const AR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous for LLM-based ana
107
151
  */
108
152
  export function handleAutoResearch(input, deps) {
109
153
  const { projectId, sessionId } = input;
110
- const { store, relay } = deps;
154
+ const { store } = deps;
111
155
  const logger = deps.logger ?? console;
112
156
  // 1. Resolve the project from the store.
113
157
  const project = store.getProject(projectId);
@@ -120,25 +164,75 @@ export function handleAutoResearch(input, deps) {
120
164
  return;
121
165
  }
122
166
  const projectPath = project.path;
123
- // 2. Emit start event
167
+ // 2. Load the auto-research agent definition.
168
+ const agentDef = findAgentDef(projectPath);
169
+ if (!agentDef) {
170
+ sendEvent(deps, sessionId, {
171
+ type: "auto_research_error",
172
+ projectId,
173
+ message: "Auto-research agent definition not found. " +
174
+ "Create .pi/agents/auto-research.md in the project or ~/.pi/agent/agents/auto-research.md.",
175
+ });
176
+ return;
177
+ }
178
+ // 3. Emit start event
124
179
  sendEvent(deps, sessionId, {
125
180
  type: "auto_research_start",
126
181
  projectId,
127
182
  });
128
- // 3. Build the prompt for the auto-research agent.
129
- // The prompt instructs the agent to analyze the project and produce
130
- // JSON-line output describing suggested extensions.
131
- const task = buildAutoResearchTask(projectPath, project.name);
132
- // 4. Spawn pi subprocess.
133
- // We use --mode json for structured output, -p for non-interactive,
134
- // and pipe stdin/stdout.
135
- const args = ["--mode", "json", "-p", "--no-session"];
183
+ // 4. Write the system prompt to a temp file so pi can load it via
184
+ // --append-system-prompt (mirrors the subagent extension pattern).
185
+ let tmpDir = null;
186
+ let tmpPromptPath = null;
187
+ try {
188
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "spectral-auto-research-"));
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 */ }
204
+ }
205
+ return;
206
+ }
207
+ // 5. Build the user task. This becomes a positional argument to pi,
208
+ // matching how the subagent extension passes tasks.
209
+ const task = buildUserTask(projectPath, project.name);
210
+ // 6. Build spawn arguments (mirroring agent/index.ts subprocess spawn).
211
+ const args = [
212
+ "--mode", "json",
213
+ "-p",
214
+ "--no-session",
215
+ "--model", agentDef.model,
216
+ "--append-system-prompt", tmpPromptPath,
217
+ ];
218
+ // Add tools if defined in the agent definition. The auto-research agent
219
+ // uses read/grep/find/ls/bash/write/edit — its frontmatter should declare
220
+ // them so pi allows those tools.
221
+ // We don't extract tools from frontmatter currently because parseFrontmatter
222
+ // returns a generic Record. For now, auto-research always uses the default
223
+ // tool set (pi's full tool set is available by default in -p mode).
224
+ // TODO: when agent def frontmatter parsing is unified with agents.ts, also
225
+ // pass --tools here.
226
+ // The user task is the last positional argument — pi treats it as the
227
+ // initial prompt in -p mode.
228
+ args.push(`Task: ${task}`);
229
+ // 7. Spawn pi subprocess.
136
230
  const invocation = getPiInvocation(args);
137
231
  let child;
138
232
  try {
139
233
  child = spawn(invocation.command, invocation.args, {
140
234
  cwd: projectPath,
141
- stdio: ["pipe", "pipe", "pipe"],
235
+ stdio: ["ignore", "pipe", "pipe"], // stdin ignored — task is positional
142
236
  env: { ...process.env },
143
237
  shell: false,
144
238
  });
@@ -150,9 +244,10 @@ export function handleAutoResearch(input, deps) {
150
244
  projectId,
151
245
  message: `Failed to spawn pi subprocess: ${msg}`,
152
246
  });
247
+ cleanupTemp(tmpDir);
153
248
  return;
154
249
  }
155
- // 5. Set up timeout
250
+ // 8. Set up timeout
156
251
  const timeout = setTimeout(() => {
157
252
  killProcess(child);
158
253
  sendEvent(deps, sessionId, {
@@ -160,8 +255,18 @@ export function handleAutoResearch(input, deps) {
160
255
  projectId,
161
256
  message: "Auto-research timed out after 5 minutes",
162
257
  });
258
+ cleanupTemp(tmpDir);
163
259
  }, AR_TIMEOUT_MS);
164
- // 6. Collect stdout lines and parse as JSON events
260
+ // 9. Collect stdout and parse pi's JSON-line output format.
261
+ // pi in --mode json emits one JSON line per event:
262
+ // {"type":"message_start",...}
263
+ // {"type":"text_delta","content":"..."}
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).
165
270
  let stdoutBuffer = "";
166
271
  const discoveredExtensions = [];
167
272
  let stderrBuffer = "";
@@ -169,34 +274,102 @@ export function handleAutoResearch(input, deps) {
169
274
  stdoutBuffer += chunk.toString("utf-8");
170
275
  // Process complete lines
171
276
  const lines = stdoutBuffer.split("\n");
172
- // The last element may be an incomplete line — keep it in the buffer
173
277
  stdoutBuffer = lines.pop() ?? "";
174
278
  for (const rawLine of lines) {
175
279
  const line = rawLine.trim();
176
280
  if (!line)
177
281
  continue;
178
- let parsed;
282
+ let event;
179
283
  try {
180
- parsed = JSON.parse(line);
284
+ event = JSON.parse(line);
181
285
  }
182
286
  catch {
183
- // Non-JSON output — could be a log line. Skip.
287
+ // Non-JSON output — ignore.
184
288
  continue;
185
289
  }
186
- processArLine(parsed, discoveredExtensions, projectId, sessionId, deps);
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
+ }
187
338
  }
188
339
  });
189
340
  child.stderr?.on("data", (chunk) => {
190
341
  stderrBuffer += chunk.toString("utf-8");
191
342
  });
192
- // 7. Handle process exit
343
+ // 10. Handle process exit
193
344
  child.on("close", (code) => {
194
345
  clearTimeout(timeout);
195
- // Process any remaining buffered stdout
346
+ // Process any remaining buffered stdout (drain the buffer)
196
347
  if (stdoutBuffer.trim()) {
197
348
  try {
198
- const parsed = JSON.parse(stdoutBuffer.trim());
199
- processArLine(parsed, discoveredExtensions, projectId, sessionId, deps);
349
+ const event = JSON.parse(stdoutBuffer.trim());
350
+ if (event.type === "message_end" && event.message?.role === "assistant") {
351
+ const content = event.message?.content;
352
+ if (Array.isArray(content)) {
353
+ for (const block of content) {
354
+ if (block.type === "text" && typeof block.text === "string") {
355
+ for (const subLine of block.text.split("\n")) {
356
+ const trimmed = subLine.trim();
357
+ if (!trimmed)
358
+ continue;
359
+ try {
360
+ const ar = JSON.parse(trimmed);
361
+ if (ar && ar.type) {
362
+ processArEvent(ar, discoveredExtensions, projectId, sessionId, deps);
363
+ }
364
+ }
365
+ catch {
366
+ /* skip */
367
+ }
368
+ }
369
+ }
370
+ }
371
+ }
372
+ }
200
373
  }
201
374
  catch {
202
375
  // ignore
@@ -212,6 +385,7 @@ export function handleAutoResearch(input, deps) {
212
385
  projectId,
213
386
  message: `Auto-research subprocess exited with code ${code}${errDetail}`,
214
387
  });
388
+ cleanupTemp(tmpDir);
215
389
  return;
216
390
  }
217
391
  // Emit completion with any discovered extensions (even partial — the
@@ -225,6 +399,7 @@ export function handleAutoResearch(input, deps) {
225
399
  if (stderrBuffer) {
226
400
  logger.error?.(`[auto-research] subprocess stderr (code=${code}): ${stderrBuffer.slice(0, 1000)}`);
227
401
  }
402
+ cleanupTemp(tmpDir);
228
403
  });
229
404
  child.on("error", (err) => {
230
405
  clearTimeout(timeout);
@@ -233,15 +408,13 @@ export function handleAutoResearch(input, deps) {
233
408
  projectId,
234
409
  message: `Auto-research subprocess error: ${err.message}`,
235
410
  });
411
+ cleanupTemp(tmpDir);
236
412
  });
237
- // 8. Write the task to stdin and close
238
- child.stdin?.write(task);
239
- child.stdin?.end();
240
413
  }
241
414
  // ---------------------------------------------------------------------------
242
- // Line processor
415
+ // Event processor
243
416
  // ---------------------------------------------------------------------------
244
- function processArLine(parsed, extensions, projectId, sessionId, deps) {
417
+ function processArEvent(parsed, extensions, projectId, sessionId, deps) {
245
418
  const t = parsed.type;
246
419
  if (t === "progress") {
247
420
  const p = parsed;
@@ -262,6 +435,13 @@ function processArLine(parsed, extensions, projectId, sessionId, deps) {
262
435
  usesLLM: eg.usesLLM,
263
436
  fileCount: eg.fileCount,
264
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
+ });
265
445
  }
266
446
  else if (t === "done") {
267
447
  const d = parsed;
@@ -281,77 +461,38 @@ function processArLine(parsed, extensions, projectId, sessionId, deps) {
281
461
  // Unknown event types are silently ignored for forward compatibility
282
462
  }
283
463
  // ---------------------------------------------------------------------------
284
- // Phase mapping
285
- // ---------------------------------------------------------------------------
286
- const VALID_PHASES = new Set([
287
- "context_collecting",
288
- "context_analyzing",
289
- "extension_generating",
290
- "extension_validating",
291
- ]);
292
- function mapPhase(agentPhase) {
293
- if (VALID_PHASES.has(agentPhase)) {
294
- return agentPhase;
295
- }
296
- return "context_analyzing";
297
- }
298
- // ---------------------------------------------------------------------------
299
464
  // Task builder
300
465
  // ---------------------------------------------------------------------------
301
466
  /**
302
- * Build the auto-research task prompt that will be sent to pi's stdin.
303
- *
304
- * The prompt instructs the auto-research subagent to:
305
- * 1. Collect context about the project
306
- * 2. Analyze what extensions would be useful
307
- * 3. Generate extension definitions
308
- * 4. Report results as structured JSON lines
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.
309
470
  */
310
- function buildAutoResearchTask(projectPath, projectName) {
311
- return JSON.stringify({
312
- type: "auto_research",
313
- projectPath,
314
- projectName,
315
- instructions: `You are an auto-research agent. Analyze the project at "${projectPath}" named "${projectName}" to determine what custom pi coding agent extensions would accelerate development.
316
-
317
- Follow these steps:
318
-
319
- 1. **Context Collection** (emit progress: phase="context_collecting")
320
- - Read the project's package.json, tsconfig.json, and any config files
321
- - List the source directory structure (src/, packages/, etc.)
322
- - Read existing .pi/extensions/ if any exist
323
- - Note the tech stack (frameworks, databases, tools)
324
- - Check git state (branch, recent commits) for workflow patterns
325
-
326
- 2. **Analysis** (emit progress: phase="context_analyzing")
327
- - Identify repetitive patterns in the codebase
328
- - Find areas where automation would save developer time
329
- - Consider common workflows: testing, linting, deployment, code review
330
- - Evaluate which AI-powered extensions (LLM calls) would add value
331
- - Prioritize extensions by impact vs. implementation complexity
332
-
333
- 3. **Extension Generation** (emit progress: phase="extension_generating")
334
- Generate .ts extension files under .pi/extensions/auto-research/ for each high-value extension. For each extension, emit:
335
- {"type":"extension_generated","name":"...","path":"...","description":"...","usesLLM":bool,"fileCount":n}
336
-
337
- Extension categories to consider:
338
- A. Workflow automation (test runner, linter integration, git hooks)
339
- B. Code generation (component scaffolding, API route generators)
340
- C. Project-specific tools (database migrations, schema generators)
341
- D. Quality & review (code reviewer, error explainer, type checker)
342
- E. Documentation (doc generator, changelog, API reference)
343
- F. LLM-powered (commit message generator, PR description, architecture advisor)
344
- G. Stateful (session memory extensions, usage dashboard, adaptive guidelines)
345
-
346
- 4. **Validation** (emit progress: phase="extension_validating")
347
- - Verify generated extensions follow pi extension API patterns
348
- - Ensure imports resolve against available npm packages
349
-
350
- 5. **Completion**
351
- When done, emit:
352
- {"type":"done","extensions":[...]}
353
- Then exit.
354
-
355
- IMPORTANT: Output ONLY JSON lines (one per line). Do not emit markdown, explanations, or any other text outside of JSON. Each line must be valid JSON on a single line. Your output will be parsed by a machine, not a human.`,
356
- });
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
+ }
357
498
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,