@ouro.bot/cli 0.1.0-alpha.250 → 0.1.0-alpha.251
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/changelog.json +10 -0
- package/dist/heart/core.js +1 -1
- package/dist/heart/model-capabilities.js +11 -0
- package/dist/heart/providers/minimax-vlm.js +167 -0
- package/dist/heart/providers/minimax.js +8 -1
- package/dist/mind/prompt.js +4 -1
- package/dist/repertoire/tools-bluebubbles.js +107 -0
- package/dist/repertoire/tools.js +10 -2
- package/dist/senses/bluebubbles/attachment-cache.js +53 -0
- package/dist/senses/bluebubbles/client.js +37 -1
- package/dist/senses/bluebubbles/inbound-log.js +1 -0
- package/dist/senses/bluebubbles/media.js +138 -22
- package/dist/senses/bluebubbles/model.js +7 -0
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.251",
|
|
6
|
+
"changes": [
|
|
7
|
+
"fix(bluebubbles): images sent via iMessage now reach the model — adds capability-gated image hydration with a MiniMax VLM fallback. Reasoning MiniMax chat models (M2/M2.1/M2.5/M2.7) silently drop OpenAI-style `image_url` content parts, so previously slugger answered image questions from fabricated context. Now, when the active chat model lacks the new `vision: true` capability flag, inbound screenshots are auto-described at ingestion via `/v1/coding_plan/vlm` and the description text replaces the `image_url` part before the turn reaches the model. Vision-capable models (claude-opus/sonnet-4-6, gpt-5.4, MiniMax-Text-01, MiniMax-VL-01) continue to see images natively via pass-through.",
|
|
8
|
+
"feat(tools): new `describe_image` agent tool registered into the BlueBubbles tool set when the chat model lacks vision. Lets the agent re-interrogate an attachment with a targeted prompt (e.g. 'what's the flight number in the bottom-right?') after ingestion. Backed by a bounded in-memory attachment cache populated during hydration; handler re-downloads bytes and calls the same VLM client.",
|
|
9
|
+
"fix(bluebubbles): `formatMessageText` now preserves the attachment marker when a message has BOTH text and attachments (B2). Previously the marker was dropped whenever text was present, hiding attachments from the agent's view of the message.",
|
|
10
|
+
"feat(heart): `ModelCapabilities` gains `vision?: boolean` and `audio?: boolean` flags (B4). Vision rows populated for claude-opus-4-6, claude-sonnet-4-6, claude-opus-4.6, claude-sonnet-4.6, gpt-5.4, MiniMax-Text-01, MiniMax-VL-01. M2.1/M2.5/M2.7 intentionally left unset.",
|
|
11
|
+
"fix(bluebubbles): `senses.bluebubbles_media_hydrate`, `_repair_start`, `_repair_end`, and `_inbound_logged` now emit at `level: warn` at the call site so they survive the BB sense's default log level without bumping the sense default (B7). `_event_normalized` stays at info. New `senses.bluebubbles_vision_format_unsupported` warn event fires when an inbound attachment is not in png/jpeg/webp."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
4
14
|
{
|
|
5
15
|
"version": "0.1.0-alpha.250",
|
|
6
16
|
"changes": [
|
package/dist/heart/core.js
CHANGED
|
@@ -471,7 +471,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
471
471
|
}
|
|
472
472
|
catch { /* unsupported */ }
|
|
473
473
|
const toolPreferences = currentContext?.friend?.toolPreferences;
|
|
474
|
-
const baseTools = options?.tools ?? (0, tools_1.getToolsForChannel)(channel ? (0, channel_1.getChannelCapabilities)(channel) : undefined, toolPreferences && Object.keys(toolPreferences).length > 0 ? toolPreferences : undefined, currentContext, providerRuntime.capabilities, options?.mcpManager);
|
|
474
|
+
const baseTools = options?.tools ?? (0, tools_1.getToolsForChannel)(channel ? (0, channel_1.getChannelCapabilities)(channel) : undefined, toolPreferences && Object.keys(toolPreferences).length > 0 ? toolPreferences : undefined, currentContext, providerRuntime.capabilities, options?.mcpManager, providerRuntime.model);
|
|
475
475
|
// Augment tool context with reasoning effort controls from provider
|
|
476
476
|
const augmentedToolContext = options?.toolContext
|
|
477
477
|
? {
|
|
@@ -8,30 +8,41 @@ exports.MODEL_CAPABILITIES = {
|
|
|
8
8
|
reasoningEffort: ["low", "medium", "high", "max"],
|
|
9
9
|
thinkingFormat: "anthropic",
|
|
10
10
|
maxOutputTokens: 128000,
|
|
11
|
+
vision: true,
|
|
11
12
|
},
|
|
12
13
|
"claude-sonnet-4-6": {
|
|
13
14
|
reasoningEffort: ["low", "medium", "high"],
|
|
14
15
|
thinkingFormat: "anthropic",
|
|
15
16
|
maxOutputTokens: 64000,
|
|
17
|
+
vision: true,
|
|
16
18
|
},
|
|
17
19
|
"claude-opus-4.6": {
|
|
18
20
|
reasoningEffort: ["low", "medium", "high", "max"],
|
|
19
21
|
maxOutputTokens: 128000,
|
|
22
|
+
vision: true,
|
|
20
23
|
},
|
|
21
24
|
"claude-sonnet-4.6": {
|
|
22
25
|
reasoningEffort: ["low", "medium", "high"],
|
|
23
26
|
maxOutputTokens: 64000,
|
|
27
|
+
vision: true,
|
|
24
28
|
},
|
|
25
29
|
"gpt-5.4": {
|
|
26
30
|
reasoningEffort: ["low", "medium", "high"],
|
|
27
31
|
phase: true,
|
|
28
32
|
maxOutputTokens: 100000,
|
|
33
|
+
vision: true,
|
|
29
34
|
},
|
|
30
35
|
"gpt-5.3-codex": {
|
|
31
36
|
reasoningEffort: ["low", "medium", "high"],
|
|
32
37
|
phase: true,
|
|
33
38
|
maxOutputTokens: 100000,
|
|
34
39
|
},
|
|
40
|
+
"MiniMax-Text-01": {
|
|
41
|
+
vision: true,
|
|
42
|
+
},
|
|
43
|
+
"MiniMax-VL-01": {
|
|
44
|
+
vision: true,
|
|
45
|
+
},
|
|
35
46
|
};
|
|
36
47
|
const EMPTY_CAPABILITIES = Object.freeze({});
|
|
37
48
|
function getModelCapabilities(modelId) {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.minimaxVlmDescribe = minimaxVlmDescribe;
|
|
4
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
5
|
+
const error_classification_1 = require("./error-classification");
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
7
|
+
const MODEL_NAME = "minimax-vlm";
|
|
8
|
+
const SUPPORTED_DATA_URL_PATTERN = /^data:image\/(png|jpeg|webp);base64,/i;
|
|
9
|
+
function buildVlmUrl(baseURL) {
|
|
10
|
+
const stripped = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
|
|
11
|
+
return `${stripped}/v1/coding_plan/vlm`;
|
|
12
|
+
}
|
|
13
|
+
function extractMimeType(imageDataUrl) {
|
|
14
|
+
const match = /^data:([^;,]+)[;,]/.exec(imageDataUrl);
|
|
15
|
+
return match?.[1]?.toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
function emitStart(params) {
|
|
18
|
+
(0, runtime_1.emitNervesEvent)({
|
|
19
|
+
level: "warn",
|
|
20
|
+
component: "senses",
|
|
21
|
+
event: "senses.bluebubbles_vlm_describe_start",
|
|
22
|
+
message: "minimax vlm describe start",
|
|
23
|
+
meta: {
|
|
24
|
+
attachmentGuid: params.attachmentGuid,
|
|
25
|
+
mimeType: params.mimeType,
|
|
26
|
+
promptLength: params.prompt.length,
|
|
27
|
+
model: MODEL_NAME,
|
|
28
|
+
chatModel: params.chatModel,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function emitEnd(params, descriptionLength, latencyMs) {
|
|
33
|
+
(0, runtime_1.emitNervesEvent)({
|
|
34
|
+
level: "warn",
|
|
35
|
+
component: "senses",
|
|
36
|
+
event: "senses.bluebubbles_vlm_describe_end",
|
|
37
|
+
message: "minimax vlm describe end",
|
|
38
|
+
meta: {
|
|
39
|
+
attachmentGuid: params.attachmentGuid,
|
|
40
|
+
descriptionLength,
|
|
41
|
+
latencyMs,
|
|
42
|
+
model: MODEL_NAME,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function emitError(params, reason, status, traceId) {
|
|
47
|
+
(0, runtime_1.emitNervesEvent)({
|
|
48
|
+
level: "warn",
|
|
49
|
+
component: "senses",
|
|
50
|
+
event: "senses.bluebubbles_vlm_describe_error",
|
|
51
|
+
message: "minimax vlm describe error",
|
|
52
|
+
meta: {
|
|
53
|
+
attachmentGuid: params.attachmentGuid,
|
|
54
|
+
mimeType: params.mimeType,
|
|
55
|
+
model: MODEL_NAME,
|
|
56
|
+
reason,
|
|
57
|
+
status,
|
|
58
|
+
traceId,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function throwAndEmit(params, message, status, traceId) {
|
|
63
|
+
emitError(params, message, status, traceId);
|
|
64
|
+
const err = new Error(message);
|
|
65
|
+
if (status !== undefined)
|
|
66
|
+
err.status = status;
|
|
67
|
+
if (traceId)
|
|
68
|
+
err.traceId = traceId;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
async function minimaxVlmDescribe(params) {
|
|
72
|
+
if (!params.apiKey) {
|
|
73
|
+
// We deliberately do NOT emit _start for param-validation errors — there's
|
|
74
|
+
// no meaningful "started a request" to pair with. Only the _error fires.
|
|
75
|
+
emitError(params, "minimax VLM: API key is empty — re-run credential setup or add a minimax key to secrets.json");
|
|
76
|
+
throw new Error("minimax VLM: API key is empty — re-run credential setup or add a minimax key to secrets.json");
|
|
77
|
+
}
|
|
78
|
+
if (!params.prompt) {
|
|
79
|
+
emitError(params, "minimax VLM: missing prompt — supply a targeted question (e.g. 'what's the flight number in the bottom-right?') and retry");
|
|
80
|
+
throw new Error("minimax VLM: missing prompt — supply a targeted question (e.g. 'what's the flight number in the bottom-right?') and retry");
|
|
81
|
+
}
|
|
82
|
+
if (!params.imageDataUrl) {
|
|
83
|
+
emitError(params, "minimax VLM: missing image data URL — verify the attachment downloaded OK or ask the user to resend the image");
|
|
84
|
+
throw new Error("minimax VLM: missing image data URL — verify the attachment downloaded OK or ask the user to resend the image");
|
|
85
|
+
}
|
|
86
|
+
if (!SUPPORTED_DATA_URL_PATTERN.test(params.imageDataUrl)) {
|
|
87
|
+
const mime = extractMimeType(params.imageDataUrl) ?? "unknown";
|
|
88
|
+
const msg = `minimax VLM: image must be a data:image/(png|jpeg|webp);base64,... URL but got ${mime} — ask the user to resend as a screenshot (png or jpeg)`;
|
|
89
|
+
emitError({ ...params, mimeType: params.mimeType ?? mime }, msg);
|
|
90
|
+
throw new Error(msg);
|
|
91
|
+
}
|
|
92
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
93
|
+
const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
94
|
+
const url = buildVlmUrl(params.baseURL);
|
|
95
|
+
emitStart(params);
|
|
96
|
+
const startTs = Date.now();
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await fetchImpl(url, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: {
|
|
102
|
+
"Authorization": `Bearer ${params.apiKey}`,
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"MM-API-Source": "Ouroboros",
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
prompt: params.prompt,
|
|
108
|
+
image_url: params.imageDataUrl,
|
|
109
|
+
}),
|
|
110
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const err = (error instanceof Error ? error : new Error(String(error)));
|
|
115
|
+
if (err.name === "AbortError" || err.name === "TimeoutError") {
|
|
116
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
117
|
+
throwAndEmit(params, `minimax VLM: request timed out after ${seconds}s — retry or ask the user to resend the image`);
|
|
118
|
+
}
|
|
119
|
+
if ((0, error_classification_1.isNetworkError)(err)) {
|
|
120
|
+
throwAndEmit(params, `minimax VLM: network error talking to the vlm endpoint (${err.message}) — retry in a moment`);
|
|
121
|
+
}
|
|
122
|
+
throwAndEmit(params, `minimax VLM: unexpected transport error (${err.message}) — retry, and if it persists surface 'image understanding is unavailable' to the user`);
|
|
123
|
+
}
|
|
124
|
+
const traceId = response.headers.get("Trace-Id") ?? response.headers.get("trace-id") ?? undefined;
|
|
125
|
+
const status = response.status;
|
|
126
|
+
if (status === 401 || status === 403) {
|
|
127
|
+
throwAndEmit(params, `minimax VLM: rejected credentials (HTTP ${status}) — the minimax key for this agent is invalid, re-run credential setup or rotate the key`, status, traceId);
|
|
128
|
+
}
|
|
129
|
+
if (status === 429) {
|
|
130
|
+
throwAndEmit(params, `minimax VLM: rate limited (HTTP 429) — wait and retry in a moment`, status, traceId);
|
|
131
|
+
}
|
|
132
|
+
if (status >= 500) {
|
|
133
|
+
throwAndEmit(params, `minimax VLM: server error (HTTP ${status}) — retry, and if it persists surface 'image understanding is unavailable' to the user`, status, traceId);
|
|
134
|
+
}
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
throwAndEmit(params, `minimax VLM: unexpected HTTP ${status} — retry, and if it persists surface 'image understanding is unavailable' to the user`, status, traceId);
|
|
137
|
+
}
|
|
138
|
+
let body;
|
|
139
|
+
try {
|
|
140
|
+
body = await response.json();
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const err = error;
|
|
144
|
+
throwAndEmit(params, `minimax VLM: malformed response (not JSON: ${err.message}) — this is a provider bug, retry or surface 'image understanding is unavailable' to the user`, status, traceId);
|
|
145
|
+
}
|
|
146
|
+
const parsed = body;
|
|
147
|
+
const providerStatus = parsed?.base_resp?.status_code ?? 0;
|
|
148
|
+
if (providerStatus !== 0) {
|
|
149
|
+
const providerMsg = parsed?.base_resp?.status_msg ?? "unknown";
|
|
150
|
+
throwAndEmit(params, `minimax VLM: provider returned status_code=${providerStatus} (${providerMsg}) — check the minimax account or retry, and if it persists surface 'image understanding is unavailable' to the user`, status, traceId);
|
|
151
|
+
}
|
|
152
|
+
const content = parsed?.content;
|
|
153
|
+
if (typeof content !== "string" || content.length === 0) {
|
|
154
|
+
throwAndEmit(params, `minimax VLM: returned no description (empty content field) — this is a provider bug, retry or surface 'image understanding is unavailable' to the user`, status, traceId);
|
|
155
|
+
}
|
|
156
|
+
const latencyMs = Date.now() - startTs;
|
|
157
|
+
emitEnd(params, content.length, latencyMs);
|
|
158
|
+
return content;
|
|
159
|
+
}
|
|
160
|
+
/* v8 ignore start — module-level observability event */
|
|
161
|
+
(0, runtime_1.emitNervesEvent)({
|
|
162
|
+
component: "engine",
|
|
163
|
+
event: "engine.minimax_vlm_loaded",
|
|
164
|
+
message: "minimax vlm client loaded",
|
|
165
|
+
meta: {},
|
|
166
|
+
});
|
|
167
|
+
/* v8 ignore stop */
|
|
@@ -3,12 +3,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.MINIMAX_PROVIDER_BASE_URL = void 0;
|
|
6
7
|
exports.classifyMinimaxError = classifyMinimaxError;
|
|
7
8
|
exports.createMinimaxProviderRuntime = createMinimaxProviderRuntime;
|
|
8
9
|
const openai_1 = __importDefault(require("openai"));
|
|
9
10
|
const config_1 = require("../config");
|
|
10
11
|
const runtime_1 = require("../../nerves/runtime");
|
|
11
12
|
const error_classification_1 = require("./error-classification");
|
|
13
|
+
/**
|
|
14
|
+
* Canonical MiniMax chat-completions base URL. Exported so the BlueBubbles
|
|
15
|
+
* VLM fallback (which targets a sibling endpoint under the same host) can
|
|
16
|
+
* derive its URL from one source of truth instead of hard-coding.
|
|
17
|
+
*/
|
|
18
|
+
exports.MINIMAX_PROVIDER_BASE_URL = "https://api.minimaxi.chat/v1";
|
|
12
19
|
function classifyMinimaxError(error) {
|
|
13
20
|
return (0, error_classification_1.classifyHttpError)(error);
|
|
14
21
|
}
|
|
@@ -29,7 +36,7 @@ function createMinimaxProviderRuntime(model) {
|
|
|
29
36
|
(0, model_capabilities_1.getModelCapabilities)(model);
|
|
30
37
|
const client = new openai_1.default({
|
|
31
38
|
apiKey: minimaxConfig.apiKey,
|
|
32
|
-
baseURL:
|
|
39
|
+
baseURL: exports.MINIMAX_PROVIDER_BASE_URL,
|
|
33
40
|
maxRetries: 0,
|
|
34
41
|
});
|
|
35
42
|
return {
|
package/dist/mind/prompt.js
CHANGED
|
@@ -371,7 +371,7 @@ function dateSection() {
|
|
|
371
371
|
return `current date: ${today}`;
|
|
372
372
|
}
|
|
373
373
|
function toolsSection(channel, options, context) {
|
|
374
|
-
const channelTools = (0, tools_1.getToolsForChannel)((0, channel_1.getChannelCapabilities)(channel), undefined, context, options?.providerCapabilities);
|
|
374
|
+
const channelTools = (0, tools_1.getToolsForChannel)((0, channel_1.getChannelCapabilities)(channel), undefined, context, options?.providerCapabilities, undefined, options?.chatModel);
|
|
375
375
|
const activeTools = (options?.toolChoiceRequired ?? true) ? [...channelTools, tools_1.settleTool] : channelTools;
|
|
376
376
|
const list = activeTools
|
|
377
377
|
.map((t) => `- ${t.function.name}: ${t.function.description}`)
|
|
@@ -997,6 +997,9 @@ function rhythmStatusSection(preReadHealth) {
|
|
|
997
997
|
* (edit_file, write_file, shell). Used to gate scrutiny prompts.
|
|
998
998
|
*/
|
|
999
999
|
function channelHasCodingTools(channel, providerCapabilities) {
|
|
1000
|
+
// Coding tools are capability/integration-gated, not vision-gated — passing
|
|
1001
|
+
// undefined for chatModel keeps describe_image out of the scan (which is
|
|
1002
|
+
// BB-scoped anyway) without affecting the coding-tool presence check.
|
|
1000
1003
|
const tools = (0, tools_1.getToolsForChannel)((0, channel_1.getChannelCapabilities)(channel), undefined, undefined, providerCapabilities);
|
|
1001
1004
|
const codingToolNames = new Set(["edit_file", "write_file", "shell", "coding_spawn"]);
|
|
1002
1005
|
return tools.some((t) => codingToolNames.has(t.function.name));
|
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.bluebubblesToolDefinitions = void 0;
|
|
4
4
|
const runtime_1 = require("../nerves/runtime");
|
|
5
|
+
const config_1 = require("../heart/config");
|
|
6
|
+
const minimax_1 = require("../heart/providers/minimax");
|
|
7
|
+
const minimax_vlm_1 = require("../heart/providers/minimax-vlm");
|
|
8
|
+
const attachment_cache_1 = require("../senses/bluebubbles/attachment-cache");
|
|
9
|
+
const media_1 = require("../senses/bluebubbles/media");
|
|
10
|
+
// AX-1: the tool description is the only instruction manual a future agent
|
|
11
|
+
// gets. It must teach WHEN to reach for the tool and HOW to write a good
|
|
12
|
+
// prompt. A type-signature-style "Describes an image." is a violation.
|
|
13
|
+
const DESCRIBE_IMAGE_DESCRIPTION = "Use this tool to look at an attachment the user sent, or to re-interrogate an image with a targeted question. " +
|
|
14
|
+
"The current chat model can't see images natively; this tool runs the image through a vision-language model (VLM) " +
|
|
15
|
+
"and returns a text description. " +
|
|
16
|
+
"Prefer targeted prompts (\"what's the flight number in the bottom-right?\") over generic ones (\"describe this image\") — " +
|
|
17
|
+
"you'll get better answers. " +
|
|
18
|
+
"Call this when the user sent a screenshot and you need to read text from it, verify a detail, or double-check something " +
|
|
19
|
+
"the ingestion-time auto-describe summary might have missed. " +
|
|
20
|
+
"The attachment_guid comes from the attachment marker in the user's most recent message.";
|
|
5
21
|
exports.bluebubblesToolDefinitions = [
|
|
6
22
|
{
|
|
7
23
|
tool: {
|
|
@@ -91,4 +107,95 @@ exports.bluebubblesToolDefinitions = [
|
|
|
91
107
|
},
|
|
92
108
|
summaryKeys: ["target", "threadOriginatorGuid"],
|
|
93
109
|
},
|
|
110
|
+
{
|
|
111
|
+
tool: {
|
|
112
|
+
type: "function",
|
|
113
|
+
function: {
|
|
114
|
+
name: "describe_image",
|
|
115
|
+
description: DESCRIBE_IMAGE_DESCRIPTION,
|
|
116
|
+
parameters: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: {
|
|
119
|
+
attachment_guid: {
|
|
120
|
+
type: "string",
|
|
121
|
+
description: "The guid of the attachment to describe. Usually pulled from the attachment marker in the user's most recent message.",
|
|
122
|
+
},
|
|
123
|
+
prompt: {
|
|
124
|
+
type: "string",
|
|
125
|
+
description: "A targeted question about the image. Prefer specific prompts over generic ones — e.g. 'what's the flight number in the bottom-right?' gives better answers than 'describe this image'.",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: ["attachment_guid", "prompt"],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
handler: async (args) => {
|
|
133
|
+
const attachmentGuid = typeof args.attachment_guid === "string" ? args.attachment_guid.trim() : "";
|
|
134
|
+
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
|
135
|
+
if (!attachmentGuid) {
|
|
136
|
+
(0, runtime_1.emitNervesEvent)({
|
|
137
|
+
level: "warn",
|
|
138
|
+
component: "tools",
|
|
139
|
+
event: "tool.error",
|
|
140
|
+
message: "describe_image missing attachment_guid",
|
|
141
|
+
meta: {},
|
|
142
|
+
});
|
|
143
|
+
return "describe_image: attachment_guid is required — pass the guid from the inbound attachment marker and retry";
|
|
144
|
+
}
|
|
145
|
+
if (!prompt) {
|
|
146
|
+
(0, runtime_1.emitNervesEvent)({
|
|
147
|
+
level: "warn",
|
|
148
|
+
component: "tools",
|
|
149
|
+
event: "tool.error",
|
|
150
|
+
message: "describe_image missing prompt",
|
|
151
|
+
meta: {},
|
|
152
|
+
});
|
|
153
|
+
return "describe_image: prompt is required — supply a targeted question (e.g. 'what's the flight number in the bottom-right?') and retry";
|
|
154
|
+
}
|
|
155
|
+
const summary = (0, attachment_cache_1.lookupBlueBubblesAttachment)(attachmentGuid);
|
|
156
|
+
if (!summary) {
|
|
157
|
+
(0, runtime_1.emitNervesEvent)({
|
|
158
|
+
level: "warn",
|
|
159
|
+
component: "tools",
|
|
160
|
+
event: "tool.error",
|
|
161
|
+
message: "describe_image attachment not found in cache",
|
|
162
|
+
meta: { attachmentGuid },
|
|
163
|
+
});
|
|
164
|
+
return `describe_image: no attachment with guid ${attachmentGuid} found in recent messages — ask the user to resend the image or verify the guid`;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const config = (0, config_1.getBlueBubblesConfig)();
|
|
168
|
+
const channelConfig = (0, config_1.getBlueBubblesChannelConfig)();
|
|
169
|
+
const downloaded = await (0, media_1.downloadBlueBubblesAttachment)(summary, config, channelConfig);
|
|
170
|
+
const mimeType = downloaded.contentType ?? summary.mimeType ?? "image/png";
|
|
171
|
+
const base64 = downloaded.buffer.toString("base64");
|
|
172
|
+
const dataUrl = `data:${mimeType};base64,${base64}`;
|
|
173
|
+
const { apiKey } = (0, config_1.getMinimaxConfig)();
|
|
174
|
+
if (!apiKey) {
|
|
175
|
+
throw new Error("minimax API key not found in secrets.json — re-run credential setup or add a minimax key");
|
|
176
|
+
}
|
|
177
|
+
const description = await (0, minimax_vlm_1.minimaxVlmDescribe)({
|
|
178
|
+
apiKey,
|
|
179
|
+
prompt,
|
|
180
|
+
imageDataUrl: dataUrl,
|
|
181
|
+
baseURL: minimax_1.MINIMAX_PROVIDER_BASE_URL,
|
|
182
|
+
attachmentGuid,
|
|
183
|
+
mimeType,
|
|
184
|
+
});
|
|
185
|
+
return description;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
189
|
+
(0, runtime_1.emitNervesEvent)({
|
|
190
|
+
level: "warn",
|
|
191
|
+
component: "tools",
|
|
192
|
+
event: "tool.error",
|
|
193
|
+
message: "describe_image failed",
|
|
194
|
+
meta: { attachmentGuid, reason },
|
|
195
|
+
});
|
|
196
|
+
return `describe_image failed: ${reason}`;
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
summaryKeys: ["attachment_guid", "prompt"],
|
|
200
|
+
},
|
|
94
201
|
];
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -14,6 +14,7 @@ const tools_github_1 = require("./tools-github");
|
|
|
14
14
|
const runtime_1 = require("../nerves/runtime");
|
|
15
15
|
const guardrails_1 = require("./guardrails");
|
|
16
16
|
const identity_1 = require("../heart/identity");
|
|
17
|
+
const model_capabilities_1 = require("../heart/model-capabilities");
|
|
17
18
|
const tools_surface_1 = require("./tools-surface");
|
|
18
19
|
const mcp_tools_1 = require("./mcp-tools");
|
|
19
20
|
function safeGetAgentRoot() {
|
|
@@ -73,10 +74,17 @@ function filterByCapability(toolList, providerCapabilities) {
|
|
|
73
74
|
// Teams/integration tools are included only if their integration is in availableIntegrations.
|
|
74
75
|
// When toolPreferences is provided, matching preferences are appended to tool descriptions.
|
|
75
76
|
// When providerCapabilities is provided, tools with requiredCapability are filtered.
|
|
76
|
-
|
|
77
|
+
// When chatModel is provided and has `vision: true` in ModelCapabilities, the
|
|
78
|
+
// BlueBubbles-scoped `describe_image` tool is excluded (native image_url
|
|
79
|
+
// pass-through makes the tool redundant). When chatModel is undefined the
|
|
80
|
+
// tool IS included — the default errs toward more tools on an ambiguous caller.
|
|
81
|
+
function getToolsForChannel(capabilities, toolPreferences, _context, providerCapabilities, mcpManager, chatModel) {
|
|
77
82
|
const baseTools = baseToolsForCapabilities();
|
|
83
|
+
const visionCapable = chatModel ? (0, model_capabilities_1.getModelCapabilities)(chatModel).vision === true : false;
|
|
78
84
|
const bluebubblesTools = capabilities?.channel === "bluebubbles"
|
|
79
|
-
? tools_bluebubbles_1.bluebubblesToolDefinitions
|
|
85
|
+
? tools_bluebubbles_1.bluebubblesToolDefinitions
|
|
86
|
+
.filter((d) => d.tool.function.name !== "describe_image" || !visionCapable)
|
|
87
|
+
.map((d) => d.tool)
|
|
80
88
|
: [];
|
|
81
89
|
let result;
|
|
82
90
|
if (!capabilities || capabilities.availableIntegrations.length === 0) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.rememberBlueBubblesAttachment = rememberBlueBubblesAttachment;
|
|
4
|
+
exports.lookupBlueBubblesAttachment = lookupBlueBubblesAttachment;
|
|
5
|
+
exports.resetBlueBubblesAttachmentCache = resetBlueBubblesAttachmentCache;
|
|
6
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
7
|
+
/**
|
|
8
|
+
* Bounded in-memory cache of recently-seen BlueBubbles attachment summaries.
|
|
9
|
+
*
|
|
10
|
+
* Populated at attachment-hydration time so the `describe_image` tool can
|
|
11
|
+
* look up a guid → summary later in the same turn (or a few turns later)
|
|
12
|
+
* and re-download the bytes on demand. Intentionally NOT persisted — per
|
|
13
|
+
* planning doc D4, session storage holds only the VLM description text,
|
|
14
|
+
* never the raw image bytes. The cache is cleared on daemon restart, which
|
|
15
|
+
* matches the product expectation that "describe_image works on recent
|
|
16
|
+
* messages in this session".
|
|
17
|
+
*
|
|
18
|
+
* Bounded at MAX_CACHED_ATTACHMENTS entries; oldest entries evict first
|
|
19
|
+
* when the limit is hit.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_CACHED_ATTACHMENTS = 50;
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
function rememberBlueBubblesAttachment(summary) {
|
|
24
|
+
const guid = summary.guid?.trim();
|
|
25
|
+
if (!guid)
|
|
26
|
+
return;
|
|
27
|
+
// Re-insert to move to end (LRU behavior via Map insertion order).
|
|
28
|
+
if (cache.has(guid))
|
|
29
|
+
cache.delete(guid);
|
|
30
|
+
cache.set(guid, { ...summary });
|
|
31
|
+
while (cache.size > MAX_CACHED_ATTACHMENTS) {
|
|
32
|
+
// cache.size > 0 here, so keys().next().value is always defined.
|
|
33
|
+
const oldestKey = cache.keys().next().value;
|
|
34
|
+
cache.delete(oldestKey);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function lookupBlueBubblesAttachment(guid) {
|
|
38
|
+
const trimmed = guid?.trim();
|
|
39
|
+
if (!trimmed)
|
|
40
|
+
return undefined;
|
|
41
|
+
return cache.get(trimmed);
|
|
42
|
+
}
|
|
43
|
+
function resetBlueBubblesAttachmentCache() {
|
|
44
|
+
cache.clear();
|
|
45
|
+
}
|
|
46
|
+
/* v8 ignore start — module-level observability event */
|
|
47
|
+
(0, runtime_1.emitNervesEvent)({
|
|
48
|
+
component: "senses",
|
|
49
|
+
event: "senses.bluebubbles_attachment_cache_loaded",
|
|
50
|
+
message: "bluebubbles attachment cache module loaded",
|
|
51
|
+
meta: { maxEntries: MAX_CACHED_ATTACHMENTS },
|
|
52
|
+
});
|
|
53
|
+
/* v8 ignore stop */
|
|
@@ -5,6 +5,8 @@ const node_crypto_1 = require("node:crypto");
|
|
|
5
5
|
const config_1 = require("../../heart/config");
|
|
6
6
|
const identity_1 = require("../../heart/identity");
|
|
7
7
|
const runtime_1 = require("../../nerves/runtime");
|
|
8
|
+
const minimax_1 = require("../../heart/providers/minimax");
|
|
9
|
+
const minimax_vlm_1 = require("../../heart/providers/minimax-vlm");
|
|
8
10
|
const model_1 = require("./model");
|
|
9
11
|
const media_1 = require("./media");
|
|
10
12
|
function buildBlueBubblesApiUrl(baseUrl, endpoint, password) {
|
|
@@ -377,6 +379,7 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
|
|
|
377
379
|
return event;
|
|
378
380
|
}
|
|
379
381
|
(0, runtime_1.emitNervesEvent)({
|
|
382
|
+
level: "warn",
|
|
380
383
|
component: "senses",
|
|
381
384
|
event: "senses.bluebubbles_repair_start",
|
|
382
385
|
message: "repairing bluebubbles event by guid",
|
|
@@ -440,8 +443,40 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
|
|
|
440
443
|
if (hydrated.kind === "message" &&
|
|
441
444
|
hydrated.balloonBundleId !== "com.apple.messages.URLBalloonProvider" &&
|
|
442
445
|
hydrated.attachments.length > 0) {
|
|
446
|
+
const agentConfig = (0, identity_1.loadAgentConfig)();
|
|
447
|
+
const chatModel = agentConfig.humanFacing.model;
|
|
448
|
+
const chatProvider = agentConfig.humanFacing.provider;
|
|
449
|
+
const vlmDescribe = async (params) => {
|
|
450
|
+
if (chatProvider !== "minimax") {
|
|
451
|
+
throw new Error("VLM fallback requires a minimax credential for this agent — " +
|
|
452
|
+
"configure one or switch to a vision-capable chat model");
|
|
453
|
+
}
|
|
454
|
+
const { apiKey } = (0, config_1.getMinimaxConfig)();
|
|
455
|
+
if (!apiKey) {
|
|
456
|
+
throw new Error("VLM fallback: minimax API key not found in secrets.json — " +
|
|
457
|
+
"re-run credential setup or ask the user to add a minimax key");
|
|
458
|
+
}
|
|
459
|
+
return (0, minimax_vlm_1.minimaxVlmDescribe)({
|
|
460
|
+
apiKey,
|
|
461
|
+
prompt: params.prompt,
|
|
462
|
+
imageDataUrl: params.imageDataUrl,
|
|
463
|
+
baseURL: minimax_1.MINIMAX_PROVIDER_BASE_URL,
|
|
464
|
+
attachmentGuid: params.attachmentGuid,
|
|
465
|
+
mimeType: params.mimeType,
|
|
466
|
+
chatModel: params.chatModel,
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
// VLM prompt context wants the user's raw inbound text — NOT
|
|
470
|
+
// the agent-facing rendering, which now carries the attachment
|
|
471
|
+
// marker after the B2 fix. Pull it from the source payload.
|
|
472
|
+
const rawUserText = typeof data.text === "string"
|
|
473
|
+
? (data.text).trim()
|
|
474
|
+
: "";
|
|
443
475
|
const media = await (0, media_1.hydrateBlueBubblesAttachments)(hydrated.attachments, config, channelConfig, {
|
|
444
|
-
preferAudioInput: providerSupportsAudioInput(
|
|
476
|
+
preferAudioInput: providerSupportsAudioInput(chatProvider),
|
|
477
|
+
chatModel,
|
|
478
|
+
vlmDescribe,
|
|
479
|
+
userText: rawUserText,
|
|
445
480
|
});
|
|
446
481
|
const transcriptSuffix = media.transcriptAdditions.map((entry) => `[${entry}]`).join("\n");
|
|
447
482
|
const noticeSuffix = media.notices.map((entry) => `[${entry}]`).join("\n");
|
|
@@ -453,6 +488,7 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
|
|
|
453
488
|
};
|
|
454
489
|
}
|
|
455
490
|
(0, runtime_1.emitNervesEvent)({
|
|
491
|
+
level: "warn",
|
|
456
492
|
component: "senses",
|
|
457
493
|
event: "senses.bluebubbles_repair_end",
|
|
458
494
|
message: "bluebubbles event repaired",
|
|
@@ -94,6 +94,7 @@ function recordBlueBubblesInbound(agentName, event, source) {
|
|
|
94
94
|
return filePath;
|
|
95
95
|
}
|
|
96
96
|
(0, runtime_1.emitNervesEvent)({
|
|
97
|
+
level: "warn",
|
|
97
98
|
component: "senses",
|
|
98
99
|
event: "senses.bluebubbles_inbound_logged",
|
|
99
100
|
message: "recorded bluebubbles inbound message to sidecar log",
|
|
@@ -33,6 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.VLM_TEXT_WRAPPERS = void 0;
|
|
37
|
+
exports.downloadBlueBubblesAttachment = downloadBlueBubblesAttachment;
|
|
36
38
|
exports.hydrateBlueBubblesAttachments = hydrateBlueBubblesAttachments;
|
|
37
39
|
const node_child_process_1 = require("node:child_process");
|
|
38
40
|
const fs = __importStar(require("node:fs/promises"));
|
|
@@ -40,6 +42,28 @@ const os = __importStar(require("node:os"));
|
|
|
40
42
|
const path = __importStar(require("node:path"));
|
|
41
43
|
const runtime_1 = require("../../nerves/runtime");
|
|
42
44
|
const identity_1 = require("../../heart/identity");
|
|
45
|
+
const model_capabilities_1 = require("../../heart/model-capabilities");
|
|
46
|
+
const attachment_cache_1 = require("./attachment-cache");
|
|
47
|
+
// Pin the exact wrapper strings so tests and code read from one source.
|
|
48
|
+
exports.VLM_TEXT_WRAPPERS = {
|
|
49
|
+
description: (desc) => `[image description: ${desc}]`,
|
|
50
|
+
unsupported: (mimeType) => `[image attachment not shown: unsupported format ${mimeType}]`,
|
|
51
|
+
failure: (reason) => `[image description failed: ${reason}]`,
|
|
52
|
+
};
|
|
53
|
+
// VLM supported formats for the /v1/coding_plan/vlm endpoint. The client in
|
|
54
|
+
// minimax-vlm.ts enforces the same set — this helper lets the sense skip the
|
|
55
|
+
// call entirely and emit a clearer `vision_format_unsupported` event instead
|
|
56
|
+
// of letting the VLM client throw.
|
|
57
|
+
const VLM_SUPPORTED_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
58
|
+
function isSupportedVlmFormat(contentType) {
|
|
59
|
+
return VLM_SUPPORTED_MIME_TYPES.has(contentType.toLowerCase());
|
|
60
|
+
}
|
|
61
|
+
// D3 prompt template. Included here (not templated out) because the contract
|
|
62
|
+
// is load-bearing — see planning doc section D3 and AX-4.
|
|
63
|
+
function buildVlmPrompt(userText) {
|
|
64
|
+
const body = userText ?? "";
|
|
65
|
+
return `User message: "${body}"\n\nDescribe this image in detail, focusing on anything relevant to what the user said above.\nInclude any text visible in the image verbatim.`;
|
|
66
|
+
}
|
|
43
67
|
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
44
68
|
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".m4a", ".caf", ".ogg"]);
|
|
45
69
|
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"]);
|
|
@@ -66,9 +90,16 @@ const AUDIO_INPUT_FORMAT_BY_EXTENSION = {
|
|
|
66
90
|
const WHISPER_CPP_FORMULA = "whisper-cpp";
|
|
67
91
|
const WHISPER_CPP_MODEL_NAME = "ggml-base.en.bin";
|
|
68
92
|
const WHISPER_CPP_MODEL_URL = `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${WHISPER_CPP_MODEL_NAME}`;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
93
|
+
// Lazy — getAgentToolsRoot() requires identity to be resolved, which isn't
|
|
94
|
+
// true at module-load time in most unit test contexts. Tests that only touch
|
|
95
|
+
// the image/VLM path shouldn't break because the audio path asked for a
|
|
96
|
+
// tools root they don't need.
|
|
97
|
+
function whisperCppPaths() {
|
|
98
|
+
const toolsDir = path.join((0, identity_1.getAgentToolsRoot)(), "whisper-cpp");
|
|
99
|
+
const modelsDir = path.join(toolsDir, "models");
|
|
100
|
+
const modelPath = path.join(modelsDir, WHISPER_CPP_MODEL_NAME);
|
|
101
|
+
return { toolsDir, modelsDir, modelPath };
|
|
102
|
+
}
|
|
72
103
|
function buildBlueBubblesApiUrl(baseUrl, endpoint, password) {
|
|
73
104
|
const root = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
74
105
|
const url = new URL(endpoint.replace(/^\//, ""), root);
|
|
@@ -170,12 +201,13 @@ async function resolveWhisperCppBinary(timeoutMs) {
|
|
|
170
201
|
return candidate;
|
|
171
202
|
}
|
|
172
203
|
async function ensureWhisperCppModel(timeoutMs, fetchImpl) {
|
|
204
|
+
const { modelsDir, modelPath } = whisperCppPaths();
|
|
173
205
|
try {
|
|
174
|
-
await fs.access(
|
|
175
|
-
return
|
|
206
|
+
await fs.access(modelPath);
|
|
207
|
+
return modelPath;
|
|
176
208
|
}
|
|
177
209
|
catch {
|
|
178
|
-
await fs.mkdir(
|
|
210
|
+
await fs.mkdir(modelsDir, { recursive: true });
|
|
179
211
|
const response = await fetchImpl(WHISPER_CPP_MODEL_URL, {
|
|
180
212
|
method: "GET",
|
|
181
213
|
signal: AbortSignal.timeout(Math.max(timeoutMs, 300_000)),
|
|
@@ -183,8 +215,8 @@ async function ensureWhisperCppModel(timeoutMs, fetchImpl) {
|
|
|
183
215
|
if (!response.ok) {
|
|
184
216
|
throw new Error(`failed to download whisper.cpp model: HTTP ${response.status}`);
|
|
185
217
|
}
|
|
186
|
-
await fs.writeFile(
|
|
187
|
-
return
|
|
218
|
+
await fs.writeFile(modelPath, Buffer.from(await response.arrayBuffer()));
|
|
219
|
+
return modelPath;
|
|
188
220
|
}
|
|
189
221
|
}
|
|
190
222
|
async function convertAudioForWhisperCpp(sourcePath, outputPath, timeoutMs) {
|
|
@@ -236,6 +268,9 @@ async function transcribeAudioWithWhisperCpp(params, modelFetchImpl = fetch) {
|
|
|
236
268
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
237
269
|
}
|
|
238
270
|
}
|
|
271
|
+
async function downloadBlueBubblesAttachment(attachment, config, channelConfig, fetchImpl = fetch) {
|
|
272
|
+
return downloadAttachment(attachment, config, channelConfig, fetchImpl);
|
|
273
|
+
}
|
|
239
274
|
async function downloadAttachment(attachment, config, channelConfig, fetchImpl) {
|
|
240
275
|
const guid = attachment.guid?.trim();
|
|
241
276
|
if (!guid) {
|
|
@@ -262,33 +297,114 @@ async function downloadAttachment(attachment, config, channelConfig, fetchImpl)
|
|
|
262
297
|
};
|
|
263
298
|
}
|
|
264
299
|
async function hydrateBlueBubblesAttachments(attachments, config, channelConfig, deps = {}) {
|
|
265
|
-
(0, runtime_1.emitNervesEvent)({
|
|
266
|
-
component: "senses",
|
|
267
|
-
event: "senses.bluebubbles_media_hydrate",
|
|
268
|
-
message: "hydrating bluebubbles attachments",
|
|
269
|
-
meta: {
|
|
270
|
-
attachmentCount: attachments.length,
|
|
271
|
-
preferAudioInput: deps.preferAudioInput ?? false,
|
|
272
|
-
},
|
|
273
|
-
});
|
|
274
300
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
275
301
|
const modelFetchImpl = deps.modelFetchImpl ?? fetch;
|
|
276
302
|
const transcribeAudio = deps.transcribeAudio ?? ((params) => transcribeAudioWithWhisperCpp(params, modelFetchImpl));
|
|
277
303
|
const preferAudioInput = deps.preferAudioInput ?? false;
|
|
304
|
+
const chatModel = deps.chatModel;
|
|
305
|
+
const visionCapable = chatModel ? (0, model_capabilities_1.getModelCapabilities)(chatModel).vision === true : true;
|
|
278
306
|
const inputParts = [];
|
|
279
307
|
const transcriptAdditions = [];
|
|
280
308
|
const notices = [];
|
|
281
309
|
for (const attachment of attachments) {
|
|
282
310
|
const name = describeAttachment(attachment);
|
|
311
|
+
// Remember every attachment we see — the describe_image agent tool looks
|
|
312
|
+
// up guids against this cache later to re-fetch bytes on demand.
|
|
313
|
+
(0, attachment_cache_1.rememberBlueBubblesAttachment)(attachment);
|
|
283
314
|
try {
|
|
284
315
|
const downloaded = await downloadAttachment(attachment, config, channelConfig, fetchImpl);
|
|
285
316
|
const base64 = downloaded.buffer.toString("base64");
|
|
317
|
+
const byteCount = downloaded.buffer.length;
|
|
286
318
|
if (isImageAttachment(attachment, downloaded.contentType)) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
319
|
+
const mimeType = downloaded.contentType ?? "application/octet-stream";
|
|
320
|
+
if (visionCapable) {
|
|
321
|
+
inputParts.push({
|
|
322
|
+
type: "image_url",
|
|
323
|
+
image_url: {
|
|
324
|
+
url: `data:${mimeType};base64,${base64}`,
|
|
325
|
+
detail: "auto",
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
(0, runtime_1.emitNervesEvent)({
|
|
329
|
+
level: "warn",
|
|
330
|
+
component: "senses",
|
|
331
|
+
event: "senses.bluebubbles_media_hydrate",
|
|
332
|
+
message: "bluebubbles media hydrate",
|
|
333
|
+
meta: {
|
|
334
|
+
attachmentGuid: attachment.guid,
|
|
335
|
+
mimeType,
|
|
336
|
+
byteCount,
|
|
337
|
+
hydrationPath: "native-passthrough",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
// Non-vision chat model — try the VLM describe fallback.
|
|
343
|
+
if (!isSupportedVlmFormat(mimeType)) {
|
|
344
|
+
(0, runtime_1.emitNervesEvent)({
|
|
345
|
+
level: "warn",
|
|
346
|
+
component: "senses",
|
|
347
|
+
event: "senses.bluebubbles_vision_format_unsupported",
|
|
348
|
+
message: "bluebubbles vision format unsupported",
|
|
349
|
+
meta: {
|
|
350
|
+
mimeType,
|
|
351
|
+
transferName: attachment.transferName,
|
|
352
|
+
attachmentGuid: attachment.guid,
|
|
353
|
+
chatModel,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
inputParts.push({
|
|
357
|
+
type: "text",
|
|
358
|
+
text: exports.VLM_TEXT_WRAPPERS.unsupported(mimeType),
|
|
359
|
+
});
|
|
360
|
+
(0, runtime_1.emitNervesEvent)({
|
|
361
|
+
level: "warn",
|
|
362
|
+
component: "senses",
|
|
363
|
+
event: "senses.bluebubbles_media_hydrate",
|
|
364
|
+
message: "bluebubbles media hydrate",
|
|
365
|
+
meta: {
|
|
366
|
+
attachmentGuid: attachment.guid,
|
|
367
|
+
mimeType,
|
|
368
|
+
byteCount,
|
|
369
|
+
hydrationPath: "skip-unsupported",
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const dataUrl = `data:${mimeType};base64,${base64}`;
|
|
375
|
+
try {
|
|
376
|
+
if (!deps.vlmDescribe) {
|
|
377
|
+
throw new Error("no VLM describer configured — wire a vlmDescribe dep or configure a vision-capable chat model");
|
|
378
|
+
}
|
|
379
|
+
const description = await deps.vlmDescribe({
|
|
380
|
+
prompt: buildVlmPrompt(deps.userText),
|
|
381
|
+
imageDataUrl: dataUrl,
|
|
382
|
+
attachmentGuid: attachment.guid,
|
|
383
|
+
mimeType,
|
|
384
|
+
chatModel,
|
|
385
|
+
});
|
|
386
|
+
inputParts.push({
|
|
387
|
+
type: "text",
|
|
388
|
+
text: exports.VLM_TEXT_WRAPPERS.description(description),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
catch (vlmError) {
|
|
392
|
+
const reason = vlmError instanceof Error ? vlmError.message : String(vlmError);
|
|
393
|
+
inputParts.push({
|
|
394
|
+
type: "text",
|
|
395
|
+
text: exports.VLM_TEXT_WRAPPERS.failure(reason),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
(0, runtime_1.emitNervesEvent)({
|
|
399
|
+
level: "warn",
|
|
400
|
+
component: "senses",
|
|
401
|
+
event: "senses.bluebubbles_media_hydrate",
|
|
402
|
+
message: "bluebubbles media hydrate",
|
|
403
|
+
meta: {
|
|
404
|
+
attachmentGuid: attachment.guid,
|
|
405
|
+
mimeType,
|
|
406
|
+
byteCount,
|
|
407
|
+
hydrationPath: "vlm-describe",
|
|
292
408
|
},
|
|
293
409
|
});
|
|
294
410
|
continue;
|
|
@@ -127,6 +127,13 @@ function formatMessageText(data, attachments) {
|
|
|
127
127
|
if (balloonBundleId === "com.apple.messages.URLBalloonProvider") {
|
|
128
128
|
return `${text}\n[link preview attached]`;
|
|
129
129
|
}
|
|
130
|
+
// B2 fix: when text and attachments both exist, append the attachment
|
|
131
|
+
// marker so downstream senses can see the guid/filename. Previously the
|
|
132
|
+
// marker was dropped whenever text was non-empty, which hid images from
|
|
133
|
+
// the agent when the user captioned a screenshot.
|
|
134
|
+
if (attachments.length > 0) {
|
|
135
|
+
return `${text}\n${formatAttachmentText(attachments)}`;
|
|
136
|
+
}
|
|
130
137
|
return text;
|
|
131
138
|
}
|
|
132
139
|
return formatAttachmentText(attachments);
|