@aexol/spectral 0.0.5 → 0.0.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/relay/dispatcher.js +51 -1
- package/dist/server/pi-bridge.js +15 -2
- package/dist/server/session-stream.js +7 -3
- package/dist/server/storage.js +54 -11
- package/package.json +1 -1
package/dist/relay/dispatcher.js
CHANGED
|
@@ -284,6 +284,45 @@ function asObject(body) {
|
|
|
284
284
|
}
|
|
285
285
|
return {};
|
|
286
286
|
}
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Helpers
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
/** Check whether a raw `images` array from the wire has at least one entry. */
|
|
291
|
+
function hasImages(raw) {
|
|
292
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
293
|
+
return false;
|
|
294
|
+
return raw.some((i) => i !== null &&
|
|
295
|
+
typeof i === "object" &&
|
|
296
|
+
typeof i.data === "string" &&
|
|
297
|
+
typeof i.mimeType === "string");
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Coerce a raw wire `images` payload into `ImageAttachment[]` or
|
|
301
|
+
* `undefined`. Filters out malformed entries (missing data/mimeType).
|
|
302
|
+
*/
|
|
303
|
+
function coerceImages(raw) {
|
|
304
|
+
if (!Array.isArray(raw))
|
|
305
|
+
return undefined;
|
|
306
|
+
const out = [];
|
|
307
|
+
for (const i of raw) {
|
|
308
|
+
if (i !== null &&
|
|
309
|
+
typeof i === "object" &&
|
|
310
|
+
typeof i.data === "string" &&
|
|
311
|
+
typeof i.mimeType === "string") {
|
|
312
|
+
out.push({
|
|
313
|
+
data: i.data,
|
|
314
|
+
mimeType: i.mimeType,
|
|
315
|
+
width: typeof i.width === "number"
|
|
316
|
+
? i.width
|
|
317
|
+
: undefined,
|
|
318
|
+
height: typeof i.height === "number"
|
|
319
|
+
? i.height
|
|
320
|
+
: undefined,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return out.length > 0 ? out : undefined;
|
|
325
|
+
}
|
|
287
326
|
/**
|
|
288
327
|
* Build a `Subscriber` that wraps each `ServerEvent` in a `WsEventFrame`
|
|
289
328
|
* and pushes it through the relay. `isOpen()` defers to the relay's
|
|
@@ -350,6 +389,8 @@ export function handleClientMessage(frame, deps) {
|
|
|
350
389
|
}
|
|
351
390
|
// 1. Validate inner message shape. Only `user_message` is supported
|
|
352
391
|
// today; reject anything else loudly-but-locally.
|
|
392
|
+
// `content` is a required string but may be empty when the user
|
|
393
|
+
// sends only images (e.g. pastes a screenshot without text).
|
|
353
394
|
if (message === null ||
|
|
354
395
|
typeof message !== "object" ||
|
|
355
396
|
message.type !== "user_message" ||
|
|
@@ -357,8 +398,17 @@ export function handleClientMessage(frame, deps) {
|
|
|
357
398
|
logger.error?.(`[dispatcher] ignoring malformed client_message for ${sessionId}`);
|
|
358
399
|
return;
|
|
359
400
|
}
|
|
401
|
+
// Reject messages that have neither content nor images — an empty
|
|
402
|
+
// message is always a client bug, and we don't want to persist a
|
|
403
|
+
// meaningless turn.
|
|
404
|
+
if (!message.content.trim() &&
|
|
405
|
+
!hasImages(message.images)) {
|
|
406
|
+
logger.error?.(`[dispatcher] ignoring empty client_message for ${sessionId}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
360
409
|
const content = message.content;
|
|
361
410
|
const isAexol = message.aexol === true;
|
|
411
|
+
const validImages = coerceImages(message.images);
|
|
362
412
|
// Set autonomous refactor loop state before firing the prompt.
|
|
363
413
|
// aexol:true → start/renew loop; aexol:false → stop loop.
|
|
364
414
|
manager.setAexolActive(sessionId, isAexol);
|
|
@@ -418,7 +468,7 @@ export function handleClientMessage(frame, deps) {
|
|
|
418
468
|
// When `aexol: true` is set on the message, route to the refactor-loop
|
|
419
469
|
// user model instead of the default session model.
|
|
420
470
|
const effectiveModelId = isAexol ? "__aexol_refactor_loop__" : modelId;
|
|
421
|
-
manager.prompt(sessionId, content, effectiveModelId).catch((err) => {
|
|
471
|
+
manager.prompt(sessionId, content, effectiveModelId, validImages).catch((err) => {
|
|
422
472
|
logger.error?.(`[dispatcher] manager.prompt failed for ${sessionId}:`, err);
|
|
423
473
|
});
|
|
424
474
|
}
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -357,12 +357,25 @@ export class PiBridge {
|
|
|
357
357
|
* The caller is responsible for persisting the user message to SQLite
|
|
358
358
|
* BEFORE invoking this — we don't do it here because pi's `prompt` may
|
|
359
359
|
* fail and we still want the user message recorded.
|
|
360
|
+
*
|
|
361
|
+
* When `images` is non-empty, each base64-encoded attachment is converted
|
|
362
|
+
* to a pi `ImageContent` block and passed as `options.images` to
|
|
363
|
+
* `session.prompt()`. Pi's model providers already register with
|
|
364
|
+
* `input: ["text", "image"]`, so the backend proxy correctly routes
|
|
365
|
+
* multimodal prompts.
|
|
360
366
|
*/
|
|
361
|
-
async prompt(text) {
|
|
367
|
+
async prompt(text, images) {
|
|
362
368
|
if (!this.session)
|
|
363
369
|
throw new Error("PiBridge.start() not called");
|
|
364
370
|
try {
|
|
365
|
-
|
|
371
|
+
const imageContents = images && images.length > 0
|
|
372
|
+
? images.map((img) => ({
|
|
373
|
+
type: "image",
|
|
374
|
+
data: img.data,
|
|
375
|
+
mimeType: img.mimeType,
|
|
376
|
+
}))
|
|
377
|
+
: undefined;
|
|
378
|
+
await this.session.prompt(text, { images: imageContents });
|
|
366
379
|
}
|
|
367
380
|
catch (err) {
|
|
368
381
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
@@ -142,7 +142,7 @@ export class SessionStreamManager {
|
|
|
142
142
|
* - When neither envelope nor SQLite have a value, we leave model
|
|
143
143
|
* selection to pi's own settings file (pre-Phase-3 behaviour).
|
|
144
144
|
*/
|
|
145
|
-
async prompt(sessionId, content, modelId) {
|
|
145
|
+
async prompt(sessionId, content, modelId, images) {
|
|
146
146
|
if (this.disposed)
|
|
147
147
|
throw new Error("SessionStreamManager disposed");
|
|
148
148
|
const stream = this.streams.get(sessionId);
|
|
@@ -198,7 +198,11 @@ export class SessionStreamManager {
|
|
|
198
198
|
// 1. Persist user message first (survives mid-prompt failures).
|
|
199
199
|
let stored;
|
|
200
200
|
try {
|
|
201
|
-
stored = this.store.appendMessage(sessionId, {
|
|
201
|
+
stored = this.store.appendMessage(sessionId, {
|
|
202
|
+
role: "user",
|
|
203
|
+
content,
|
|
204
|
+
images,
|
|
205
|
+
});
|
|
202
206
|
}
|
|
203
207
|
catch (err) {
|
|
204
208
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
@@ -222,7 +226,7 @@ export class SessionStreamManager {
|
|
|
222
226
|
// 4. Fire pi. `prompt` resolves on agent_end; errors are handled inside
|
|
223
227
|
// PiBridge (it emits `error` for us). We don't await — broadcast is
|
|
224
228
|
// driven by the bridge's emit callback.
|
|
225
|
-
void stream.bridge.prompt(content);
|
|
229
|
+
void stream.bridge.prompt(content, images);
|
|
226
230
|
}
|
|
227
231
|
/**
|
|
228
232
|
* Tear down everything. Best-effort: disposes every bridge, drops all
|
package/dist/server/storage.js
CHANGED
|
@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|
|
64
64
|
role TEXT NOT NULL CHECK (role IN ('user','assistant','system')),
|
|
65
65
|
content TEXT NOT NULL,
|
|
66
66
|
events_jsonl TEXT NOT NULL DEFAULT '',
|
|
67
|
+
images_json TEXT NOT NULL DEFAULT '',
|
|
67
68
|
created_at INTEGER NOT NULL
|
|
68
69
|
);
|
|
69
70
|
|
|
@@ -99,6 +100,34 @@ function applyBindingFields(project) {
|
|
|
99
100
|
}
|
|
100
101
|
/** Tables we own — used by the migration drop step. */
|
|
101
102
|
const KNOWN_TABLES = ["messages", "sessions", "projects"];
|
|
103
|
+
/** Best-effort parse of the `images_json` column into `ImageAttachment[]`. */
|
|
104
|
+
function parseImagesJson(raw) {
|
|
105
|
+
if (!raw || raw === "")
|
|
106
|
+
return undefined;
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(raw);
|
|
109
|
+
if (!Array.isArray(parsed))
|
|
110
|
+
return undefined;
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const i of parsed) {
|
|
113
|
+
if (i !== null &&
|
|
114
|
+
typeof i === "object" &&
|
|
115
|
+
typeof i.data === "string" &&
|
|
116
|
+
typeof i.mimeType === "string") {
|
|
117
|
+
out.push({
|
|
118
|
+
data: i.data,
|
|
119
|
+
mimeType: i.mimeType,
|
|
120
|
+
width: typeof i.width === "number" ? i.width : undefined,
|
|
121
|
+
height: typeof i.height === "number" ? i.height : undefined,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out.length > 0 ? out : undefined;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
102
131
|
export class SessionStore {
|
|
103
132
|
path;
|
|
104
133
|
db;
|
|
@@ -169,6 +198,14 @@ export class SessionStore {
|
|
|
169
198
|
if (!sessionCols.some((c) => c.name === "model_id")) {
|
|
170
199
|
this.db.exec(`ALTER TABLE sessions ADD COLUMN model_id TEXT`);
|
|
171
200
|
}
|
|
201
|
+
// Additive migration: images_json column for base64 image attachments on
|
|
202
|
+
// user messages. Older rows default to '' (no images).
|
|
203
|
+
const msgCols = this.db
|
|
204
|
+
.prepare(`PRAGMA table_info(messages)`)
|
|
205
|
+
.all();
|
|
206
|
+
if (!msgCols.some((c) => c.name === "images_json")) {
|
|
207
|
+
this.db.exec(`ALTER TABLE messages ADD COLUMN images_json TEXT NOT NULL DEFAULT ''`);
|
|
208
|
+
}
|
|
172
209
|
// ---- one-time cleanup -------------------------------------------------
|
|
173
210
|
// Historical garbage from prior runs of the bridge that persisted every
|
|
174
211
|
// intermediate `message_end` pi emitted, including pure-framing rows
|
|
@@ -223,10 +260,10 @@ export class SessionStore {
|
|
|
223
260
|
this.stmtGetSession = this.db.prepare(`SELECT id, project_id, title, created_at, updated_at FROM sessions WHERE id = ?`);
|
|
224
261
|
this.stmtCreateSession = this.db.prepare(`INSERT INTO sessions (id, project_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
|
|
225
262
|
this.stmtDeleteSession = this.db.prepare(`DELETE FROM sessions WHERE id = ?`);
|
|
226
|
-
this.stmtListMessages = this.db.prepare(`SELECT id, session_id, role, content, events_jsonl, created_at
|
|
263
|
+
this.stmtListMessages = this.db.prepare(`SELECT id, session_id, role, content, events_jsonl, images_json, created_at
|
|
227
264
|
FROM messages WHERE session_id = ? ORDER BY created_at ASC, id ASC`);
|
|
228
|
-
this.stmtAppendMessage = this.db.prepare(`INSERT INTO messages (id, session_id, role, content, events_jsonl, created_at)
|
|
229
|
-
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
265
|
+
this.stmtAppendMessage = this.db.prepare(`INSERT INTO messages (id, session_id, role, content, events_jsonl, images_json, created_at)
|
|
266
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
230
267
|
this.stmtTouchSession = this.db.prepare(`UPDATE sessions SET updated_at = ? WHERE id = ?`);
|
|
231
268
|
this.stmtRenameSession = this.db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`);
|
|
232
269
|
this.stmtSessionWithCount = this.db.prepare(`
|
|
@@ -381,13 +418,17 @@ export class SessionStore {
|
|
|
381
418
|
return row ? row.project_id : null;
|
|
382
419
|
}
|
|
383
420
|
getMessages(sessionId) {
|
|
384
|
-
return this.stmtListMessages.all(sessionId).map((r) =>
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
421
|
+
return this.stmtListMessages.all(sessionId).map((r) => {
|
|
422
|
+
const images = parseImagesJson(r.images_json);
|
|
423
|
+
return {
|
|
424
|
+
id: r.id,
|
|
425
|
+
role: r.role,
|
|
426
|
+
content: r.content,
|
|
427
|
+
events: r.events_jsonl,
|
|
428
|
+
createdAt: r.created_at,
|
|
429
|
+
...(images ? { images } : {}),
|
|
430
|
+
};
|
|
431
|
+
});
|
|
391
432
|
}
|
|
392
433
|
/**
|
|
393
434
|
* Append a message and bump the session's updated_at in one transaction.
|
|
@@ -397,8 +438,9 @@ export class SessionStore {
|
|
|
397
438
|
const id = msg.id ?? randomUUID();
|
|
398
439
|
const createdAt = msg.createdAt ?? Date.now();
|
|
399
440
|
const eventsJsonl = msg.eventsJsonl ?? "";
|
|
441
|
+
const imagesJson = msg.images && msg.images.length > 0 ? JSON.stringify(msg.images) : "";
|
|
400
442
|
const tx = this.db.transaction(() => {
|
|
401
|
-
this.stmtAppendMessage.run(id, sessionId, msg.role, msg.content, eventsJsonl, createdAt);
|
|
443
|
+
this.stmtAppendMessage.run(id, sessionId, msg.role, msg.content, eventsJsonl, imagesJson, createdAt);
|
|
402
444
|
this.stmtTouchSession.run(createdAt, sessionId);
|
|
403
445
|
});
|
|
404
446
|
tx();
|
|
@@ -408,6 +450,7 @@ export class SessionStore {
|
|
|
408
450
|
content: msg.content,
|
|
409
451
|
events: eventsJsonl,
|
|
410
452
|
createdAt,
|
|
453
|
+
...(msg.images && msg.images.length > 0 ? { images: msg.images } : {}),
|
|
411
454
|
};
|
|
412
455
|
}
|
|
413
456
|
/**
|