@aexol/spectral 0.6.1 → 0.6.2
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,357 @@
|
|
|
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. 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
|
|
12
|
+
*
|
|
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
|
+
*/
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/**
|
|
25
|
+
* Detect the pi/spectral binary for spawning subprocesses.
|
|
26
|
+
* Mirrors the logic in `agent/index.ts#getPiInvocation`.
|
|
27
|
+
*/
|
|
28
|
+
function getPiInvocation(subagentArgs) {
|
|
29
|
+
const currentScript = process.argv[1];
|
|
30
|
+
// Case 1: running via tsx with an existing script file
|
|
31
|
+
if (currentScript && fs.existsSync(currentScript)) {
|
|
32
|
+
return { command: process.execPath, args: [currentScript, ...subagentArgs] };
|
|
33
|
+
}
|
|
34
|
+
// Case 2: check if this is the spectral/aexol wrapper binary
|
|
35
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
36
|
+
if (execName === "spectral" || execName === "aexol") {
|
|
37
|
+
return { command: process.execPath, args: subagentArgs };
|
|
38
|
+
}
|
|
39
|
+
// Case 3: generic node — try spectral first, then pi
|
|
40
|
+
function hasBin(name) {
|
|
41
|
+
const PATH = process.env.PATH || "";
|
|
42
|
+
const envPathSep = process.platform === "win32" ? ";" : ":";
|
|
43
|
+
for (const dir of PATH.split(envPathSep)) {
|
|
44
|
+
const candidate = path.join(dir, name);
|
|
45
|
+
try {
|
|
46
|
+
if (fs.existsSync(candidate))
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* skip */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (hasBin("spectral")) {
|
|
56
|
+
return { command: "spectral", args: subagentArgs };
|
|
57
|
+
}
|
|
58
|
+
if (hasBin("pi")) {
|
|
59
|
+
return { command: "pi", args: subagentArgs };
|
|
60
|
+
}
|
|
61
|
+
return { command: "pi", args: subagentArgs };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Send a ServerEvent to the browser via the relay on the auto-research session.
|
|
65
|
+
*/
|
|
66
|
+
function sendEvent(deps, sessionId, event) {
|
|
67
|
+
deps.relay.send({
|
|
68
|
+
kind: "ws_event",
|
|
69
|
+
sessionId,
|
|
70
|
+
event,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Safely kill a child process. Best-effort — errors are silently swallowed.
|
|
75
|
+
*/
|
|
76
|
+
function killProcess(child) {
|
|
77
|
+
try {
|
|
78
|
+
if (!child.killed && child.exitCode === null) {
|
|
79
|
+
child.kill("SIGTERM");
|
|
80
|
+
// Give it 2 seconds to clean up, then force-kill
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
try {
|
|
83
|
+
if (!child.killed && child.exitCode === null) {
|
|
84
|
+
child.kill("SIGKILL");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// ignore
|
|
89
|
+
}
|
|
90
|
+
}, 2000);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// ignore
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Handler
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
const AR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous for LLM-based analysis
|
|
101
|
+
/**
|
|
102
|
+
* Execute auto-research for a project.
|
|
103
|
+
*
|
|
104
|
+
* Caller (dispatcher) is fire-and-forget — this function is `void` and
|
|
105
|
+
* all errors are surfaced as `auto_research_error` events on the wire
|
|
106
|
+
* rather than thrown.
|
|
107
|
+
*/
|
|
108
|
+
export function handleAutoResearch(input, deps) {
|
|
109
|
+
const { projectId, sessionId } = input;
|
|
110
|
+
const { store, relay } = deps;
|
|
111
|
+
const logger = deps.logger ?? console;
|
|
112
|
+
// 1. Resolve the project from the store.
|
|
113
|
+
const project = store.getProject(projectId);
|
|
114
|
+
if (!project) {
|
|
115
|
+
sendEvent(deps, sessionId, {
|
|
116
|
+
type: "auto_research_error",
|
|
117
|
+
projectId,
|
|
118
|
+
message: `Project not found: ${projectId}`,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const projectPath = project.path;
|
|
123
|
+
// 2. Emit start event
|
|
124
|
+
sendEvent(deps, sessionId, {
|
|
125
|
+
type: "auto_research_start",
|
|
126
|
+
projectId,
|
|
127
|
+
});
|
|
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"];
|
|
136
|
+
const invocation = getPiInvocation(args);
|
|
137
|
+
let child;
|
|
138
|
+
try {
|
|
139
|
+
child = spawn(invocation.command, invocation.args, {
|
|
140
|
+
cwd: projectPath,
|
|
141
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
142
|
+
env: { ...process.env },
|
|
143
|
+
shell: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
148
|
+
sendEvent(deps, sessionId, {
|
|
149
|
+
type: "auto_research_error",
|
|
150
|
+
projectId,
|
|
151
|
+
message: `Failed to spawn pi subprocess: ${msg}`,
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// 5. Set up timeout
|
|
156
|
+
const timeout = setTimeout(() => {
|
|
157
|
+
killProcess(child);
|
|
158
|
+
sendEvent(deps, sessionId, {
|
|
159
|
+
type: "auto_research_error",
|
|
160
|
+
projectId,
|
|
161
|
+
message: "Auto-research timed out after 5 minutes",
|
|
162
|
+
});
|
|
163
|
+
}, AR_TIMEOUT_MS);
|
|
164
|
+
// 6. Collect stdout lines and parse as JSON events
|
|
165
|
+
let stdoutBuffer = "";
|
|
166
|
+
const discoveredExtensions = [];
|
|
167
|
+
let stderrBuffer = "";
|
|
168
|
+
child.stdout?.on("data", (chunk) => {
|
|
169
|
+
stdoutBuffer += chunk.toString("utf-8");
|
|
170
|
+
// Process complete lines
|
|
171
|
+
const lines = stdoutBuffer.split("\n");
|
|
172
|
+
// The last element may be an incomplete line — keep it in the buffer
|
|
173
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
174
|
+
for (const rawLine of lines) {
|
|
175
|
+
const line = rawLine.trim();
|
|
176
|
+
if (!line)
|
|
177
|
+
continue;
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(line);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Non-JSON output — could be a log line. Skip.
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
processArLine(parsed, discoveredExtensions, projectId, sessionId, deps);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
child.stderr?.on("data", (chunk) => {
|
|
190
|
+
stderrBuffer += chunk.toString("utf-8");
|
|
191
|
+
});
|
|
192
|
+
// 7. Handle process exit
|
|
193
|
+
child.on("close", (code) => {
|
|
194
|
+
clearTimeout(timeout);
|
|
195
|
+
// Process any remaining buffered stdout
|
|
196
|
+
if (stdoutBuffer.trim()) {
|
|
197
|
+
try {
|
|
198
|
+
const parsed = JSON.parse(stdoutBuffer.trim());
|
|
199
|
+
processArLine(parsed, discoveredExtensions, projectId, sessionId, deps);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// ignore
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (code !== 0 && discoveredExtensions.length === 0) {
|
|
206
|
+
// Subprocess exited with error and no extensions were generated
|
|
207
|
+
const errDetail = stderrBuffer
|
|
208
|
+
? ` (stderr: ${stderrBuffer.slice(0, 500)})`
|
|
209
|
+
: "";
|
|
210
|
+
sendEvent(deps, sessionId, {
|
|
211
|
+
type: "auto_research_error",
|
|
212
|
+
projectId,
|
|
213
|
+
message: `Auto-research subprocess exited with code ${code}${errDetail}`,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Emit completion with any discovered extensions (even partial — the
|
|
218
|
+
// UI can show what was generated before the process died).
|
|
219
|
+
sendEvent(deps, sessionId, {
|
|
220
|
+
type: "auto_research_complete",
|
|
221
|
+
projectId,
|
|
222
|
+
extensions: discoveredExtensions,
|
|
223
|
+
});
|
|
224
|
+
// Log any stderr for debugging
|
|
225
|
+
if (stderrBuffer) {
|
|
226
|
+
logger.error?.(`[auto-research] subprocess stderr (code=${code}): ${stderrBuffer.slice(0, 1000)}`);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
child.on("error", (err) => {
|
|
230
|
+
clearTimeout(timeout);
|
|
231
|
+
sendEvent(deps, sessionId, {
|
|
232
|
+
type: "auto_research_error",
|
|
233
|
+
projectId,
|
|
234
|
+
message: `Auto-research subprocess error: ${err.message}`,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
// 8. Write the task to stdin and close
|
|
238
|
+
child.stdin?.write(task);
|
|
239
|
+
child.stdin?.end();
|
|
240
|
+
}
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Line processor
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
function processArLine(parsed, extensions, projectId, sessionId, deps) {
|
|
245
|
+
const t = parsed.type;
|
|
246
|
+
if (t === "progress") {
|
|
247
|
+
const p = parsed;
|
|
248
|
+
const wirePhase = mapPhase(p.phase);
|
|
249
|
+
sendEvent(deps, sessionId, {
|
|
250
|
+
type: "auto_research_progress",
|
|
251
|
+
projectId,
|
|
252
|
+
phase: wirePhase,
|
|
253
|
+
message: p.message,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
else if (t === "extension_generated") {
|
|
257
|
+
const eg = parsed;
|
|
258
|
+
extensions.push({
|
|
259
|
+
name: eg.name,
|
|
260
|
+
path: eg.path,
|
|
261
|
+
description: eg.description,
|
|
262
|
+
usesLLM: eg.usesLLM,
|
|
263
|
+
fileCount: eg.fileCount,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
else if (t === "done") {
|
|
267
|
+
const d = parsed;
|
|
268
|
+
for (const ext of d.extensions) {
|
|
269
|
+
extensions.push(ext);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (t === "error") {
|
|
273
|
+
const e = parsed;
|
|
274
|
+
sendEvent(deps, sessionId, {
|
|
275
|
+
type: "auto_research_progress",
|
|
276
|
+
projectId,
|
|
277
|
+
phase: "extension_validating",
|
|
278
|
+
message: `Error: ${e.message}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Unknown event types are silently ignored for forward compatibility
|
|
282
|
+
}
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
// Task builder
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
/**
|
|
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
|
|
309
|
+
*/
|
|
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
|
+
});
|
|
357
|
+
}
|
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");
|