@aexol/spectral 0.6.1 → 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.
package/dist/commands/serve.js
CHANGED
|
@@ -38,7 +38,7 @@ import { fileURLToPath } from "node:url";
|
|
|
38
38
|
import { getConfigDir } from "../config.js";
|
|
39
39
|
import { requireLogin } from "../preflight.js";
|
|
40
40
|
import { RelayClient } from "../relay/client.js";
|
|
41
|
-
import { detachAllSubscribers, handleCancelTurn, handleClientMessage, handleRestRequest, handleSubscribe, } from "../relay/dispatcher.js";
|
|
41
|
+
import { detachAllSubscribers, handleAutoResearchFrame, handleCancelTurn, handleClientMessage, handleRestRequest, handleSubscribe, } from "../relay/dispatcher.js";
|
|
42
42
|
import { ensureMachineRegistered } from "../relay/registration.js";
|
|
43
43
|
import { SessionStreamManager } from "../server/session-stream.js";
|
|
44
44
|
import { gracefulShutdown } from "../server/shutdown.js";
|
|
@@ -260,6 +260,15 @@ export async function runServe(opts = {}) {
|
|
|
260
260
|
handleCancelTurn(frame, { manager });
|
|
261
261
|
return;
|
|
262
262
|
}
|
|
263
|
+
if (frame.kind === "auto_research") {
|
|
264
|
+
handleAutoResearchFrame(frame, {
|
|
265
|
+
store,
|
|
266
|
+
manager,
|
|
267
|
+
relay,
|
|
268
|
+
cwd,
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
263
272
|
// Other frames (error, machine_disconnected addressed to us, etc.)
|
|
264
273
|
// are ignored at this layer. Future batches may surface them in
|
|
265
274
|
// structured logs.
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-research handler — spawns an isolated pi subprocess to analyze a
|
|
3
|
+
* project and generate custom extensions.
|
|
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
|
|
16
|
+
*
|
|
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.
|
|
19
|
+
*/
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as os from "node:os";
|
|
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
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* Detect the pi/spectral binary for spawning subprocesses.
|
|
56
|
+
* Mirrors the logic in `agent/index.ts#getPiInvocation`.
|
|
57
|
+
*/
|
|
58
|
+
function getPiInvocation(subagentArgs) {
|
|
59
|
+
const currentScript = process.argv[1];
|
|
60
|
+
// Case 1: running via tsx with an existing script file
|
|
61
|
+
if (currentScript && fs.existsSync(currentScript)) {
|
|
62
|
+
return { command: process.execPath, args: [currentScript, ...subagentArgs] };
|
|
63
|
+
}
|
|
64
|
+
// Case 2: check if this is the spectral/aexol wrapper binary
|
|
65
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
66
|
+
if (execName === "spectral" || execName === "aexol") {
|
|
67
|
+
return { command: process.execPath, args: subagentArgs };
|
|
68
|
+
}
|
|
69
|
+
// Case 3: generic node — try spectral first, then pi
|
|
70
|
+
function hasBin(name) {
|
|
71
|
+
const PATH = process.env.PATH || "";
|
|
72
|
+
const envPathSep = process.platform === "win32" ? ";" : ":";
|
|
73
|
+
for (const dir of PATH.split(envPathSep)) {
|
|
74
|
+
const candidate = path.join(dir, name);
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(candidate))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* skip */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (hasBin("spectral")) {
|
|
86
|
+
return { command: "spectral", args: subagentArgs };
|
|
87
|
+
}
|
|
88
|
+
if (hasBin("pi")) {
|
|
89
|
+
return { command: "pi", args: subagentArgs };
|
|
90
|
+
}
|
|
91
|
+
return { command: "pi", args: subagentArgs };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Send a ServerEvent to the browser via the relay on the auto-research session.
|
|
95
|
+
*/
|
|
96
|
+
function sendEvent(deps, sessionId, event) {
|
|
97
|
+
deps.relay.send({
|
|
98
|
+
kind: "ws_event",
|
|
99
|
+
sessionId,
|
|
100
|
+
event,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Safely kill a child process. Best-effort — errors are silently swallowed.
|
|
105
|
+
*/
|
|
106
|
+
function killProcess(child) {
|
|
107
|
+
try {
|
|
108
|
+
if (!child.killed && child.exitCode === null) {
|
|
109
|
+
child.kill("SIGTERM");
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
try {
|
|
112
|
+
if (!child.killed && child.exitCode === null) {
|
|
113
|
+
child.kill("SIGKILL");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
}, 2000);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
}
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Handler
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
const AR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous for LLM-based analysis
|
|
145
|
+
/**
|
|
146
|
+
* Execute auto-research for a project.
|
|
147
|
+
*
|
|
148
|
+
* Caller (dispatcher) is fire-and-forget — this function is `void` and
|
|
149
|
+
* all errors are surfaced as `auto_research_error` events on the wire
|
|
150
|
+
* rather than thrown.
|
|
151
|
+
*/
|
|
152
|
+
export function handleAutoResearch(input, deps) {
|
|
153
|
+
const { projectId, sessionId } = input;
|
|
154
|
+
const { store } = deps;
|
|
155
|
+
const logger = deps.logger ?? console;
|
|
156
|
+
// 1. Resolve the project from the store.
|
|
157
|
+
const project = store.getProject(projectId);
|
|
158
|
+
if (!project) {
|
|
159
|
+
sendEvent(deps, sessionId, {
|
|
160
|
+
type: "auto_research_error",
|
|
161
|
+
projectId,
|
|
162
|
+
message: `Project not found: ${projectId}`,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const projectPath = project.path;
|
|
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
|
|
179
|
+
sendEvent(deps, sessionId, {
|
|
180
|
+
type: "auto_research_start",
|
|
181
|
+
projectId,
|
|
182
|
+
});
|
|
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.
|
|
230
|
+
const invocation = getPiInvocation(args);
|
|
231
|
+
let child;
|
|
232
|
+
try {
|
|
233
|
+
child = spawn(invocation.command, invocation.args, {
|
|
234
|
+
cwd: projectPath,
|
|
235
|
+
stdio: ["ignore", "pipe", "pipe"], // stdin ignored — task is positional
|
|
236
|
+
env: { ...process.env },
|
|
237
|
+
shell: false,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
242
|
+
sendEvent(deps, sessionId, {
|
|
243
|
+
type: "auto_research_error",
|
|
244
|
+
projectId,
|
|
245
|
+
message: `Failed to spawn pi subprocess: ${msg}`,
|
|
246
|
+
});
|
|
247
|
+
cleanupTemp(tmpDir);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// 8. Set up timeout
|
|
251
|
+
const timeout = setTimeout(() => {
|
|
252
|
+
killProcess(child);
|
|
253
|
+
sendEvent(deps, sessionId, {
|
|
254
|
+
type: "auto_research_error",
|
|
255
|
+
projectId,
|
|
256
|
+
message: "Auto-research timed out after 5 minutes",
|
|
257
|
+
});
|
|
258
|
+
cleanupTemp(tmpDir);
|
|
259
|
+
}, AR_TIMEOUT_MS);
|
|
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).
|
|
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) => {
|
|
345
|
+
clearTimeout(timeout);
|
|
346
|
+
// Process any remaining buffered stdout (drain the buffer)
|
|
347
|
+
if (stdoutBuffer.trim()) {
|
|
348
|
+
try {
|
|
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
|
+
}
|
|
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) => {
|
|
405
|
+
clearTimeout(timeout);
|
|
406
|
+
sendEvent(deps, sessionId, {
|
|
407
|
+
type: "auto_research_error",
|
|
408
|
+
projectId,
|
|
409
|
+
message: `Auto-research subprocess error: ${err.message}`,
|
|
410
|
+
});
|
|
411
|
+
cleanupTemp(tmpDir);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
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
|
@@ -41,8 +41,9 @@
|
|
|
41
41
|
import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
|
|
42
42
|
import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
|
|
43
43
|
import { handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
|
|
44
|
-
import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
|
|
44
|
+
import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleGetSessionMemoryDetails, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
|
|
45
45
|
import { shutdownState } from "../server/shutdown.js";
|
|
46
|
+
import { handleAutoResearch } from "./auto-research.js";
|
|
46
47
|
/**
|
|
47
48
|
* Inline path matcher. Returns `null` for any path/method combination we
|
|
48
49
|
* don't recognise; the caller turns that into a `404 Unknown route`.
|
|
@@ -103,13 +104,15 @@ export function matchRoute(method, path) {
|
|
|
103
104
|
return { route: "delete_session", id };
|
|
104
105
|
return null;
|
|
105
106
|
}
|
|
106
|
-
// /api/sessions/:id/memory and /api/sessions/:id/compact
|
|
107
|
-
const sessionActionMatch = /^\/api\/sessions\/([^/]+)\/(memory
|
|
107
|
+
// /api/sessions/:id/memory/details and /api/sessions/:id/memory and /api/sessions/:id/compact
|
|
108
|
+
const sessionActionMatch = /^\/api\/sessions\/([^/]+)\/(memory(?:\/details)?|compact)$/.exec(cleanPath);
|
|
108
109
|
if (sessionActionMatch) {
|
|
109
110
|
const id = decodeURIComponent(sessionActionMatch[1]);
|
|
110
111
|
const action = sessionActionMatch[2];
|
|
111
112
|
if (action === "memory" && method === "GET")
|
|
112
113
|
return { route: "get_session_memory", id };
|
|
114
|
+
if (action === "memory/details" && method === "GET")
|
|
115
|
+
return { route: "get_session_memory_details", id };
|
|
113
116
|
if (action === "compact" && method === "POST")
|
|
114
117
|
return { route: "compact_session", id };
|
|
115
118
|
return null;
|
|
@@ -269,6 +272,8 @@ async function dispatchRoute(match, body, deps) {
|
|
|
269
272
|
return handleGetSessionDetail(store, id);
|
|
270
273
|
case "get_session_memory":
|
|
271
274
|
return handleGetSessionMemoryStatus(store, manager, id);
|
|
275
|
+
case "get_session_memory_details":
|
|
276
|
+
return handleGetSessionMemoryDetails(store, manager, id);
|
|
272
277
|
case "update_session": {
|
|
273
278
|
const session = handleUpdateSession(store, id, asObject(body));
|
|
274
279
|
safePublish(publishMetaEvent, logger, {
|
|
@@ -627,3 +632,22 @@ export function detachAllSubscribers(manager, subscribers) {
|
|
|
627
632
|
}
|
|
628
633
|
subscribers.clear();
|
|
629
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* Dispatch an `auto_research` frame. Spawns an isolated pi subprocess that:
|
|
637
|
+
* 1. Collects project context (files, existing extensions, git state)
|
|
638
|
+
* 2. Analyzes the project using an LLM (via subagent)
|
|
639
|
+
* 3. Generates extension `.ts` files under `.pi/extensions/auto-research/`
|
|
640
|
+
* 4. Hot-reloads pi so new extensions take effect immediately
|
|
641
|
+
*
|
|
642
|
+
* Progress is streamed back as `ws_event` frames carrying
|
|
643
|
+
* `auto_research_*` ServerEvent types on the `sessionId` channel.
|
|
644
|
+
*
|
|
645
|
+
* Errors are surfaced as `auto_research_error` events; the underlying
|
|
646
|
+
* subprocess is killed on error to prevent zombie processes.
|
|
647
|
+
*/
|
|
648
|
+
export function handleAutoResearchFrame(frame, deps) {
|
|
649
|
+
handleAutoResearch({
|
|
650
|
+
projectId: frame.projectId,
|
|
651
|
+
sessionId: frame.sessionId,
|
|
652
|
+
}, deps);
|
|
653
|
+
}
|
|
@@ -46,6 +46,12 @@ export function handleGetSessionMemoryStatus(store, manager, id) {
|
|
|
46
46
|
throw new NotFoundError("Session not found");
|
|
47
47
|
return manager.getSessionMemoryStatus(id);
|
|
48
48
|
}
|
|
49
|
+
export function handleGetSessionMemoryDetails(store, manager, id) {
|
|
50
|
+
const detail = store.getSession(id);
|
|
51
|
+
if (!detail)
|
|
52
|
+
throw new NotFoundError("Session not found");
|
|
53
|
+
return manager.getSessionMemoryDetails(id);
|
|
54
|
+
}
|
|
49
55
|
export async function handleCompactSession(store, manager, id) {
|
|
50
56
|
const detail = store.getSession(id);
|
|
51
57
|
if (!detail)
|
|
@@ -42,7 +42,7 @@ import { getMemoryState, isSourceEntry, rawTokensSinceLastBound, rawTokensSinceL
|
|
|
42
42
|
import { observationPoolTokens, renderSummary } from "../memory/compaction.js";
|
|
43
43
|
import { loadConfig } from "../memory/config.js";
|
|
44
44
|
import { estimateStringTokens } from "../memory/tokens.js";
|
|
45
|
-
import { reflectionContent } from "../memory/types.js";
|
|
45
|
+
import { reflectionContent, reflectionId } from "../memory/types.js";
|
|
46
46
|
import { generateSessionTitle, isDefaultTitle, } from "./title-generator.js";
|
|
47
47
|
const DEFAULT_BRIDGE_FACTORY = (args) => new PiBridge(args);
|
|
48
48
|
/** Safety limit for autonomous loop iterations per session. */
|
|
@@ -373,6 +373,61 @@ export class SessionStreamManager {
|
|
|
373
373
|
}
|
|
374
374
|
return status;
|
|
375
375
|
}
|
|
376
|
+
getSessionMemoryDetails(sessionId) {
|
|
377
|
+
const detail = this.store.getSession(sessionId);
|
|
378
|
+
if (!detail)
|
|
379
|
+
throw new Error(`Unknown sessionId: ${sessionId}`);
|
|
380
|
+
const stream = this.streams.get(sessionId);
|
|
381
|
+
const entries = stream?.bridge.getSessionBranch?.();
|
|
382
|
+
let observations = [];
|
|
383
|
+
let pendingObs = [];
|
|
384
|
+
let reflections = [];
|
|
385
|
+
let summary = null;
|
|
386
|
+
if (entries) {
|
|
387
|
+
const memoryState = getMemoryState(entries);
|
|
388
|
+
observations = memoryState.committedObs;
|
|
389
|
+
pendingObs = memoryState.pendingObs;
|
|
390
|
+
reflections = memoryState.reflections;
|
|
391
|
+
summary = renderSummary(reflections, [...observations, ...pendingObs]);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
const snapshot = this.store.getSessionMemorySnapshot(sessionId);
|
|
395
|
+
if (snapshot) {
|
|
396
|
+
observations = snapshot.details.observations;
|
|
397
|
+
reflections = snapshot.details.reflections;
|
|
398
|
+
summary = snapshot.summary;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const pendingIds = new Set(pendingObs.map((o) => o.id));
|
|
402
|
+
const wireObservations = [
|
|
403
|
+
...observations.map((o) => ({
|
|
404
|
+
id: o.id,
|
|
405
|
+
content: o.content,
|
|
406
|
+
timestamp: o.timestamp,
|
|
407
|
+
relevance: o.relevance,
|
|
408
|
+
pending: false,
|
|
409
|
+
})),
|
|
410
|
+
...pendingObs.map((o) => ({
|
|
411
|
+
id: o.id,
|
|
412
|
+
content: o.content,
|
|
413
|
+
timestamp: o.timestamp,
|
|
414
|
+
relevance: o.relevance,
|
|
415
|
+
pending: true,
|
|
416
|
+
})),
|
|
417
|
+
];
|
|
418
|
+
const wireReflections = reflections.map((r) => ({
|
|
419
|
+
id: reflectionId(r) ?? "legacy",
|
|
420
|
+
content: reflectionContent(r),
|
|
421
|
+
supportingObservationIds: typeof r === "object" && "supportingObservationIds" in r
|
|
422
|
+
? r.supportingObservationIds
|
|
423
|
+
: [],
|
|
424
|
+
}));
|
|
425
|
+
return {
|
|
426
|
+
summary,
|
|
427
|
+
observations: wireObservations,
|
|
428
|
+
reflections: wireReflections,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
376
431
|
async compactSession(sessionId) {
|
|
377
432
|
if (this.disposed)
|
|
378
433
|
throw new Error("SessionStreamManager disposed");
|