@aexol/spectral 0.1.3 → 0.1.7

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, handleClientMessage, handleRestRequest, handleSubscribe, } from "../relay/dispatcher.js";
41
+ import { detachAllSubscribers, 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";
@@ -252,6 +252,10 @@ export async function runServe(opts = {}) {
252
252
  });
253
253
  return;
254
254
  }
255
+ if (frame.kind === "cancel_turn") {
256
+ handleCancelTurn(frame, { manager });
257
+ return;
258
+ }
255
259
  // Other frames (error, machine_disconnected addressed to us, etc.)
256
260
  // are ignored at this layer. Future batches may surface them in
257
261
  // structured logs.
@@ -407,11 +407,12 @@ export function handleClientMessage(frame, deps) {
407
407
  return;
408
408
  }
409
409
  const content = message.content;
410
- const isAexol = message.aexol === true;
410
+ const isLoop = message.loop === true;
411
411
  const validImages = coerceImages(message.images);
412
- // Set autonomous refactor loop state before firing the prompt.
413
- // aexol:true → start/renew loop; aexol:false stop loop.
414
- manager.setAexolActive(sessionId, isAexol);
412
+ // Set autonomous iterative loop state before firing the prompt.
413
+ // loop:true → start/renew loop with the current content as original prompt;
414
+ // loop:false → stop any active loop.
415
+ manager.setLoopActive(sessionId, isLoop, content);
415
416
  // 2. Attach (idempotent). On first attach we capture the replay payload
416
417
  // and synthesize a `session_ready` ws_event so the browser sees the
417
418
  // same first frame it would have on a direct WS connection.
@@ -465,10 +466,9 @@ export function handleClientMessage(frame, deps) {
465
466
  // against the team-scoped whitelist; the CLI resolves it via pi's
466
467
  // own model registry inside the manager → bridge.
467
468
  //
468
- // When `aexol: true` is set on the message, route to the refactor-loop
469
- // user model instead of the default session model.
470
- const effectiveModelId = isAexol ? "__aexol_refactor_loop__" : modelId;
471
- manager.prompt(sessionId, content, effectiveModelId, validImages).catch((err) => {
469
+ // When `loop: true`, loop state is set before prompting; the normal
470
+ // model is used loop replay is handled by session-stream on agent_end.
471
+ manager.prompt(sessionId, content, modelId, validImages).catch((err) => {
472
472
  logger.error?.(`[dispatcher] manager.prompt failed for ${sessionId}:`, err);
473
473
  });
474
474
  }
@@ -532,6 +532,16 @@ export function handleSubscribe(frame, deps) {
532
532
  });
533
533
  }
534
534
  }
535
+ /**
536
+ * Dispatch a `cancel_turn` frame. Disposes the session's pi bridge and
537
+ * removes the stream so the next user message creates a fresh one. The
538
+ * bridge dispose triggers `agent_end` broadcast to all subscribers.
539
+ *
540
+ * Idempotent: no-ops when no stream exists for the session.
541
+ */
542
+ export function handleCancelTurn(frame, deps) {
543
+ deps.manager.cancelTurn(frame.sessionId);
544
+ }
535
545
  /**
536
546
  * Detach every subscriber the dispatcher has attached. Called by
537
547
  * `serve.ts` on relay disconnect / shutdown so the underlying pi
@@ -38,14 +38,15 @@
38
38
  * configured) are caught by the caller in `routes.ts` and re-emitted as
39
39
  * `{type:"error"}`.
40
40
  *
41
- * History rehydration limitation:
42
- * Pi's SDK exposes `messages` and `sendUserMessage` but reconstructing a
43
- * fresh AgentSession from a transcript of `WireMessage`s is non-trivial —
44
- * the SDK's internal state (tool registry, system prompt, model context)
45
- * expects to own message creation. For the MVP we accept that pi sees a
46
- * fresh context on reconnect; the user still sees the full transcript in
47
- * the UI because we send `session_ready.history` from SQLite. Multi-turn
48
- * conversations within a single WS connection work normally.
41
+ * History rehydration:
42
+ * On first attach to a previously-created session (e.g. after a server
43
+ * restart), the SessionStreamManager passes the full SQLite transcript
44
+ * to the PiBridge via `PiBridgeOptions.history`. Before
45
+ * `createAgentSession` is called, each message is appended to the
46
+ * in-memory SessionManager so the LLM sees the full conversation
47
+ * context from the very first prompt. Multi-turn conversations within
48
+ * a single pi session also work normally (the same AgentSession
49
+ * instance is reused across `prompt()` calls).
49
50
  */
50
51
  import { AuthStorage, createAgentSession, DefaultResourceLoader, ModelRegistry, SessionManager, } from "@mariozechner/pi-coding-agent";
51
52
  import { createJiti } from "@mariozechner/jiti";
@@ -69,7 +70,6 @@ import { fetchAllowedModels as defaultFetchAllowedModels, } from "../relay/model
69
70
  const SPECTRAL_PROXY_ANTHROPIC = "spectral-proxy-anthropic";
70
71
  const SPECTRAL_PROXY_OPENAI = "spectral-proxy-openai";
71
72
  const SPECTRAL_PROXY_USER_MODEL = "spectral-proxy-user-model";
72
- const SPECTRAL_PROXY_AEXOL_REFACTOR = "spectral-proxy-aexol-refactor";
73
73
  /**
74
74
  * Concatenate text from an `AssistantMessage.content` array. Returns the
75
75
  * empty string when no text blocks are present (tool-only turns) or when
@@ -192,6 +192,53 @@ export class PiBridge {
192
192
  await resourceLoader.reload();
193
193
  // In-memory session: SQLite is our source of truth.
194
194
  const sessionManager = SessionManager.inMemory(this.opts.cwd);
195
+ // Rehydrate session history so the LLM sees the full conversation
196
+ // transcript from the beginning (not just the current prompt).
197
+ // Previously this was documented as a "History rehydration
198
+ // limitation" — the UI saw the transcript but pi did not.
199
+ if (this.opts.history && this.opts.history.length > 0) {
200
+ for (const msg of this.opts.history) {
201
+ if (msg.role === "user") {
202
+ const content = msg.images && msg.images.length > 0
203
+ ? [
204
+ ...msg.images.map((img) => ({
205
+ type: "image",
206
+ data: img.data,
207
+ mimeType: img.mimeType,
208
+ })),
209
+ { type: "text", text: msg.content },
210
+ ]
211
+ : msg.content;
212
+ sessionManager.appendMessage({
213
+ role: "user",
214
+ content,
215
+ timestamp: msg.createdAt,
216
+ });
217
+ }
218
+ else if (msg.role === "assistant") {
219
+ const textBlocks = msg.content ? [{ type: "text", text: msg.content }] : [];
220
+ sessionManager.appendMessage({
221
+ role: "assistant",
222
+ content: textBlocks,
223
+ api: "anthropic-messages",
224
+ provider: "spectral-proxy-anthropic",
225
+ model: "unknown",
226
+ usage: {
227
+ input: 0,
228
+ output: 0,
229
+ cacheRead: 0,
230
+ cacheWrite: 0,
231
+ totalTokens: 0,
232
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
233
+ },
234
+ stopReason: "stop",
235
+ timestamp: msg.createdAt,
236
+ });
237
+ }
238
+ // system messages are informational only; skip for LLM context
239
+ }
240
+ console.info(`[PiBridge] Rehydrated ${this.opts.history.length} history message(s) into session context`);
241
+ }
195
242
  // Build a model registry that does NOT touch ~/.pi/agent/auth.json or
196
243
  // ~/.pi/agent/models.json — the backend is now the only source of
197
244
  // provider credentials and the only allowed inference target. We then
@@ -340,31 +387,6 @@ export class PiBridge {
340
387
  })),
341
388
  });
342
389
  }
343
- // Refactor-loop model — dedicated endpoint for Aexol agent chat toggle.
344
- // Routes to the team user model at /models/team/aexol/refactor-loop/v1
345
- // using machine JWT auth, same as other synthetic providers.
346
- {
347
- const refactorBaseUrl = `${this.opts.backendUrl.replace(/\/$/, "")}/models/built-in/refactor-loop/v1`;
348
- this.modelRegistry.registerProvider(SPECTRAL_PROXY_AEXOL_REFACTOR, {
349
- baseUrl: refactorBaseUrl,
350
- apiKey: this.opts.machineJwt,
351
- authHeader: true,
352
- api: "openai-completions",
353
- models: [
354
- {
355
- id: "__aexol_refactor_loop__",
356
- name: "Aexol Refactor Loop",
357
- api: "openai-completions",
358
- baseUrl: refactorBaseUrl,
359
- reasoning: false,
360
- input: ["text", "image"],
361
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
362
- contextWindow: 0,
363
- maxTokens: 0,
364
- },
365
- ],
366
- });
367
- }
368
390
  }
369
391
  /**
370
392
  * Apply a sticky model selection to the underlying pi session, if it
@@ -40,8 +40,10 @@ import { randomUUID } from "node:crypto";
40
40
  import { PiBridge } from "./pi-bridge.js";
41
41
  import { generateSessionTitle, isDefaultTitle, } from "./title-generator.js";
42
42
  const DEFAULT_BRIDGE_FACTORY = (args) => new PiBridge(args);
43
- /** Safety limit for autonomous refactor loop iterations per session. */
44
- const MAX_AEXOL_ITERATIONS = 100;
43
+ /** Safety limit for autonomous loop iterations per session. */
44
+ const MAX_LOOP_ITERATIONS = 100;
45
+ /** Marker the agent emits in its response to signal the task is complete. */
46
+ const LOOP_DONE_MARKER = "<LOOP_DONE>";
45
47
  export class SessionStreamManager {
46
48
  store;
47
49
  cwd;
@@ -89,7 +91,7 @@ export class SessionStreamManager {
89
91
  throw new Error(`Unknown sessionId: ${sessionId}`);
90
92
  let stream = this.streams.get(sessionId);
91
93
  if (!stream) {
92
- stream = this.createStream(sessionId);
94
+ stream = this.createStream(sessionId, detail.messages);
93
95
  this.streams.set(sessionId, stream);
94
96
  }
95
97
  stream.subscribers.add(subscriber);
@@ -145,9 +147,52 @@ export class SessionStreamManager {
145
147
  async prompt(sessionId, content, modelId, images) {
146
148
  if (this.disposed)
147
149
  throw new Error("SessionStreamManager disposed");
148
- const stream = this.streams.get(sessionId);
150
+ let stream = this.streams.get(sessionId);
149
151
  if (!stream)
150
152
  throw new Error(`No active stream for session: ${sessionId}`);
153
+ // If the bridge was disposed (e.g. by cancelTurn), recreate it before
154
+ // proceeding. We rebuild only the bridge inside the existing stream so
155
+ // subscribers, cwd, and other metadata are preserved.
156
+ if (stream.startError) {
157
+ const history = this.store.getSession(sessionId)?.messages;
158
+ const bridgeOpts = {
159
+ cwd: stream.cwd,
160
+ agentDir: this.agentDir,
161
+ backendUrl: this.backendUrl,
162
+ machineJwt: this.machineJwt,
163
+ history,
164
+ emit: (event) => this.handleBridgeEvent(stream, event),
165
+ onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
166
+ try {
167
+ this.store.appendMessage(sessionId, {
168
+ id: messageId,
169
+ role: "assistant",
170
+ content,
171
+ eventsJsonl,
172
+ });
173
+ }
174
+ catch (err) {
175
+ console.error(`[spectral] error: failed to persist assistant message: ${err instanceof Error ? err.message : String(err)}`);
176
+ }
177
+ },
178
+ onError: (err) => {
179
+ console.error(`[spectral] error: pi bridge error: ${err.message}`);
180
+ },
181
+ };
182
+ stream.bridge = this.bridgeFactory(bridgeOpts);
183
+ stream.startError = null;
184
+ stream.ready = stream.bridge
185
+ .start()
186
+ .catch((err) => {
187
+ const e = err instanceof Error ? err : new Error(String(err));
188
+ stream.startError = e;
189
+ this.broadcast(stream, {
190
+ type: "error",
191
+ message: `Failed to start agent: ${e.message}`,
192
+ });
193
+ throw e;
194
+ });
195
+ }
151
196
  // Wait for pi to be ready before we persist + invoke. If start failed,
152
197
  // surface to all subscribers instead of throwing into the route handler.
153
198
  try {
@@ -237,7 +282,7 @@ export class SessionStreamManager {
237
282
  return;
238
283
  this.disposed = true;
239
284
  for (const stream of this.streams.values()) {
240
- stream.aexolActive = false;
285
+ stream.loopActive = false;
241
286
  try {
242
287
  stream.bridge.dispose();
243
288
  }
@@ -270,6 +315,49 @@ export class SessionStreamManager {
270
315
  }
271
316
  return n;
272
317
  }
318
+ /**
319
+ * Cancel the in-flight turn for a session (user pressed Stop in the UI).
320
+ * Disposes the pi bridge and broadcasts `agent_end` so all subscribers
321
+ * see the turn close. The stream itself is kept alive — the next user
322
+ * message (via `prompt()`) will lazily create a fresh bridge.
323
+ *
324
+ * Idempotent: if no stream exists for the session, or no turn is in
325
+ * flight, this is a no-op.
326
+ */
327
+ cancelTurn(sessionId) {
328
+ const stream = this.streams.get(sessionId);
329
+ if (!stream)
330
+ return;
331
+ // Stop any active loop before disposing — if the loop was mid-iteration
332
+ // the next agent_end would otherwise trigger another prompt.
333
+ stream.loopActive = false;
334
+ stream.loopOriginalPrompt = null;
335
+ stream.loopIterationCount = 0;
336
+ // Dispose the pi bridge immediately — this tears down pi's session and
337
+ // unsubscribe. The bridge's own event handler is detached; no further
338
+ // events will flow. We broadcast agent_end ourselves below.
339
+ try {
340
+ stream.bridge.dispose();
341
+ }
342
+ catch {
343
+ // ignore
344
+ }
345
+ // Set startError so any late `await stream.ready` in the loop path
346
+ // (if the loop was between iterations when we disposed) rejects cleanly
347
+ // instead of hanging. Also prevents the next prompt() from waiting on
348
+ // a dead bridge's ready promise.
349
+ stream.startError = new Error("Turn cancelled");
350
+ // Broadcast agent_end so all subscribers close their open turn and
351
+ // re-enable their composers.
352
+ if (stream.currentTurn) {
353
+ this.broadcast(stream, { type: "agent_end" });
354
+ stream.currentTurn = null;
355
+ }
356
+ // Don't delete the stream — the subscribers are still attached and the
357
+ // next `prompt()` call will create a fresh bridge via `createStream()`.
358
+ // We just need to signal that the bridge is gone so `prompt()` knows to
359
+ // recreate one.
360
+ }
273
361
  /**
274
362
  * Tear down a single session's stream — disposes the pi bridge and clears
275
363
  * subscribers. Idempotent. Called by the routes layer right before
@@ -282,7 +370,7 @@ export class SessionStreamManager {
282
370
  const stream = this.streams.get(sessionId);
283
371
  if (!stream)
284
372
  return;
285
- stream.aexolActive = false;
373
+ stream.loopActive = false;
286
374
  try {
287
375
  stream.bridge.dispose();
288
376
  }
@@ -318,20 +406,25 @@ export class SessionStreamManager {
318
406
  }
319
407
  }
320
408
  /**
321
- * Set the autonomous refactor loop state for a session. When `active` is
322
- * true, the manager will auto-send "continue" after each `agent_end` event
323
- * until deactivated or the safety limit is reached.
409
+ * Set the autonomous iterative loop state for a session.
410
+ *
411
+ * When `active` is true, the manager replays `originalPrompt` after each
412
+ * `agent_end` event — the agent sees its own file changes from prior
413
+ * iterations and iteratively improves its solution (Ralph Wiggum pattern).
414
+ * The loop stops when the agent emits `<LOOP_DONE>` in its response or the
415
+ * safety iteration limit is reached.
324
416
  */
325
- setAexolActive(sessionId, active) {
417
+ setLoopActive(sessionId, active, originalPrompt) {
326
418
  const stream = this.streams.get(sessionId);
327
419
  if (stream) {
328
- stream.aexolActive = active;
420
+ stream.loopActive = active;
421
+ stream.loopOriginalPrompt = active ? (originalPrompt ?? null) : null;
329
422
  if (!active)
330
- stream.aexolIterationCount = 0;
423
+ stream.loopIterationCount = 0;
331
424
  }
332
425
  }
333
426
  // --- internals ----------------------------------------------------------
334
- createStream(sessionId) {
427
+ createStream(sessionId, history) {
335
428
  // Resolve cwd from the owning project. Sessions without a project
336
429
  // shouldn't exist (FK enforces it), but we fall back to the manager's
337
430
  // default cwd if the lookup somehow fails — better than crashing the
@@ -353,14 +446,16 @@ export class SessionStreamManager {
353
446
  startError: null,
354
447
  subscribers: new Set(),
355
448
  currentTurn: null,
356
- aexolActive: false,
357
- aexolIterationCount: 0,
449
+ loopActive: false,
450
+ loopIterationCount: 0,
451
+ loopOriginalPrompt: null,
358
452
  };
359
453
  const bridgeOpts = {
360
454
  cwd,
361
455
  agentDir: this.agentDir,
362
456
  backendUrl: this.backendUrl,
363
457
  machineJwt: this.machineJwt,
458
+ history,
364
459
  emit: (event) => this.handleBridgeEvent(stream, event),
365
460
  onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
366
461
  try {
@@ -428,19 +523,47 @@ export class SessionStreamManager {
428
523
  // default title, and never blocks the user's stream (the user's
429
524
  // turn is already complete by the time this runs).
430
525
  this.maybeGenerateTitle(stream, finishedTurn);
431
- // Autonomous refactor loop: when aexolActive is set, auto-send
432
- // "continue" after each agent_end to keep the loop going.
433
- if (stream.aexolActive && stream.aexolIterationCount < MAX_AEXOL_ITERATIONS) {
434
- stream.aexolIterationCount++;
435
- console.log(`[aexol-loop] iteration ${stream.aexolIterationCount}/${MAX_AEXOL_ITERATIONS}`);
436
- void this.prompt(stream.sessionId, "continue", undefined).catch((err) => {
437
- console.error(`[aexol-loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
438
- stream.aexolActive = false;
439
- });
440
- }
441
- else if (stream.aexolActive) {
442
- console.log("[aexol-loop] max iterations reached, stopping");
443
- stream.aexolActive = false;
526
+ // Autonomous iterative loop (Ralph Wiggum pattern).
527
+ // When loopActive is set, check for completion marker, then re-send
528
+ // the ORIGINAL prompt so the agent sees its prior changes and
529
+ // iteratively improves the solution.
530
+ if (stream.loopActive && stream.loopOriginalPrompt) {
531
+ const finishedAssistantText = finishedTurn?.assistantText ?? "";
532
+ if (finishedAssistantText.includes(LOOP_DONE_MARKER)) {
533
+ console.log(`[loop] completion marker detected after ${stream.loopIterationCount} iteration(s), stopping`);
534
+ const completedIterations = stream.loopIterationCount;
535
+ stream.loopActive = false;
536
+ stream.loopIterationCount = 0;
537
+ stream.loopOriginalPrompt = null;
538
+ this.broadcast(stream, {
539
+ type: "loop_complete",
540
+ iterations: completedIterations,
541
+ });
542
+ }
543
+ else if (stream.loopIterationCount >= MAX_LOOP_ITERATIONS) {
544
+ console.log(`[loop] max iterations (${MAX_LOOP_ITERATIONS}) reached, stopping`);
545
+ stream.loopActive = false;
546
+ stream.loopOriginalPrompt = null;
547
+ this.broadcast(stream, {
548
+ type: "loop_max_iterations",
549
+ iterations: stream.loopIterationCount,
550
+ });
551
+ }
552
+ else {
553
+ stream.loopIterationCount++;
554
+ console.log(`[loop] iteration ${stream.loopIterationCount}/${MAX_LOOP_ITERATIONS}`);
555
+ this.broadcast(stream, {
556
+ type: "loop_iteration",
557
+ iteration: stream.loopIterationCount,
558
+ maxIterations: MAX_LOOP_ITERATIONS,
559
+ prompt: stream.loopOriginalPrompt,
560
+ });
561
+ void this.prompt(stream.sessionId, stream.loopOriginalPrompt, undefined).catch((err) => {
562
+ console.error(`[loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
563
+ stream.loopActive = false;
564
+ stream.loopOriginalPrompt = null;
565
+ });
566
+ }
444
567
  }
445
568
  }
446
569
  else if (event.type === "error") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.1.3",
3
+ "version": "0.1.7",
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,
@@ -54,7 +54,7 @@
54
54
  "@mariozechner/jiti": "^2.6.5",
55
55
  "@mariozechner/pi-coding-agent": "^0.70.2",
56
56
  "better-sqlite3": "^12.9.0",
57
- "pi-mcp-adapter": "^2.5.4",
57
+ "pi-mcp-adapter": "file:../packages/pi-mcp-adapter",
58
58
  "picocolors": "^1.1.1",
59
59
  "ws": "^8.20.0"
60
60
  },