@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.
@@ -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
+ }
@@ -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|compact)$/.exec(cleanPath);
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.6.1",
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,