@aexol/spectral 0.6.4 → 0.6.8

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.
@@ -633,17 +633,15 @@ export function detachAllSubscribers(manager, subscribers) {
633
633
  subscribers.clear();
634
634
  }
635
635
  /**
636
- * Dispatch an `auto_research` frame. Spawns an isolated pi subprocess that:
637
- * 1. Collects project context (files, existing extensions, git state)
638
- * 2. Analyzes the project using an LLM (via subagent)
639
- * 3. Generates extension `.ts` files under `.pi/extensions/auto-research/`
640
- * 4. Hot-reloads pi so new extensions take effect immediately
636
+ * Dispatch an `auto_research` frame. Sends the auto-research task through
637
+ * the existing PiBridge (backend proxy) instead of spawning a separate pi
638
+ * subprocess. This ensures auto-research uses the same model and API keys
639
+ * as the active session.
641
640
  *
642
641
  * Progress is streamed back as `ws_event` frames carrying
643
642
  * `auto_research_*` ServerEvent types on the `sessionId` channel.
644
643
  *
645
- * Errors are surfaced as `auto_research_error` events; the underlying
646
- * subprocess is killed on error to prevent zombie processes.
644
+ * Errors are surfaced as `auto_research_error` events.
647
645
  */
648
646
  export function handleAutoResearchFrame(frame, deps) {
649
647
  handleAutoResearch({
@@ -28,7 +28,7 @@ const cache = new Map();
28
28
  export function clearAllowedModelsCache() {
29
29
  cache.clear();
30
30
  }
31
- const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow } }`;
31
+ const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow supportsImages } }`;
32
32
  /**
33
33
  * Fetch the whitelist of allowed base models. Throws on any failure with a
34
34
  * message tailored for an operator running `spectral serve` — the caller
@@ -101,6 +101,9 @@ export async function fetchAllowedModels(opts) {
101
101
  const contextWindow = typeof row?.contextWindow === "number" && row.contextWindow > 0
102
102
  ? row.contextWindow
103
103
  : null;
104
+ const supportsImages = typeof row?.supportsImages === "boolean"
105
+ ? row.supportsImages
106
+ : null;
104
107
  const model = {
105
108
  modelId: name,
106
109
  displayName: name,
@@ -111,6 +114,7 @@ export async function fetchAllowedModels(opts) {
111
114
  creditCacheReadPer1M: asOptionalNumber(row?.creditCacheReadPer1M),
112
115
  creditCacheWritePer1M: asOptionalNumber(row?.creditCacheWritePer1M),
113
116
  contextWindow,
117
+ supportsImages,
114
118
  };
115
119
  if (typeof row?.userModelId === "string") {
116
120
  model.userModelId = row.userModelId;
@@ -610,7 +610,7 @@ export class PiBridge {
610
610
  provider: SPECTRAL_PROXY_ANTHROPIC,
611
611
  baseUrl,
612
612
  reasoning: supportsReasoning(m.modelId),
613
- input: ["text", "image"],
613
+ input: m.supportsImages !== false ? ["text", "image"] : ["text"],
614
614
  // Real pricing so pi can compute accurate token costs.
615
615
  cost: pricing
616
616
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
@@ -640,7 +640,7 @@ export class PiBridge {
640
640
  provider: SPECTRAL_PROXY_OPENAI,
641
641
  baseUrl,
642
642
  reasoning: supportsReasoning(m.modelId),
643
- input: ["text", "image"],
643
+ input: m.supportsImages !== false ? ["text", "image"] : ["text"],
644
644
  // Real pricing so pi can compute accurate token costs.
645
645
  cost: pricing
646
646
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
@@ -671,7 +671,7 @@ export class PiBridge {
671
671
  provider: SPECTRAL_PROXY_USER_MODEL,
672
672
  baseUrl,
673
673
  reasoning: supportsReasoning(m.modelId),
674
- input: ["text", "image"],
674
+ input: m.supportsImages !== false ? ["text", "image"] : ["text"],
675
675
  cost: pricing
676
676
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
677
677
  : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -802,22 +802,46 @@ export class PiBridge {
802
802
  *
803
803
  * When `images` is non-empty, each base64-encoded attachment is converted
804
804
  * to a pi `ImageContent` block and passed as `options.images` to
805
- * `session.prompt()`. Pi's model providers already register with
806
- * `input: ["text", "image"]`, so the backend proxy correctly routes
807
- * multimodal prompts.
805
+ * `session.prompt()`. If the current model does not support image inputs,
806
+ * images are instead converted to text placeholders so the conversation
807
+ * can continue without errors.
808
808
  */
809
809
  async prompt(text, images) {
810
810
  if (!this.session)
811
811
  throw new Error("PiBridge.start() not called");
812
+ // Check whether the currently active model supports image input.
813
+ // When `supportsImages` is null/undefined (unknown), we are conservative
814
+ // and convert images to text rather than risking a 400 error.
815
+ const currentModel = this.lastAppliedModelId
816
+ ? this.allowedModels?.find((m) => m.modelId === this.lastAppliedModelId)
817
+ : undefined;
818
+ const modelSupportsImages = currentModel?.supportsImages === true;
812
819
  try {
813
- const imageContents = images && images.length > 0
814
- ? images.map((img) => ({
820
+ if (images && images.length > 0 && modelSupportsImages) {
821
+ const imageContents = images.map((img) => ({
815
822
  type: "image",
816
823
  data: img.data,
817
824
  mimeType: img.mimeType,
818
- }))
819
- : undefined;
820
- await this.session.prompt(text, { images: imageContents });
825
+ }));
826
+ await this.session.prompt(text, { images: imageContents });
827
+ }
828
+ else if (images && images.length > 0 && !modelSupportsImages) {
829
+ // Model doesn't support images — convert them to text descriptions
830
+ // so the conversation can continue instead of hanging.
831
+ const imageDescriptions = images
832
+ .map((img, i) => `[Image ${i + 1}: ${img.mimeType}, ${img.data.length.toLocaleString()} bytes base64]`)
833
+ .join("\n");
834
+ const augmentedText = `${text}\n\n---\nThe following image(s) were attached but the current model does not support image input:\n${imageDescriptions}\n(Describe what you see or ask the user to switch to a model that supports images.)`;
835
+ this.opts.emit({
836
+ type: "agent_notification",
837
+ message: `The current model does not support image input. ${images.length} image(s) were converted to text descriptions.`,
838
+ level: "warning",
839
+ });
840
+ await this.session.prompt(augmentedText);
841
+ }
842
+ else {
843
+ await this.session.prompt(text);
844
+ }
821
845
  }
822
846
  catch (err) {
823
847
  const e = err instanceof Error ? err : new Error(String(err));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.6.4",
3
+ "version": "0.6.8",
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,