@aexol/spectral 0.9.12 → 0.9.14

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.
Files changed (50) hide show
  1. package/dist/extensions/spectral-vision-fallback.d.ts +10 -15
  2. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  3. package/dist/extensions/spectral-vision-fallback.js +231 -134
  4. package/dist/memory/hooks/compaction-hook.d.ts.map +1 -1
  5. package/dist/memory/hooks/compaction-hook.js +69 -3
  6. package/dist/memory/observer.d.ts +2 -1
  7. package/dist/memory/observer.d.ts.map +1 -1
  8. package/dist/memory/observer.js +2 -1
  9. package/dist/memory/testing/index.d.ts +12 -0
  10. package/dist/memory/testing/index.d.ts.map +1 -0
  11. package/dist/memory/testing/index.js +11 -0
  12. package/dist/memory/testing/populate.d.ts +93 -0
  13. package/dist/memory/testing/populate.d.ts.map +1 -0
  14. package/dist/memory/testing/populate.js +177 -0
  15. package/dist/memory/testing/random-model.d.ts +75 -0
  16. package/dist/memory/testing/random-model.d.ts.map +1 -0
  17. package/dist/memory/testing/random-model.js +237 -0
  18. package/dist/memory/testing/random-text.d.ts +27 -0
  19. package/dist/memory/testing/random-text.d.ts.map +1 -0
  20. package/dist/memory/testing/random-text.js +216 -0
  21. package/dist/memory/testing/seeded-random.d.ts +26 -0
  22. package/dist/memory/testing/seeded-random.d.ts.map +1 -0
  23. package/dist/memory/testing/seeded-random.js +57 -0
  24. package/dist/relay/dispatcher.d.ts.map +1 -1
  25. package/dist/relay/dispatcher.js +2 -0
  26. package/dist/sdk/agent-core/harness/compaction/utils.d.ts.map +1 -1
  27. package/dist/sdk/agent-core/harness/compaction/utils.js +31 -2
  28. package/dist/sdk/agent-core/harness/types.d.ts +2 -0
  29. package/dist/sdk/agent-core/harness/types.d.ts.map +1 -1
  30. package/dist/sdk/coding-agent/core/agent-session.d.ts +4 -0
  31. package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
  32. package/dist/sdk/coding-agent/core/agent-session.js +74 -1
  33. package/dist/sdk/coding-agent/core/compaction/utils.d.ts.map +1 -1
  34. package/dist/sdk/coding-agent/core/compaction/utils.js +39 -2
  35. package/dist/sdk/coding-agent/core/extensions/types.d.ts +6 -0
  36. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  37. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  38. package/dist/sdk/coding-agent/core/system-prompt.js +1 -0
  39. package/dist/server/agent-bridge.d.ts +13 -1
  40. package/dist/server/agent-bridge.d.ts.map +1 -1
  41. package/dist/server/agent-bridge.js +38 -11
  42. package/dist/server/session-stream.d.ts +2 -0
  43. package/dist/server/session-stream.d.ts.map +1 -1
  44. package/dist/server/session-stream.js +19 -6
  45. package/dist/server/storage.d.ts +10 -0
  46. package/dist/server/storage.d.ts.map +1 -1
  47. package/dist/server/storage.js +25 -0
  48. package/dist/server/wire.d.ts +15 -0
  49. package/dist/server/wire.d.ts.map +1 -1
  50. package/package.json +1 -1
@@ -1,22 +1,17 @@
1
1
  /**
2
2
  * Spectral Vision Extension
3
3
  *
4
- * Automatically switches to a vision-capable model for image processing
5
- * when images are attached. This allows non-vision models to "see" images
6
- * and saves main agent context by using a potentially smaller/cheaper
7
- * vision model for image understanding.
4
+ * Converts attached images into text analysis before the main agent call.
8
5
  *
9
- * Logic:
10
- * - If the main model supports vision natively, images pass through
11
- * directly no interception, no text descriptions.
12
- * - When the main model does NOT support vision, falls back to the
13
- * admin-configured default vision model (isVisionDefault flag from
14
- * backend via SettingsManager) for image descriptions.
15
- * - If no admin default is configured, falls back to the best
16
- * available vision-capable model by provider priority.
17
- *
18
- * Hooks into the `context` event to intercept ALL images before they reach
19
- * the LLM (covers user-attached images and tool-result images alike).
6
+ * Pipeline:
7
+ * - If the current main model can process images, use it as the analyzer.
8
+ * - Otherwise use the admin-configured default vision model.
9
+ * - Otherwise use the best available authenticated image-capable model.
10
+ * - Replace image blocks with the text analysis before persistence when
11
+ * possible (`message_end`), so image data is deleted from conversation
12
+ * history and is not re-analyzed on later turns.
13
+ * - Keep a `context` hook as a defense-in-depth fallback for older sessions
14
+ * or messages that entered context before the replacement hook existed.
20
15
  */
21
16
  import type { ExtensionAPI } from "../sdk/coding-agent/index.js";
22
17
  export default function spectralVisionExtension(ext: ExtensionAPI): void;
@@ -1 +1 @@
1
- {"version":3,"file":"spectral-vision-fallback.d.ts","sourceRoot":"","sources":["../../src/extensions/spectral-vision-fallback.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAuNjE,MAAM,CAAC,OAAO,UAAU,uBAAuB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CAmFvE"}
1
+ {"version":3,"file":"spectral-vision-fallback.d.ts","sourceRoot":"","sources":["../../src/extensions/spectral-vision-fallback.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAsWjE,MAAM,CAAC,OAAO,UAAU,uBAAuB,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI,CA6DvE"}
@@ -1,27 +1,47 @@
1
1
  /**
2
2
  * Spectral Vision Extension
3
3
  *
4
- * Automatically switches to a vision-capable model for image processing
5
- * when images are attached. This allows non-vision models to "see" images
6
- * and saves main agent context by using a potentially smaller/cheaper
7
- * vision model for image understanding.
4
+ * Converts attached images into text analysis before the main agent call.
8
5
  *
9
- * Logic:
10
- * - If the main model supports vision natively, images pass through
11
- * directly no interception, no text descriptions.
12
- * - When the main model does NOT support vision, falls back to the
13
- * admin-configured default vision model (isVisionDefault flag from
14
- * backend via SettingsManager) for image descriptions.
15
- * - If no admin default is configured, falls back to the best
16
- * available vision-capable model by provider priority.
17
- *
18
- * Hooks into the `context` event to intercept ALL images before they reach
19
- * the LLM (covers user-attached images and tool-result images alike).
6
+ * Pipeline:
7
+ * - If the current main model can process images, use it as the analyzer.
8
+ * - Otherwise use the admin-configured default vision model.
9
+ * - Otherwise use the best available authenticated image-capable model.
10
+ * - Replace image blocks with the text analysis before persistence when
11
+ * possible (`message_end`), so image data is deleted from conversation
12
+ * history and is not re-analyzed on later turns.
13
+ * - Keep a `context` hook as a defense-in-depth fallback for older sessions
14
+ * or messages that entered context before the replacement hook existed.
20
15
  */
21
16
  import { streamSimple } from "../sdk/ai/index.js";
22
17
  // ---------------------------------------------------------------------------
23
18
  // Helpers
24
19
  // ---------------------------------------------------------------------------
20
+ const EXTENSION_NAME = "spectral-vision";
21
+ const IMAGE_DESCRIPTION_PATTERN = /^\[Image analysis from /;
22
+ const MAX_CACHE_ENTRIES = 500;
23
+ function withImageInput(model) {
24
+ if (model.input.includes("image"))
25
+ return model;
26
+ return {
27
+ ...model,
28
+ input: [...model.input, "image"],
29
+ };
30
+ }
31
+ function sameModel(a, b) {
32
+ return !!a && !!b && a.provider === b.provider && a.id === b.id;
33
+ }
34
+ function getCurrentImageAnalyzer(ctx) {
35
+ const currentModel = ctx.model;
36
+ if (!currentModel)
37
+ return undefined;
38
+ if (!ctx.modelRegistry.hasConfiguredAuth(currentModel))
39
+ return undefined;
40
+ if (currentModel.input.includes("image")) {
41
+ return { model: withImageInput(currentModel), source: "main" };
42
+ }
43
+ return undefined;
44
+ }
25
45
  /**
26
46
  * Check if the SettingsManager has an admin-configured default vision model
27
47
  * with working auth. Returns the model if found and auth-ready, or undefined.
@@ -36,75 +56,126 @@ function getAdminVisionModel(ctx) {
36
56
  return undefined;
37
57
  const allModels = ctx.modelRegistry.getAll();
38
58
  const match = allModels.find((m) => m.provider === defaultVisionProvider && m.id === defaultVisionModel);
39
- if (!match || !match.input.includes("image"))
59
+ if (!match)
40
60
  return undefined;
41
61
  if (!ctx.modelRegistry.hasConfiguredAuth(match))
42
62
  return undefined;
43
- return match;
63
+ // Trust the admin's explicit choice: if they marked this model as the
64
+ // default vision model, it should be used even when the backend's
65
+ // capabilities data doesn't report image support.
66
+ return withImageInput(match);
44
67
  }
45
- /**
46
- * Resolve the best vision model to use for image descriptions.
47
- *
48
- * Priority:
49
- * 1. Current main model if it supports images — passthrough, no interception
50
- * 2. Admin-configured default vision model (isVisionDefault from backend)
51
- * 3. Best available vision model by provider priority
52
- * 4. First available vision model
53
- */
54
- function resolveVisionModel(ctx) {
55
- // 1. If the main model supports vision natively, skip interception entirely
56
- // and let the main model process images directly.
57
- const currentModel = ctx.model;
58
- if (currentModel?.input.includes("image") && ctx.modelRegistry.hasConfiguredAuth(currentModel)) {
59
- process.stderr.write(`[spectral-vision] Main model supports vision — passing images through natively (${currentModel.provider}/${currentModel.id})\n`);
60
- return undefined;
61
- }
62
- // 2. Admin-configured default vision model (fallback for non-vision main models)
68
+ function getFallbackVisionModel(ctx, exclude) {
63
69
  const adminModel = getAdminVisionModel(ctx);
64
- if (adminModel) {
65
- process.stderr.write(`[spectral-vision] Using admin-configured default: ${adminModel.provider}/${adminModel.id}\n`);
66
- return adminModel;
70
+ if (adminModel && !sameModel(adminModel, exclude)) {
71
+ return { model: adminModel, source: "default" };
67
72
  }
68
- // 3. Fall back to best available vision model by provider priority
69
73
  const allModels = ctx.modelRegistry.getAll();
70
- const visionModels = allModels.filter((m) => m.input.includes("image") && ctx.modelRegistry.hasConfiguredAuth(m));
74
+ const visionModels = allModels.filter((m) => m.input.includes("image") &&
75
+ ctx.modelRegistry.hasConfiguredAuth(m) &&
76
+ !sameModel(m, exclude));
71
77
  if (visionModels.length === 0)
72
78
  return undefined;
73
79
  const providerPriority = ["anthropic", "openai", "google", "openrouter"];
74
80
  for (const provider of providerPriority) {
75
81
  const match = visionModels.find((m) => m.provider === provider);
76
- if (match) {
77
- process.stderr.write(`[spectral-vision] Using fallback vision model: ${match.provider}/${match.id}\n`);
78
- return match;
79
- }
82
+ if (match)
83
+ return { model: match, source: "fallback" };
80
84
  }
81
- // 4. First available vision model
82
- const fallback = visionModels[0];
83
- process.stderr.write(`[spectral-vision] Using first available vision model: ${fallback.provider}/${fallback.id}\n`);
84
- return fallback;
85
+ return { model: visionModels[0], source: "fallback" };
86
+ }
87
+ function resolvePrimaryAnalyzer(ctx) {
88
+ const currentAnalyzer = getCurrentImageAnalyzer(ctx);
89
+ if (currentAnalyzer) {
90
+ process.stderr.write(`[${EXTENSION_NAME}] Using main model for image analysis: ${currentAnalyzer.model.provider}/${currentAnalyzer.model.id}\n`);
91
+ return currentAnalyzer;
92
+ }
93
+ const fallback = getFallbackVisionModel(ctx);
94
+ if (fallback) {
95
+ const label = fallback.source === "default" ? "admin-configured default" : "fallback vision model";
96
+ process.stderr.write(`[${EXTENSION_NAME}] Using ${label}: ${fallback.model.provider}/${fallback.model.id}\n`);
97
+ return fallback;
98
+ }
99
+ return undefined;
85
100
  }
86
101
  /** Check if any content block in an array is an image */
87
102
  function hasImageContent(content) {
88
103
  return Array.isArray(content) && content.some((c) => c?.type === "image");
89
104
  }
105
+ function removeImageContent(content) {
106
+ return content.filter((block) => block.type === "text");
107
+ }
90
108
  /** Count images in a message */
91
109
  function countImages(msg) {
92
110
  if (!Array.isArray(msg.content))
93
111
  return 0;
94
112
  return msg.content.filter((c) => c.type === "image").length;
95
113
  }
114
+ function isProcessableImageMessage(msg) {
115
+ return (msg.role === "user" || msg.role === "toolResult") && Array.isArray(msg.content) && hasImageContent(msg.content);
116
+ }
117
+ /**
118
+ * Check if a message has already been processed by our vision fallback.
119
+ */
120
+ function isAlreadyDescribed(msg) {
121
+ if (!Array.isArray(msg.content))
122
+ return false;
123
+ return msg.content.some((c) => c.type === "text" && IMAGE_DESCRIPTION_PATTERN.test(String(c.text)));
124
+ }
125
+ function contextTextFor(content) {
126
+ return content
127
+ .filter((c) => c.type === "text")
128
+ .map((c) => c.text)
129
+ .join(" ")
130
+ .substring(0, 500);
131
+ }
132
+ function imageMessageCacheKey(msg) {
133
+ const content = Array.isArray(msg.content) ? msg.content : [];
134
+ const text = content
135
+ .filter((c) => c.type === "text")
136
+ .map((c) => String(c.text))
137
+ .join("\n")
138
+ .slice(0, 1000);
139
+ const images = content
140
+ .filter((c) => c.type === "image")
141
+ .map((c) => `${c.mimeType ?? "image"}:${String(c.data ?? "").length}:${String(c.data ?? "").slice(0, 64)}`)
142
+ .join("|");
143
+ return `${msg.role}:${msg.timestamp ?? "no-ts"}:${text}:${images}`;
144
+ }
145
+ function rememberAnalysis(cache, key, content) {
146
+ cache.set(key, content);
147
+ if (cache.size <= MAX_CACHE_ENTRIES)
148
+ return;
149
+ const first = cache.keys().next().value;
150
+ if (first)
151
+ cache.delete(first);
152
+ }
153
+ function omittedContent(msg, reason) {
154
+ const textBlocks = Array.isArray(msg.content)
155
+ ? msg.content.filter((c) => c.type === "text")
156
+ : [];
157
+ const imageCount = countImages(msg);
158
+ return [
159
+ ...textBlocks,
160
+ {
161
+ type: "text",
162
+ text: `[${imageCount} image(s) omitted — ${reason}]`,
163
+ },
164
+ ];
165
+ }
96
166
  // ---------------------------------------------------------------------------
97
167
  // Core: call vision model to describe images
98
168
  // ---------------------------------------------------------------------------
99
169
  async function describeImages(visionModel, content, contextText, ctx) {
170
+ const model = withImageInput(visionModel);
100
171
  const userContent = [
101
172
  {
102
173
  type: "text",
103
- text: `Please describe the following image(s). ` +
174
+ text: `Please analyze the following image(s). ` +
104
175
  (contextText ? `Context: ${contextText}. ` : "") +
105
176
  "Focus on what is visually visible — text content, UI elements, diagrams, " +
106
- "code structure, layout, colors, etc. Be concise but thorough. " +
107
- "If multiple images are provided, describe each one separately with a heading.",
177
+ "code structure, layout, colors, errors, and any details needed to answer the user's request. " +
178
+ "Be concise but thorough. If multiple images are provided, analyze each one separately with a heading.",
108
179
  },
109
180
  ];
110
181
  const textPrefixBlocks = [];
@@ -120,115 +191,141 @@ async function describeImages(visionModel, content, contextText, ctx) {
120
191
  }
121
192
  if (imageCount === 0)
122
193
  return content;
123
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(visionModel);
194
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
124
195
  if (!auth.ok) {
125
- process.stderr.write(`[spectral-vision] No API key for vision model ${visionModel.provider}/${visionModel.id}\n`);
196
+ throw new Error(`No API key for vision model ${model.provider}/${model.id}: ${auth.error}`);
197
+ }
198
+ const result = await streamSimple(model, {
199
+ systemPrompt: "You are an image analysis assistant. Analyze images accurately and concisely. Return only the visual analysis, not commentary about being an assistant.",
200
+ messages: [{ role: "user", content: userContent, timestamp: Date.now() }],
201
+ tools: [],
202
+ }, {
203
+ apiKey: auth.apiKey,
204
+ headers: auth.headers,
205
+ });
206
+ let description = "";
207
+ for await (const event of result) {
208
+ if (event.type === "text_delta") {
209
+ description += event.delta;
210
+ }
211
+ }
212
+ const textNote = description.trim()
213
+ ? `[Image analysis from ${model.provider}/${model.id} (${imageCount} image(s)):\n${description.trim()}\n]`
214
+ : `[${imageCount} image(s) — vision model returned empty analysis]`;
215
+ try {
216
+ ctx.ui.notify(`[${EXTENSION_NAME}] Analyzed ${imageCount} image(s) with ${model.provider}/${model.id}`, "info");
217
+ }
218
+ catch {
219
+ // UI context may not be available in headless modes.
220
+ }
221
+ return [
222
+ ...textPrefixBlocks,
223
+ { type: "text", text: textNote },
224
+ ];
225
+ }
226
+ async function analyzeMessageImages(msg, ctx, cache) {
227
+ const key = imageMessageCacheKey(msg);
228
+ const cached = cache.get(key);
229
+ if (cached)
230
+ return cached;
231
+ if (isAlreadyDescribed(msg)) {
232
+ const withoutImages = removeImageContent(msg.content);
233
+ rememberAnalysis(cache, key, withoutImages);
234
+ return withoutImages;
235
+ }
236
+ const primary = resolvePrimaryAnalyzer(ctx);
237
+ if (!primary) {
238
+ const content = omittedContent(msg, "no authenticated vision-capable model is configured");
239
+ rememberAnalysis(cache, key, content);
126
240
  return content;
127
241
  }
242
+ const contextText = contextTextFor(msg.content);
243
+ const imageCount = countImages(msg);
128
244
  try {
129
- const result = await streamSimple(visionModel, {
130
- systemPrompt: "You are an image description assistant. Describe images accurately and concisely.",
131
- messages: [{ role: "user", content: userContent, timestamp: Date.now() }],
132
- tools: [],
133
- }, {
134
- apiKey: auth.apiKey,
135
- headers: auth.headers,
136
- });
137
- let description = "";
138
- for await (const event of result) {
139
- if (event.type === "text_delta") {
140
- description += event.delta;
141
- }
142
- }
143
- const textNote = description.trim()
144
- ? `[Image description from ${visionModel.provider}/${visionModel.id} (${imageCount} image(s)):\n${description}\n]`
145
- : `[${imageCount} image(s) — vision model returned empty description]`;
146
- // Notify the frontend that the vision extension analyzed the image(s)
147
- try {
148
- ctx.ui.notify(`[spectral-vision] Analyzed ${imageCount} image(s) with ${visionModel.provider}/${visionModel.id}`, "info");
149
- }
150
- catch {
151
- // UI context may not be available in headless modes
152
- }
153
- return [
154
- ...textPrefixBlocks,
155
- { type: "text", text: textNote },
156
- ];
245
+ const described = await describeImages(primary.model, msg.content, contextText, ctx);
246
+ const withoutImages = removeImageContent(described);
247
+ rememberAnalysis(cache, key, withoutImages);
248
+ return withoutImages;
157
249
  }
158
250
  catch (err) {
159
- const msg = err instanceof Error ? err.message : String(err);
160
- process.stderr.write(`[spectral-vision] Vision call failed: ${msg}\n`);
161
- // Notify frontend about the failure
251
+ const message = err instanceof Error ? err.message : String(err);
252
+ process.stderr.write(`[${EXTENSION_NAME}] Image analysis with ${primary.model.provider}/${primary.model.id} failed: ${message}\n`);
253
+ const fallback = primary.source === "main" ? getFallbackVisionModel(ctx, primary.model) : undefined;
254
+ if (fallback) {
255
+ process.stderr.write(`[${EXTENSION_NAME}] Retrying image analysis with ${fallback.model.provider}/${fallback.model.id}\n`);
256
+ try {
257
+ const described = await describeImages(fallback.model, msg.content, contextText, ctx);
258
+ const withoutImages = removeImageContent(described);
259
+ rememberAnalysis(cache, key, withoutImages);
260
+ return withoutImages;
261
+ }
262
+ catch (fallbackErr) {
263
+ const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
264
+ process.stderr.write(`[${EXTENSION_NAME}] Fallback image analysis with ${fallback.model.provider}/${fallback.model.id} failed: ${fallbackMessage}\n`);
265
+ try {
266
+ ctx.ui.notify(`[${EXTENSION_NAME}] Failed to analyze ${imageCount} image(s): ${fallbackMessage}`, "warning");
267
+ }
268
+ catch {
269
+ // UI context may not be available in headless modes.
270
+ }
271
+ const content = omittedContent(msg, `vision analysis failed: ${fallbackMessage}`);
272
+ rememberAnalysis(cache, key, content);
273
+ return content;
274
+ }
275
+ }
162
276
  try {
163
- ctx.ui.notify(`[spectral-vision] Failed to analyze ${imageCount} image(s): ${msg}`, "warning");
277
+ ctx.ui.notify(`[${EXTENSION_NAME}] Failed to analyze ${imageCount} image(s): ${message}`, "warning");
164
278
  }
165
279
  catch {
166
- // UI context may not be available in headless modes
280
+ // UI context may not be available in headless modes.
167
281
  }
168
- return [
169
- ...textPrefixBlocks,
170
- {
171
- type: "text",
172
- text: `[${imageCount} image(s) omitted — vision fallback failed: ${msg}]`,
173
- },
174
- ];
282
+ const content = omittedContent(msg, `vision analysis failed: ${message}`);
283
+ rememberAnalysis(cache, key, content);
284
+ return content;
175
285
  }
176
286
  }
177
287
  // ---------------------------------------------------------------------------
178
288
  // Extension entry point
179
289
  // ---------------------------------------------------------------------------
180
290
  export default function spectralVisionExtension(ext) {
181
- let visionModel;
291
+ const analysisCache = new Map();
182
292
  ext.on("session_start", (_event, ctx) => {
183
- visionModel = resolveVisionModel(ctx);
184
- if (visionModel) {
185
- process.stderr.write(`[spectral-vision] Ready using ${visionModel.provider}/${visionModel.id} for image descriptions.\n`);
186
- }
187
- else if (ctx.model?.input.includes("image")) {
188
- process.stderr.write(`[spectral-vision] Ready — main model ${ctx.model.provider}/${ctx.model.id} handles vision natively, passthrough enabled.\n`);
293
+ const analyzer = resolvePrimaryAnalyzer(ctx);
294
+ if (analyzer) {
295
+ const label = analyzer.source === "main" ? "main model" : analyzer.source === "default" ? "default vision model" : "fallback vision model";
296
+ process.stderr.write(`[${EXTENSION_NAME}] Ready — ${label} ${analyzer.model.provider}/${analyzer.model.id} will analyze images before agent calls.\n`);
189
297
  }
190
298
  else {
191
- process.stderr.write("[spectral-vision] No vision model with auth configured. Image description disabled.\n");
299
+ process.stderr.write(`[${EXTENSION_NAME}] No vision model with auth configured. Images will be removed with an explanatory note.\n`);
192
300
  }
193
301
  });
302
+ // Primary path: replace user/tool-result image blocks before AgentSession
303
+ // persists the message. This deletes raw base64 image data from conversation
304
+ // history and prevents repeat analysis on later turns.
305
+ ext.on("message_end", async (event, ctx) => {
306
+ const msg = event.message;
307
+ if (!isProcessableImageMessage(msg))
308
+ return;
309
+ process.stderr.write(`[${EXTENSION_NAME}] Analyzing ${countImages(msg)} image(s) before persisting ${msg.role} message...\n`);
310
+ const content = await analyzeMessageImages(msg, ctx, analysisCache);
311
+ return { message: { ...msg, content } };
312
+ });
313
+ // Defense-in-depth path: handle images still present in context from older
314
+ // sessions or from messages that bypassed message_end replacement. Results are
315
+ // cached by message/image fingerprint so old images are not re-analyzed every
316
+ // turn; cached analysis is substituted instead.
194
317
  ext.on("context", async (event, ctx) => {
195
318
  const messages = event.messages;
196
- // Check if any message contains images
197
- let totalImages = 0;
198
- for (const msg of messages) {
199
- totalImages += countImages(msg);
200
- }
201
- if (totalImages === 0)
319
+ const imageMessages = messages.filter(isProcessableImageMessage);
320
+ if (imageMessages.length === 0)
202
321
  return;
203
- // Re-resolve vision model each time images are encountered.
204
- // If the main model supports vision natively, resolveVisionModel
205
- // returns undefined and we pass images through without interception.
206
- visionModel = resolveVisionModel(ctx);
207
- if (!visionModel) {
208
- // Check if we're in passthrough mode (main model has native vision)
209
- if (ctx.model?.input.includes("image")) {
210
- process.stderr.write(`[spectral-vision] Passthrough — ${totalImages} image(s) flowing to main model natively\n`);
211
- }
212
- else {
213
- process.stderr.write("[spectral-vision] No vision model available, images will be omitted\n");
214
- }
215
- return;
216
- }
217
- process.stderr.write(`[spectral-vision] Describing ${totalImages} image(s) with ${visionModel.provider}/${visionModel.id}...\n`);
218
- // Process each message — intercept both user messages and tool results
219
- // that contain images (e.g. read tool returning a screenshot).
322
+ const totalImages = imageMessages.reduce((sum, msg) => sum + countImages(msg), 0);
323
+ process.stderr.write(`[${EXTENSION_NAME}] Context contains ${totalImages} image(s); replacing with analysis text before provider request...\n`);
220
324
  const processed = await Promise.all(messages.map(async (msg) => {
221
- if ((msg.role !== "user" && msg.role !== "toolResult") || !Array.isArray(msg.content) || !hasImageContent(msg.content)) {
325
+ if (!isProcessableImageMessage(msg))
222
326
  return msg;
223
- }
224
- // Extract context from preceding text blocks
225
- const textBlocks = msg.content
226
- .filter((c) => c.type === "text")
227
- .map((c) => c.text)
228
- .join(" ");
229
- const contextText = textBlocks.substring(0, 500); // limit context
230
- const described = await describeImages(visionModel, msg.content, contextText, ctx);
231
- return { ...msg, content: described };
327
+ const content = await analyzeMessageImages(msg, ctx, analysisCache);
328
+ return { ...msg, content };
232
329
  }));
233
330
  return { messages: processed };
234
331
  });
@@ -1 +1 @@
1
- {"version":3,"file":"compaction-hook.d.ts","sourceRoot":"","sources":["../../../src/memory/hooks/compaction-hook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,iCAAiC,CAAC;AAsBtF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AA6B7C,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CA2VhF"}
1
+ {"version":3,"file":"compaction-hook.d.ts","sourceRoot":"","sources":["../../../src/memory/hooks/compaction-hook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAA+C,MAAM,iCAAiC,CAAC;AAsBjH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAmE7C,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CA8XhF"}