@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.
- package/dist/relay/dispatcher.js +7 -6
- package/dist/server/pi-bridge.js +65 -18
- package/dist/server/session-stream.js +64 -27
- package/package.json +2 -2
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,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 `
|
|
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 =
|
|
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
|
});
|
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,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
|
|
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
|
-
//
|
|
344
|
-
// Routes to
|
|
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
|
|
348
|
-
this.modelRegistry.registerProvider(
|
|
349
|
-
baseUrl:
|
|
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: "
|
|
356
|
-
name: "
|
|
402
|
+
id: "__spectral_loop__",
|
|
403
|
+
name: "Spectral Loop",
|
|
357
404
|
api: "openai-completions",
|
|
358
|
-
baseUrl:
|
|
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
|
|
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);
|
|
@@ -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.
|
|
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.
|
|
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
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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
|
-
|
|
331
|
+
setLoopActive(sessionId, active, originalPrompt) {
|
|
326
332
|
const stream = this.streams.get(sessionId);
|
|
327
333
|
if (stream) {
|
|
328
|
-
stream.
|
|
334
|
+
stream.loopActive = active;
|
|
335
|
+
stream.loopOriginalPrompt = active ? (originalPrompt ?? null) : null;
|
|
329
336
|
if (!active)
|
|
330
|
-
stream.
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
stream.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
+
"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": "
|
|
57
|
+
"pi-mcp-adapter": "file:../packages/pi-mcp-adapter",
|
|
58
58
|
"picocolors": "^1.1.1",
|
|
59
59
|
"ws": "^8.20.0"
|
|
60
60
|
},
|