@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.
- package/dist/extensions/spectral-vision-fallback.d.ts +10 -15
- package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
- package/dist/extensions/spectral-vision-fallback.js +231 -134
- package/dist/memory/hooks/compaction-hook.d.ts.map +1 -1
- package/dist/memory/hooks/compaction-hook.js +69 -3
- package/dist/memory/observer.d.ts +2 -1
- package/dist/memory/observer.d.ts.map +1 -1
- package/dist/memory/observer.js +2 -1
- package/dist/memory/testing/index.d.ts +12 -0
- package/dist/memory/testing/index.d.ts.map +1 -0
- package/dist/memory/testing/index.js +11 -0
- package/dist/memory/testing/populate.d.ts +93 -0
- package/dist/memory/testing/populate.d.ts.map +1 -0
- package/dist/memory/testing/populate.js +177 -0
- package/dist/memory/testing/random-model.d.ts +75 -0
- package/dist/memory/testing/random-model.d.ts.map +1 -0
- package/dist/memory/testing/random-model.js +237 -0
- package/dist/memory/testing/random-text.d.ts +27 -0
- package/dist/memory/testing/random-text.d.ts.map +1 -0
- package/dist/memory/testing/random-text.js +216 -0
- package/dist/memory/testing/seeded-random.d.ts +26 -0
- package/dist/memory/testing/seeded-random.d.ts.map +1 -0
- package/dist/memory/testing/seeded-random.js +57 -0
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +2 -0
- package/dist/sdk/agent-core/harness/compaction/utils.d.ts.map +1 -1
- package/dist/sdk/agent-core/harness/compaction/utils.js +31 -2
- package/dist/sdk/agent-core/harness/types.d.ts +2 -0
- package/dist/sdk/agent-core/harness/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/agent-session.d.ts +4 -0
- package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/agent-session.js +74 -1
- package/dist/sdk/coding-agent/core/compaction/utils.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/compaction/utils.js +39 -2
- package/dist/sdk/coding-agent/core/extensions/types.d.ts +6 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.js +1 -0
- package/dist/server/agent-bridge.d.ts +13 -1
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +38 -11
- package/dist/server/session-stream.d.ts +2 -0
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +19 -6
- package/dist/server/storage.d.ts +10 -0
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +25 -0
- package/dist/server/wire.d.ts +15 -0
- package/dist/server/wire.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Spectral Vision Extension
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
10
|
-
* - If the main model
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
10
|
-
* - If the main model
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
59
|
+
if (!match)
|
|
40
60
|
return undefined;
|
|
41
61
|
if (!ctx.modelRegistry.hasConfiguredAuth(match))
|
|
42
62
|
return undefined;
|
|
43
|
-
|
|
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
|
-
|
|
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") &&
|
|
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
|
-
|
|
78
|
-
return match;
|
|
79
|
-
}
|
|
82
|
+
if (match)
|
|
83
|
+
return { model: match, source: "fallback" };
|
|
80
84
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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,
|
|
107
|
-
"If multiple images are provided,
|
|
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(
|
|
194
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
124
195
|
if (!auth.ok) {
|
|
125
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
160
|
-
process.stderr.write(`[
|
|
161
|
-
|
|
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(`[
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
291
|
+
const analysisCache = new Map();
|
|
182
292
|
ext.on("session_start", (_event, ctx) => {
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
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(
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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 (
|
|
325
|
+
if (!isProcessableImageMessage(msg))
|
|
222
326
|
return msg;
|
|
223
|
-
|
|
224
|
-
|
|
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,
|
|
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"}
|