@ouro.bot/cli 0.1.0-alpha.250 → 0.1.0-alpha.252

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 CHANGED
@@ -1,10 +1,26 @@
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.252",
6
+ "changes": [
7
+ "fix(tui): polished history preview (spacing, shadow color, showing-recent label), full-width separators, backslash+enter and ctrl+j for newlines."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.251",
12
+ "changes": [
13
+ "fix(bluebubbles): images sent via iMessage now reach the model \u2014 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.",
14
+ "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.",
15
+ "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.",
16
+ "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.",
17
+ "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."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.250",
6
22
  "changes": [
7
- "fix(sync): surface 'bundle is not a git repo' as an actionable error instead of silently failing. Previously, enabling `sync.enabled` on a bundle that had never been `git init`'d produced a generic `git status` failure buried in nerves logs; the agent saw nothing in its start-of-turn packet and the user saw nothing in `ouro status`. Now: (1) `preTurnPull` and `postTurnPush` detect the missing `.git` directory before touching git and return an actionable error with the bundle path and the `git init` hint; (2) this error propagates via `ctx.syncFailure` into the agent's Sync warning, so the agent can offer to run `git init` or just do it; (3) `ouro status` shows a red `error` state with `not a git repo — run \\`git init\\` to enable sync` next to the offending bundle. New nerves event: `heart.sync_not_a_repo`."
23
+ "fix(sync): surface 'bundle is not a git repo' as an actionable error instead of silently failing. Previously, enabling `sync.enabled` on a bundle that had never been `git init`'d produced a generic `git status` failure buried in nerves logs; the agent saw nothing in its start-of-turn packet and the user saw nothing in `ouro status`. Now: (1) `preTurnPull` and `postTurnPush` detect the missing `.git` directory before touching git and return an actionable error with the bundle path and the `git init` hint; (2) this error propagates via `ctx.syncFailure` into the agent's Sync warning, so the agent can offer to run `git init` or just do it; (3) `ouro status` shows a red `error` state with `not a git repo \u2014 run \\`git init\\` to enable sync` next to the offending bundle. New nerves event: `heart.sync_not_a_repo`."
8
24
  ]
9
25
  },
10
26
  {
@@ -17,7 +33,7 @@
17
33
  {
18
34
  "version": "0.1.0-alpha.248",
19
35
  "changes": [
20
- "feat(daemon): `ouro status` Git Sync now resolves and shows the actual remote URL via `git remote get-url`. Three states: `origin → git@github.com:me/foo.git` when the remote resolves, `local only` when sync is enabled but no remote is configured, `disabled` when sync is off. Previously you had to `cd` into the bundle and run `git remote -v` to find out where it pushes.",
36
+ "feat(daemon): `ouro status` Git Sync now resolves and shows the actual remote URL via `git remote get-url`. Three states: `origin \u2192 git@github.com:me/foo.git` when the remote resolves, `local only` when sync is enabled but no remote is configured, `disabled` when sync is off. Previously you had to `cd` into the bundle and run `git remote -v` to find out where it pushes.",
21
37
  "fix(heart/sync): `preTurnPull` now skips the pull when no git remote is configured, mirroring the existing `postTurnPush` behavior. Closes the half-implemented 'no-remote sync' (local-only commit log) story \u2014 previously, enabling sync without a remote produced a `syncFailure` on every turn from the failing `git pull` call."
22
38
  ]
23
39
  },
@@ -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: "https://api.minimaxi.chat/v1",
39
+ baseURL: exports.MINIMAX_PROVIDER_BASE_URL,
33
40
  maxRetries: 0,
34
41
  });
35
42
  return {
@@ -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
  ];
@@ -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
- function getToolsForChannel(capabilities, toolPreferences, _context, providerCapabilities, mcpManager) {
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.map((d) => d.tool)
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((0, identity_1.loadAgentConfig)().humanFacing.provider),
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
- const WHISPER_CPP_TOOLS_DIR = path.join((0, identity_1.getAgentToolsRoot)(), "whisper-cpp");
70
- const WHISPER_CPP_MODELS_DIR = path.join(WHISPER_CPP_TOOLS_DIR, "models");
71
- const WHISPER_CPP_MODEL_PATH = path.join(WHISPER_CPP_MODELS_DIR, WHISPER_CPP_MODEL_NAME);
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(WHISPER_CPP_MODEL_PATH);
175
- return WHISPER_CPP_MODEL_PATH;
206
+ await fs.access(modelPath);
207
+ return modelPath;
176
208
  }
177
209
  catch {
178
- await fs.mkdir(WHISPER_CPP_MODELS_DIR, { recursive: true });
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(WHISPER_CPP_MODEL_PATH, Buffer.from(await response.arrayBuffer()));
187
- return WHISPER_CPP_MODEL_PATH;
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
- inputParts.push({
288
- type: "image_url",
289
- image_url: {
290
- url: `data:${downloaded.contentType ?? "application/octet-stream"};base64,${base64}`,
291
- detail: "auto",
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);
@@ -41,10 +41,14 @@ function ringColor(elapsedSec) {
41
41
  return OURO.scale;
42
42
  }
43
43
  // ─── Header ─────────────────────────────────────────────────────────
44
- /** Safe terminal width: capped at 200, with 2-char margin */
44
+ /** Terminal width for content (capped at 200, with 2-char margin) */
45
45
  function safeWidth() {
46
46
  return Math.min(process.stdout.columns || 80, 200) - 2;
47
47
  }
48
+ /** Full terminal width for edge-to-edge elements (separators) */
49
+ function termWidth() {
50
+ return process.stdout.columns || 80;
51
+ }
48
52
  function Header({ agentName, model, contextPercent, cwd }) {
49
53
  const showCtx = contextPercent > 0;
50
54
  const info = [agentName, model, cwd, showCtx ? `ctx ${contextPercent}%` : ""].filter(Boolean).join(" · ");
@@ -81,18 +85,18 @@ function MessageBlock({ msg }) {
81
85
  return (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" });
82
86
  return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: visibleCalls.map((tc, i) => (0, jsx_runtime_1.jsx)(ToolResultLine, { tc: tc }, i)) }));
83
87
  }
84
- // ── Session history (dimmed replay of last exchanges) ──
88
+ // ── Session history (dimmed recap of recent exchanges) ──
85
89
  if (msg.role === "history-summary") {
86
- return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: msg.content }) }));
90
+ return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: msg.content }) }));
87
91
  }
88
92
  if (msg.role === "history-user") {
89
- return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginTop: 1, children: (0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, bold: true, children: ") " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, bold: true, children: msg.content })] }) }));
93
+ return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginTop: 1, children: [(0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, bold: true, children: ") " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, bold: true, children: msg.content })] }), (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
90
94
  }
91
95
  if (msg.role === "history-assistant") {
92
- return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginBottom: 1, children: msg.content ? (0, jsx_runtime_1.jsx)(streaming_markdown_1.StreamingMarkdown, { text: msg.content, maxWidth: safeWidth() }) : null }));
96
+ return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginBottom: 1, children: [msg.content ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: msg.content }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
93
97
  }
94
98
  if (msg.role === "history-end") {
95
- return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(safeWidth() + 2) }) }));
99
+ return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.separator, children: "─".repeat(termWidth()) }) }));
96
100
  }
97
101
  if (msg.role === "user") {
98
102
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginTop: 1, children: [msg.content ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, bold: true, children: msg.content }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
@@ -171,6 +175,23 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
171
175
  const cursorRef = (0, react_1.useRef)(0);
172
176
  const historyIdx = (0, react_1.useRef)(-1);
173
177
  const savedInput = (0, react_1.useRef)("");
178
+ // Raw stdin handler for Alt+Enter (ESC + CR/LF) — Ink 3.2's useInput splits
179
+ // \x1b\r into separate events, so we catch the combined sequence here.
180
+ const { stdin } = (0, ink_1.useStdin)();
181
+ (0, react_1.useEffect)(() => {
182
+ if (!stdin)
183
+ return;
184
+ const onData = (data) => {
185
+ const str = data.toString("utf-8");
186
+ if (str === "\x1b\r" || str === "\x1b\n") {
187
+ const before = inputRef.current.slice(0, cursorRef.current);
188
+ const after = inputRef.current.slice(cursorRef.current);
189
+ updateInput(before + "\n" + after, cursorRef.current + 1);
190
+ }
191
+ };
192
+ stdin.on("data", onData);
193
+ return () => { stdin.off("data", onData); };
194
+ }, [stdin]); // eslint-disable-line react-hooks/exhaustive-deps
174
195
  const tooltipTimerRef = (0, react_1.useRef)(null);
175
196
  (0, react_1.useEffect)(() => () => {
176
197
  if (tooltipTimerRef.current)
@@ -234,13 +255,20 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
234
255
  return;
235
256
  }
236
257
  if (key.return) {
237
- if (key.meta) {
238
- // Alt+Enter: insert newline
258
+ // Alt+Enter or Shift+Enter: insert newline
259
+ if (key.meta || key.shift) {
239
260
  const before = inputRef.current.slice(0, cursorRef.current);
240
261
  const after = inputRef.current.slice(cursorRef.current);
241
262
  updateInput(before + "\n" + after, cursorRef.current + 1);
242
263
  return;
243
264
  }
265
+ // Backslash+Enter: insert newline (like Claude Code)
266
+ if (cursorRef.current > 0 && inputRef.current[cursorRef.current - 1] === "\\") {
267
+ const before = inputRef.current.slice(0, cursorRef.current - 1);
268
+ const after = inputRef.current.slice(cursorRef.current);
269
+ updateInput(before + "\n" + after, cursorRef.current);
270
+ return;
271
+ }
244
272
  const text = inputRef.current;
245
273
  if (text.trim())
246
274
  onSubmit(text);
@@ -376,17 +404,15 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
376
404
  historyIdx.current = -1;
377
405
  }
378
406
  });
379
- // Get terminal width (capped for sanity)
380
- const cols = safeWidth();
381
407
  const isMultiline = input.includes("\n");
382
408
  const inputLines = input.split("\n");
383
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(cols + 2) }), isMultiline ? ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: inputLines.map((line, i) => ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: i === 0 ? ") " : "· " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: line }), i === inputLines.length - 1 ? (cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: "█" }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { children: " " })) : null] }, i))) })) : ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: ") " }), !input && queuedInputs.length > 0 ? ((0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: "Press up to edit queued messages" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(0, cursorPos) }), cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: "█" }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input[cursorPos] ?? " " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(cursorPos + (cursorVisible ? 0 : 1)) })] }))] })), (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(cols + 2) }), (0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsxs)(ink_1.Text, { dimColor: true, children: [" ", agentName, model ? ` · ${model}` : "", " \u00B7 /help"] }), (0, jsx_runtime_1.jsx)(ink_1.Box, { flexGrow: 1 }), tooltip ? (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: tooltip }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "opt+enter for newline" })] })] }));
409
+ return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(termWidth()) }), isMultiline ? ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: inputLines.map((line, i) => ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: i === 0 ? ") " : "· " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: line }), i === inputLines.length - 1 ? (cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: "█" }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { children: " " })) : null] }, i))) })) : ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: ") " }), !input && queuedInputs.length > 0 ? ((0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: "Press up to edit queued messages" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(0, cursorPos) }), cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: "█" }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input[cursorPos] ?? " " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(cursorPos + (cursorVisible ? 0 : 1)) })] }))] })), (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(termWidth()) }), (0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsxs)(ink_1.Text, { dimColor: true, children: [" ", agentName, model ? ` · ${model}` : "", " \u00B7 /help"] }), (0, jsx_runtime_1.jsx)(ink_1.Box, { flexGrow: 1 }), tooltip ? (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: tooltip }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "opt+enter for newline" })] })] }));
384
410
  }
385
411
  // ─── Main TUI Component ─────────────────────────────────────────────
386
412
  function OuroTui({ agentName, model, completedMessages, inputHistory, queuedInputs, live, elapsedSeconds, contextPercent, onSubmit, onCtrlC, onPopQueue, cwd, }) {
387
413
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Static, { items: [{ id: "__header__" }, ...completedMessages], children: (item, index) => {
388
414
  if (index === 0) {
389
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginBottom: 2, children: [(0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(Header, { agentName: agentName, model: model, contextPercent: contextPercent, cwd: cwd }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, dimColor: true, children: " Ctrl-C twice to exit \u00b7 \u2191\u2193 history \u00b7 Esc clear \u00b7 Alt+Enter newline" })] }, "header"));
415
+ return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginBottom: 2, children: [(0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(Header, { agentName: agentName, model: model, contextPercent: contextPercent, cwd: cwd }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, dimColor: true, children: " Ctrl-C twice to exit \u00b7 \u2191\u2193 history \u00b7 Esc clear \u00b7 opt+Enter newline" })] }, "header"));
390
416
  }
391
417
  return (0, jsx_runtime_1.jsx)(MessageBlock, { msg: item }, item.id);
392
418
  } }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, (0, jsx_runtime_1.jsx)(LiveArea, { live: live, elapsed: elapsedSeconds }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, queuedInputs.length > 0 ? (0, jsx_runtime_1.jsx)(QueuedMessages, { items: queuedInputs }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(InputArea, { onSubmit: onSubmit, onCtrlC: onCtrlC, history: inputHistory, queuedInputs: queuedInputs, onPopQueue: onPopQueue, agentName: agentName, model: model })] }));
@@ -546,7 +546,7 @@ async function runCliSession(options) {
546
546
  }
547
547
  const msgCount = messages.filter(m => m.role === "user" || m.role === "assistant").length;
548
548
  const timeAgo = options.lastActivityAt ? formatTimeAgo(new Date(options.lastActivityAt)) : null;
549
- const summary = ` resuming session (${msgCount} messages${timeAgo ? ` · last active ${timeAgo}` : ""})`;
549
+ const summary = ` resuming session · ${msgCount} messages${timeAgo ? ` · last active ${timeAgo}` : ""} · showing recent`;
550
550
  tuiStore.addSessionHistory(summary, lastExchanges);
551
551
  }
552
552
  /* v8 ignore stop */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.250",
3
+ "version": "0.1.0-alpha.252",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",