@aexol/spectral 0.6.2 → 0.6.4

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,82 @@
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
+ // 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();
53
+ }
54
+ /** Default agent definition used as a fallback when no agent .md file exists. */
55
+ function getDefaultAgentDef() {
56
+ 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"),
81
+ };
82
+ }
21
83
  // ---------------------------------------------------------------------------
22
84
  // Helpers
23
85
  // ---------------------------------------------------------------------------
@@ -77,7 +139,6 @@ function killProcess(child) {
77
139
  try {
78
140
  if (!child.killed && child.exitCode === null) {
79
141
  child.kill("SIGTERM");
80
- // Give it 2 seconds to clean up, then force-kill
81
142
  setTimeout(() => {
82
143
  try {
83
144
  if (!child.killed && child.exitCode === null) {
@@ -95,6 +156,21 @@ function killProcess(child) {
95
156
  }
96
157
  }
97
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;
170
+ }
171
+ return "context_analyzing";
172
+ }
173
+ // ---------------------------------------------------------------------------
98
174
  // Handler
99
175
  // ---------------------------------------------------------------------------
100
176
  const AR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous for LLM-based analysis
@@ -107,7 +183,7 @@ const AR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous for LLM-based ana
107
183
  */
108
184
  export function handleAutoResearch(input, deps) {
109
185
  const { projectId, sessionId } = input;
110
- const { store, relay } = deps;
186
+ const { store } = deps;
111
187
  const logger = deps.logger ?? console;
112
188
  // 1. Resolve the project from the store.
113
189
  const project = store.getProject(projectId);
@@ -120,25 +196,75 @@ export function handleAutoResearch(input, deps) {
120
196
  return;
121
197
  }
122
198
  const projectPath = project.path;
123
- // 2. Emit start event
199
+ // 2. Load the auto-research agent definition.
200
+ const agentDef = findAgentDef(projectPath);
201
+ if (!agentDef) {
202
+ sendEvent(deps, sessionId, {
203
+ type: "auto_research_error",
204
+ 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.",
207
+ });
208
+ return;
209
+ }
210
+ // 3. Emit start event
124
211
  sendEvent(deps, sessionId, {
125
212
  type: "auto_research_start",
126
213
  projectId,
127
214
  });
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"];
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 */ }
236
+ }
237
+ return;
238
+ }
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.
136
262
  const invocation = getPiInvocation(args);
137
263
  let child;
138
264
  try {
139
265
  child = spawn(invocation.command, invocation.args, {
140
266
  cwd: projectPath,
141
- stdio: ["pipe", "pipe", "pipe"],
267
+ stdio: ["ignore", "pipe", "pipe"], // stdin ignored — task is positional
142
268
  env: { ...process.env },
143
269
  shell: false,
144
270
  });
@@ -150,9 +276,10 @@ export function handleAutoResearch(input, deps) {
150
276
  projectId,
151
277
  message: `Failed to spawn pi subprocess: ${msg}`,
152
278
  });
279
+ cleanupTemp(tmpDir);
153
280
  return;
154
281
  }
155
- // 5. Set up timeout
282
+ // 8. Set up timeout
156
283
  const timeout = setTimeout(() => {
157
284
  killProcess(child);
158
285
  sendEvent(deps, sessionId, {
@@ -160,8 +287,18 @@ export function handleAutoResearch(input, deps) {
160
287
  projectId,
161
288
  message: "Auto-research timed out after 5 minutes",
162
289
  });
290
+ cleanupTemp(tmpDir);
163
291
  }, AR_TIMEOUT_MS);
164
- // 6. Collect stdout lines and parse as JSON events
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).
165
302
  let stdoutBuffer = "";
166
303
  const discoveredExtensions = [];
167
304
  let stderrBuffer = "";
@@ -169,34 +306,102 @@ export function handleAutoResearch(input, deps) {
169
306
  stdoutBuffer += chunk.toString("utf-8");
170
307
  // Process complete lines
171
308
  const lines = stdoutBuffer.split("\n");
172
- // The last element may be an incomplete line — keep it in the buffer
173
309
  stdoutBuffer = lines.pop() ?? "";
174
310
  for (const rawLine of lines) {
175
311
  const line = rawLine.trim();
176
312
  if (!line)
177
313
  continue;
178
- let parsed;
314
+ let event;
179
315
  try {
180
- parsed = JSON.parse(line);
316
+ event = JSON.parse(line);
181
317
  }
182
318
  catch {
183
- // Non-JSON output — could be a log line. Skip.
319
+ // Non-JSON output — ignore.
184
320
  continue;
185
321
  }
186
- processArLine(parsed, discoveredExtensions, projectId, sessionId, deps);
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
+ }
187
370
  }
188
371
  });
189
372
  child.stderr?.on("data", (chunk) => {
190
373
  stderrBuffer += chunk.toString("utf-8");
191
374
  });
192
- // 7. Handle process exit
375
+ // 10. Handle process exit
193
376
  child.on("close", (code) => {
194
377
  clearTimeout(timeout);
195
- // Process any remaining buffered stdout
378
+ // Process any remaining buffered stdout (drain the buffer)
196
379
  if (stdoutBuffer.trim()) {
197
380
  try {
198
- const parsed = JSON.parse(stdoutBuffer.trim());
199
- processArLine(parsed, discoveredExtensions, projectId, sessionId, deps);
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
+ }
200
405
  }
201
406
  catch {
202
407
  // ignore
@@ -212,6 +417,7 @@ export function handleAutoResearch(input, deps) {
212
417
  projectId,
213
418
  message: `Auto-research subprocess exited with code ${code}${errDetail}`,
214
419
  });
420
+ cleanupTemp(tmpDir);
215
421
  return;
216
422
  }
217
423
  // Emit completion with any discovered extensions (even partial — the
@@ -225,6 +431,7 @@ export function handleAutoResearch(input, deps) {
225
431
  if (stderrBuffer) {
226
432
  logger.error?.(`[auto-research] subprocess stderr (code=${code}): ${stderrBuffer.slice(0, 1000)}`);
227
433
  }
434
+ cleanupTemp(tmpDir);
228
435
  });
229
436
  child.on("error", (err) => {
230
437
  clearTimeout(timeout);
@@ -233,15 +440,13 @@ export function handleAutoResearch(input, deps) {
233
440
  projectId,
234
441
  message: `Auto-research subprocess error: ${err.message}`,
235
442
  });
443
+ cleanupTemp(tmpDir);
236
444
  });
237
- // 8. Write the task to stdin and close
238
- child.stdin?.write(task);
239
- child.stdin?.end();
240
445
  }
241
446
  // ---------------------------------------------------------------------------
242
- // Line processor
447
+ // Event processor
243
448
  // ---------------------------------------------------------------------------
244
- function processArLine(parsed, extensions, projectId, sessionId, deps) {
449
+ function processArEvent(parsed, extensions, projectId, sessionId, deps) {
245
450
  const t = parsed.type;
246
451
  if (t === "progress") {
247
452
  const p = parsed;
@@ -262,6 +467,13 @@ function processArLine(parsed, extensions, projectId, sessionId, deps) {
262
467
  usesLLM: eg.usesLLM,
263
468
  fileCount: eg.fileCount,
264
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
+ });
265
477
  }
266
478
  else if (t === "done") {
267
479
  const d = parsed;
@@ -281,77 +493,38 @@ function processArLine(parsed, extensions, projectId, sessionId, deps) {
281
493
  // Unknown event types are silently ignored for forward compatibility
282
494
  }
283
495
  // ---------------------------------------------------------------------------
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
496
  // Task builder
300
497
  // ---------------------------------------------------------------------------
301
498
  /**
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
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.
309
502
  */
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
- });
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
+ }
357
530
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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,