@aexol/spectral 0.0.6 → 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.
@@ -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
  }
@@ -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
- await this.session.prompt(text);
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, { role: "user", content });
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
@@ -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
- id: r.id,
386
- role: r.role,
387
- content: r.content,
388
- events: r.events_jsonl,
389
- createdAt: r.created_at,
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.0.6",
3
+ "version": "0.0.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,