@aexol/spectral 0.1.3 → 0.1.5

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.
@@ -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,9 +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
+ // When `loop: true` is set on the message, route to the spectral-loop
469
470
  // user model instead of the default session model.
470
- const effectiveModelId = isAexol ? "__aexol_refactor_loop__" : modelId;
471
+ const effectiveModelId = isLoop ? "__spectral_loop__" : modelId;
471
472
  manager.prompt(sessionId, content, effectiveModelId, validImages).catch((err) => {
472
473
  logger.error?.(`[dispatcher] manager.prompt failed for ${sessionId}:`, err);
473
474
  });
@@ -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,7 @@ 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
+ const SPECTRAL_PROXY_LOOP = "spectral-proxy-loop";
73
74
  /**
74
75
  * Concatenate text from an `AssistantMessage.content` array. Returns the
75
76
  * empty string when no text blocks are present (tool-only turns) or when
@@ -192,6 +193,53 @@ export class PiBridge {
192
193
  await resourceLoader.reload();
193
194
  // In-memory session: SQLite is our source of truth.
194
195
  const sessionManager = SessionManager.inMemory(this.opts.cwd);
196
+ // Rehydrate session history so the LLM sees the full conversation
197
+ // transcript from the beginning (not just the current prompt).
198
+ // Previously this was documented as a "History rehydration
199
+ // limitation" — the UI saw the transcript but pi did not.
200
+ if (this.opts.history && this.opts.history.length > 0) {
201
+ for (const msg of this.opts.history) {
202
+ if (msg.role === "user") {
203
+ const content = msg.images && msg.images.length > 0
204
+ ? [
205
+ ...msg.images.map((img) => ({
206
+ type: "image",
207
+ data: img.data,
208
+ mimeType: img.mimeType,
209
+ })),
210
+ { type: "text", text: msg.content },
211
+ ]
212
+ : msg.content;
213
+ sessionManager.appendMessage({
214
+ role: "user",
215
+ content,
216
+ timestamp: msg.createdAt,
217
+ });
218
+ }
219
+ else if (msg.role === "assistant") {
220
+ const textBlocks = msg.content ? [{ type: "text", text: msg.content }] : [];
221
+ sessionManager.appendMessage({
222
+ role: "assistant",
223
+ content: textBlocks,
224
+ api: "anthropic-messages",
225
+ provider: "spectral-proxy-anthropic",
226
+ model: "unknown",
227
+ usage: {
228
+ input: 0,
229
+ output: 0,
230
+ cacheRead: 0,
231
+ cacheWrite: 0,
232
+ totalTokens: 0,
233
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
234
+ },
235
+ stopReason: "stop",
236
+ timestamp: msg.createdAt,
237
+ });
238
+ }
239
+ // system messages are informational only; skip for LLM context
240
+ }
241
+ console.info(`[PiBridge] Rehydrated ${this.opts.history.length} history message(s) into session context`);
242
+ }
195
243
  // Build a model registry that does NOT touch ~/.pi/agent/auth.json or
196
244
  // ~/.pi/agent/models.json — the backend is now the only source of
197
245
  // provider credentials and the only allowed inference target. We then
@@ -340,22 +388,21 @@ export class PiBridge {
340
388
  })),
341
389
  });
342
390
  }
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.
391
+ // Loop model — dedicated endpoint for Spectral agent loop toggle.
392
+ // Routes to /models/built-in/loop/v1 using machine JWT auth.
346
393
  {
347
- const refactorBaseUrl = `${this.opts.backendUrl.replace(/\/$/, "")}/models/built-in/refactor-loop/v1`;
348
- this.modelRegistry.registerProvider(SPECTRAL_PROXY_AEXOL_REFACTOR, {
349
- baseUrl: refactorBaseUrl,
394
+ const loopBaseUrl = `${this.opts.backendUrl.replace(/\/$/, "")}/models/built-in/loop/v1`;
395
+ this.modelRegistry.registerProvider(SPECTRAL_PROXY_LOOP, {
396
+ baseUrl: loopBaseUrl,
350
397
  apiKey: this.opts.machineJwt,
351
398
  authHeader: true,
352
399
  api: "openai-completions",
353
400
  models: [
354
401
  {
355
- id: "__aexol_refactor_loop__",
356
- name: "Aexol Refactor Loop",
402
+ id: "__spectral_loop__",
403
+ name: "Spectral Loop",
357
404
  api: "openai-completions",
358
- baseUrl: refactorBaseUrl,
405
+ baseUrl: loopBaseUrl,
359
406
  reasoning: false,
360
407
  input: ["text", "image"],
361
408
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -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);
@@ -237,7 +239,7 @@ export class SessionStreamManager {
237
239
  return;
238
240
  this.disposed = true;
239
241
  for (const stream of this.streams.values()) {
240
- stream.aexolActive = false;
242
+ stream.loopActive = false;
241
243
  try {
242
244
  stream.bridge.dispose();
243
245
  }
@@ -282,7 +284,7 @@ export class SessionStreamManager {
282
284
  const stream = this.streams.get(sessionId);
283
285
  if (!stream)
284
286
  return;
285
- stream.aexolActive = false;
287
+ stream.loopActive = false;
286
288
  try {
287
289
  stream.bridge.dispose();
288
290
  }
@@ -318,20 +320,25 @@ export class SessionStreamManager {
318
320
  }
319
321
  }
320
322
  /**
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.
323
+ * Set the autonomous iterative loop state for a session.
324
+ *
325
+ * When `active` is true, the manager replays `originalPrompt` after each
326
+ * `agent_end` event — the agent sees its own file changes from prior
327
+ * iterations and iteratively improves its solution (Ralph Wiggum pattern).
328
+ * The loop stops when the agent emits `<LOOP_DONE>` in its response or the
329
+ * safety iteration limit is reached.
324
330
  */
325
- setAexolActive(sessionId, active) {
331
+ setLoopActive(sessionId, active, originalPrompt) {
326
332
  const stream = this.streams.get(sessionId);
327
333
  if (stream) {
328
- stream.aexolActive = active;
334
+ stream.loopActive = active;
335
+ stream.loopOriginalPrompt = active ? (originalPrompt ?? null) : null;
329
336
  if (!active)
330
- stream.aexolIterationCount = 0;
337
+ stream.loopIterationCount = 0;
331
338
  }
332
339
  }
333
340
  // --- internals ----------------------------------------------------------
334
- createStream(sessionId) {
341
+ createStream(sessionId, history) {
335
342
  // Resolve cwd from the owning project. Sessions without a project
336
343
  // shouldn't exist (FK enforces it), but we fall back to the manager's
337
344
  // default cwd if the lookup somehow fails — better than crashing the
@@ -353,14 +360,16 @@ export class SessionStreamManager {
353
360
  startError: null,
354
361
  subscribers: new Set(),
355
362
  currentTurn: null,
356
- aexolActive: false,
357
- aexolIterationCount: 0,
363
+ loopActive: false,
364
+ loopIterationCount: 0,
365
+ loopOriginalPrompt: null,
358
366
  };
359
367
  const bridgeOpts = {
360
368
  cwd,
361
369
  agentDir: this.agentDir,
362
370
  backendUrl: this.backendUrl,
363
371
  machineJwt: this.machineJwt,
372
+ history,
364
373
  emit: (event) => this.handleBridgeEvent(stream, event),
365
374
  onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
366
375
  try {
@@ -428,19 +437,47 @@ export class SessionStreamManager {
428
437
  // default title, and never blocks the user's stream (the user's
429
438
  // turn is already complete by the time this runs).
430
439
  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;
440
+ // Autonomous iterative loop (Ralph Wiggum pattern).
441
+ // When loopActive is set, check for completion marker, then re-send
442
+ // the ORIGINAL prompt so the agent sees its prior changes and
443
+ // iteratively improves the solution.
444
+ if (stream.loopActive && stream.loopOriginalPrompt) {
445
+ const finishedAssistantText = finishedTurn?.assistantText ?? "";
446
+ if (finishedAssistantText.includes(LOOP_DONE_MARKER)) {
447
+ console.log(`[loop] completion marker detected after ${stream.loopIterationCount} iteration(s), stopping`);
448
+ const completedIterations = stream.loopIterationCount;
449
+ stream.loopActive = false;
450
+ stream.loopIterationCount = 0;
451
+ stream.loopOriginalPrompt = null;
452
+ this.broadcast(stream, {
453
+ type: "loop_complete",
454
+ iterations: completedIterations,
455
+ });
456
+ }
457
+ else if (stream.loopIterationCount >= MAX_LOOP_ITERATIONS) {
458
+ console.log(`[loop] max iterations (${MAX_LOOP_ITERATIONS}) reached, stopping`);
459
+ stream.loopActive = false;
460
+ stream.loopOriginalPrompt = null;
461
+ this.broadcast(stream, {
462
+ type: "loop_max_iterations",
463
+ iterations: stream.loopIterationCount,
464
+ });
465
+ }
466
+ else {
467
+ stream.loopIterationCount++;
468
+ console.log(`[loop] iteration ${stream.loopIterationCount}/${MAX_LOOP_ITERATIONS}`);
469
+ this.broadcast(stream, {
470
+ type: "loop_iteration",
471
+ iteration: stream.loopIterationCount,
472
+ maxIterations: MAX_LOOP_ITERATIONS,
473
+ prompt: stream.loopOriginalPrompt,
474
+ });
475
+ void this.prompt(stream.sessionId, stream.loopOriginalPrompt, undefined).catch((err) => {
476
+ console.error(`[loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
477
+ stream.loopActive = false;
478
+ stream.loopOriginalPrompt = null;
479
+ });
480
+ }
444
481
  }
445
482
  }
446
483
  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.5",
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
  },