@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.
- package/dist/commands/serve.js +5 -1
- package/dist/relay/dispatcher.js +18 -8
- package/dist/server/pi-bridge.js +56 -34
- package/dist/server/session-stream.js +151 -28
- package/package.json +2 -2
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, 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.
|
package/dist/relay/dispatcher.js
CHANGED
|
@@ -407,11 +407,12 @@ export function handleClientMessage(frame, deps) {
|
|
|
407
407
|
return;
|
|
408
408
|
}
|
|
409
409
|
const content = message.content;
|
|
410
|
-
const
|
|
410
|
+
const isLoop = message.loop === true;
|
|
411
411
|
const validImages = coerceImages(message.images);
|
|
412
|
-
// Set autonomous
|
|
413
|
-
//
|
|
414
|
-
|
|
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 `
|
|
469
|
-
//
|
|
470
|
-
|
|
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
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
44
|
-
const
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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
|
-
|
|
417
|
+
setLoopActive(sessionId, active, originalPrompt) {
|
|
326
418
|
const stream = this.streams.get(sessionId);
|
|
327
419
|
if (stream) {
|
|
328
|
-
stream.
|
|
420
|
+
stream.loopActive = active;
|
|
421
|
+
stream.loopOriginalPrompt = active ? (originalPrompt ?? null) : null;
|
|
329
422
|
if (!active)
|
|
330
|
-
stream.
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
stream.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
+
"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": "
|
|
57
|
+
"pi-mcp-adapter": "file:../packages/pi-mcp-adapter",
|
|
58
58
|
"picocolors": "^1.1.1",
|
|
59
59
|
"ws": "^8.20.0"
|
|
60
60
|
},
|