@ouro.bot/cli 0.1.0-alpha.514 → 0.1.0-alpha.515

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,6 +1,18 @@
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.515",
6
+ "changes": [
7
+ "New `speak` tool — agent can deliver words to the current friend mid-turn without ending the turn. Pairs with `settle` (ends turn) and `ponder` (private inner thought). For acknowledgment of heavy work, phase-boundary updates, or progress narration on chat-style channels (cli, teams, bluebubbles).",
8
+ "Schema is intentionally minimal: `speak({ message: string })`. Not sole-call, doesn't terminate the turn, NOT exempt from the 24-call circuit breaker (the breaker is healthy backpressure against narration-spam — silence is a natural fallback for speak, unlike settle/rest). Added `flushNow?(): void | Promise<void>` to `ChannelCallbacks`; per-sense impls deliver the buffered message immediately (CLI noop, BlueBubbles `client.sendText` keeping typing on, Teams stream emit with `sendMessage` fallback).",
9
+ "Engine integration follows the `ponder` interception template at `core.ts:~1303`: `speak` runs inline (emit + flushNow + push `(spoken)` tool result + nerves event `engine.speak`), then the loop continues. Empty/missing message rejected with a tool-result error and `engine.speak_invalid`. New event keys: `engine.speak`, `engine.speak_invalid`, `engine.speak_delivery_failed`, `bluebubbles.speak_flush`, `teams.speak_flush`.",
10
+ "System prompt nudge in Group #4 (`how i work`), gated to chat-style channels: dependency boundary (settle if next step needs a reply, otherwise speak), phase boundaries (after acking heavy ask / hitting major constraint / switching strategy / before externally-visible step — not per-tool narration), one-way framing (speak is progress, not invitation).",
11
+ "Hardened `speak` delivery semantics (slugger PR review fix). `flushNow` contract is now explicit: throws if the message could not be delivered through any available path. Teams `flushNow` THROWS when both stream emit AND `sendMessage` fallback fail (was silently logging delivered=false and returning normally — engine then recorded `(spoken)` even though nothing reached the friend). BlueBubbles `flushNow` already let `client.sendText` rejections propagate; contract documented. Engine wraps `await flushNow()` in try/catch: on hard failure it calls `onToolEnd('speak', ..., false)`, pushes a `'speak delivery failed: ... did not reach your friend; do not assume they saw it'` tool result, emits `engine.speak_delivery_failed` (level=error), and the turn continues — preventing the agent from assuming silent success.",
12
+ "`speak` is now treated as flow-control across all senses (slugger PR review fix). Like settle/observe/ponder/rest, its only visible output is the message itself — no spinner, no phrase rotation, no `⏳` placeholder, no tool-activity status line. Added 'speak' to `FLOW_CONTROL_TOOLS` in `cli/tool-display.ts` and `cli/ouro-tui.tsx`; CLI/BlueBubbles/Teams `onToolStart` early-return for speak; `tool-description.ts` returns null for speak as defense-in-depth for any future sense using `createToolActivityCallbacks`. Teams `flushNow` also stops phrase rotation when it delivers, so the actual message replaces the cycling 'thinking...' phrase.",
13
+ "Teams `flushNow` no longer aborts the turn on a successful sendMessage fallback (slugger PR review fix). Prior code path: stream emit fails → `tryEmit` calls `markStopped()` which calls `controller.abort()` → falls through to `sendMessage` → succeeds → `flushNow` returns normally → core records `(spoken)` with success=true — but the turn controller is already aborted, so the next model/tool step aborts. Successful fallback delivery should not poison the rest of the turn. Fix adds a non-aborting `tryEmitNoAbort` variant adjacent to `tryEmit`; `flushNow` uses it so a primary-stream failure followed by a successful sendMessage no longer triggers `controller.abort()`. Only when ALL delivery paths fail does `flushNow` call `markStopped()` and throw, letting the engine's existing `engine.speak_delivery_failed` catch path end the turn cleanly. `tryEmit` and other non-flushNow callers (end-of-turn `flush()`, `safeEmit`) are unchanged — their abort-on-failure behavior remains correct because they have no fallback path forward."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.514",
6
18
  "changes": [
@@ -6,6 +6,7 @@ exports.getModel = getModel;
6
6
  exports.getProvider = getProvider;
7
7
  exports.createSummarize = createSummarize;
8
8
  exports.getProviderDisplayLabel = getProviderDisplayLabel;
9
+ exports.isChatStyleChannel = isChatStyleChannel;
9
10
  exports.isExternalStateQuery = isExternalStateQuery;
10
11
  exports.getSettleRetryError = getSettleRetryError;
11
12
  exports.stripLastToolCalls = stripLastToolCalls;
@@ -211,6 +212,12 @@ function hasFreshPendingWork(options) {
211
212
  return pendingMessages.some((message) => typeof message?.content === "string"
212
213
  && message.content.trim().length > 0);
213
214
  }
215
+ /** Chat-style channels expose the `speak` tool — outer human-conversation channels
216
+ * where mid-turn delivery is meaningful. Inner dialog has `ponder`. MCP returns
217
+ * synchronously. Mail is batch. Anything else (unknown channel) treats as non-chat. */
218
+ function isChatStyleChannel(channel) {
219
+ return channel === "cli" || channel === "teams" || channel === "bluebubbles";
220
+ }
214
221
  // Sole-call tools must be the only tool call in a turn. When they appear
215
222
  // alongside other tools, the sole-call tool is rejected with this message.
216
223
  const SOLE_CALL_REJECTION = {
@@ -672,6 +679,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
672
679
  ...(isInnerDialog ? [tools_2.surfaceToolDef, tools_1.restTool] : []),
673
680
  ...(!isInnerDialog ? [tools_1.observeTool] : []),
674
681
  ...(!isInnerDialog ? [tools_1.settleTool] : []),
682
+ ...(isChatStyleChannel(channel ?? "") ? [tools_1.speakTool] : []),
675
683
  ];
676
684
  const steeringFollowUps = options?.drainSteeringFollowUps?.() ?? [];
677
685
  if (steeringFollowUps.length > 0) {
@@ -1074,6 +1082,63 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1074
1082
  if (tc.name === "send_message" && args.friendId === "self") {
1075
1083
  sawSendMessageSelf = true;
1076
1084
  }
1085
+ if (tc.name === "speak") {
1086
+ let speakArgs = {};
1087
+ try {
1088
+ speakArgs = JSON.parse(tc.arguments);
1089
+ }
1090
+ catch { /* malformed */ }
1091
+ const speakMessage = typeof speakArgs.message === "string" ? speakArgs.message : "";
1092
+ const argSummary = (0, tools_1.summarizeArgs)("speak", { message: speakMessage });
1093
+ callbacks.onToolStart("speak", { message: speakMessage });
1094
+ if (speakMessage.trim().length === 0) {
1095
+ const err = "speak requires a non-empty `message` string.";
1096
+ callbacks.onToolEnd("speak", argSummary, false);
1097
+ messages.push({ role: "tool", tool_call_id: tc.id, content: err });
1098
+ providerRuntime.appendToolOutput(tc.id, err);
1099
+ (0, runtime_1.emitNervesEvent)({
1100
+ level: "warn",
1101
+ component: "engine",
1102
+ event: "engine.speak_invalid",
1103
+ message: "speak rejected: missing or empty message",
1104
+ meta: {},
1105
+ });
1106
+ continue;
1107
+ }
1108
+ callbacks.onTextChunk(speakMessage);
1109
+ let speakDeliveryError = null;
1110
+ try {
1111
+ await callbacks.flushNow?.();
1112
+ }
1113
+ catch (err) {
1114
+ speakDeliveryError = err instanceof Error ? err : new Error(String(err));
1115
+ }
1116
+ if (speakDeliveryError) {
1117
+ callbacks.onToolEnd("speak", argSummary, false);
1118
+ const failMsg = `speak delivery failed: ${speakDeliveryError.message}. the message did not reach your friend; do not assume they saw it.`;
1119
+ messages.push({ role: "tool", tool_call_id: tc.id, content: failMsg });
1120
+ providerRuntime.appendToolOutput(tc.id, failMsg);
1121
+ (0, runtime_1.emitNervesEvent)({
1122
+ level: "error",
1123
+ component: "engine",
1124
+ event: "engine.speak_delivery_failed",
1125
+ message: "speak delivery failed",
1126
+ meta: { error: speakDeliveryError.message, messageLength: speakMessage.length },
1127
+ });
1128
+ continue;
1129
+ }
1130
+ callbacks.onToolEnd("speak", argSummary, true);
1131
+ const ack = "(spoken)";
1132
+ messages.push({ role: "tool", tool_call_id: tc.id, content: ack });
1133
+ providerRuntime.appendToolOutput(tc.id, ack);
1134
+ (0, runtime_1.emitNervesEvent)({
1135
+ component: "engine",
1136
+ event: "engine.speak",
1137
+ message: "agent spoke mid-turn",
1138
+ meta: { messageLength: speakMessage.length },
1139
+ });
1140
+ continue;
1141
+ }
1077
1142
  if (tc.name === "ponder") {
1078
1143
  const parsedArgs = normalizeLegacyPonderArgs(parsePonderPayload(tc.arguments));
1079
1144
  const argSummary = (0, tools_1.summarizeArgs)(tc.name, parsedArgs);
@@ -114,6 +114,10 @@ const TOOL_DESCRIPTIONS = {
114
114
  settle: () => null,
115
115
  rest: () => null,
116
116
  descend: () => null,
117
+ // speak's visible output is the message itself, delivered via onTextChunk +
118
+ // flushNow. The shared tool-activity callbacks must skip it so no per-sense
119
+ // tool-status text precedes the actual message.
120
+ speak: () => null,
117
121
  };
118
122
  function humanReadableToolDescription(name, args) {
119
123
  (0, runtime_1.emitNervesEvent)({
@@ -46,6 +46,7 @@ exports.commitmentsSection = commitmentsSection;
46
46
  exports.delegationHintSection = delegationHintSection;
47
47
  exports.workspaceDisciplineSection = workspaceDisciplineSection;
48
48
  exports.ponderPacketSopsSection = ponderPacketSopsSection;
49
+ exports.speakSopsSection = speakSopsSection;
49
50
  exports.contextSection = contextSection;
50
51
  exports.metacognitiveFramingSection = metacognitiveFramingSection;
51
52
  exports.readJournalFiles = readJournalFiles;
@@ -991,6 +992,20 @@ function ponderPacketSopsSection() {
991
992
  - research: investigate the bounded question, gather evidence, and surface the answer or concrete artifact.
992
993
  - reflection: ordinary private thinking with no engineering workflow implied.`;
993
994
  }
995
+ function speakSopsSection(channel) {
996
+ const isChatStyle = channel === "cli" || channel === "teams" || channel === "bluebubbles";
997
+ if (!isChatStyle)
998
+ return "";
999
+ return [
1000
+ "## speaking mid-turn",
1001
+ "",
1002
+ "i have a `speak` tool that sends words to my friend without ending my turn. i use it to keep my friend in the loop while i'm doing real work.",
1003
+ "",
1004
+ "- if my next step depends on a reply, i settle. otherwise, i speak.",
1005
+ "- i speak at phase boundaries during heavy work — after acking a heavy ask, after hitting a major constraint, before switching strategy, before a long externally-visible step. i don't narrate individual tool calls.",
1006
+ "- speak is progress, not invitation. my friend won't steer me mid-turn after i speak — if i need steering, i settle.",
1007
+ ].join("\n");
1008
+ }
994
1009
  function contextSection(context, options) {
995
1010
  if (!context)
996
1011
  return "";
@@ -1311,6 +1326,7 @@ async function buildSystem(channel = "cli", options, context) {
1311
1326
  "# how i work",
1312
1327
  workspaceDisciplineSection(),
1313
1328
  ponderPacketSopsSection(),
1329
+ speakSopsSection(channel),
1314
1330
  (0, scrutiny_1.preImplementationScrutinySection)(channelHasCodingTools(channel, options?.providerCapabilities)),
1315
1331
  toolRestrictionSection(context),
1316
1332
  loopOrientationSection(channel),
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.tools = exports.baseToolDefinitions = exports.editFileReadTracker = exports.renderInnerProgressStatus = exports.restTool = exports.settleTool = exports.observeTool = exports.ponderTool = void 0;
3
+ exports.tools = exports.baseToolDefinitions = exports.editFileReadTracker = exports.renderInnerProgressStatus = exports.speakTool = exports.restTool = exports.settleTool = exports.observeTool = exports.ponderTool = void 0;
4
4
  const tools_files_1 = require("./tools-files");
5
5
  const tools_shell_1 = require("./tools-shell");
6
6
  const tools_notes_1 = require("./tools-notes");
@@ -24,6 +24,7 @@ Object.defineProperty(exports, "ponderTool", { enumerable: true, get: function (
24
24
  Object.defineProperty(exports, "observeTool", { enumerable: true, get: function () { return tools_flow_1.observeTool; } });
25
25
  Object.defineProperty(exports, "settleTool", { enumerable: true, get: function () { return tools_flow_1.settleTool; } });
26
26
  Object.defineProperty(exports, "restTool", { enumerable: true, get: function () { return tools_flow_1.restTool; } });
27
+ Object.defineProperty(exports, "speakTool", { enumerable: true, get: function () { return tools_flow_1.speakTool; } });
27
28
  // Re-export renderInnerProgressStatus for consumers
28
29
  var tools_session_2 = require("./tools-session");
29
30
  Object.defineProperty(exports, "renderInnerProgressStatus", { enumerable: true, get: function () { return tools_session_2.renderInnerProgressStatus; } });
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.restTool = exports.settleTool = exports.observeTool = exports.ponderTool = void 0;
3
+ exports.restTool = exports.speakTool = exports.settleTool = exports.observeTool = exports.ponderTool = void 0;
4
4
  exports.ponderTool = {
5
5
  type: "function",
6
6
  function: {
@@ -83,6 +83,20 @@ exports.settleTool = {
83
83
  },
84
84
  },
85
85
  };
86
+ exports.speakTool = {
87
+ type: "function",
88
+ function: {
89
+ name: "speak",
90
+ description: "i speak to send words to my friend mid-turn without ending it. for progress, acknowledgment, or phase-boundary updates during heavy work. i settle when my work is done or i need a reply. speak is one-way: my friend cannot steer me mid-turn after i speak.",
91
+ parameters: {
92
+ type: "object",
93
+ properties: {
94
+ message: { type: "string", description: "the words i'm sending to my friend right now" },
95
+ },
96
+ required: ["message"],
97
+ },
98
+ },
99
+ };
86
100
  exports.restTool = {
87
101
  type: "function",
88
102
  function: {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.surfaceToolDef = exports.restTool = exports.ponderTool = exports.observeTool = exports.settleTool = exports.tools = void 0;
3
+ exports.surfaceToolDef = exports.speakTool = exports.restTool = exports.ponderTool = exports.observeTool = exports.settleTool = exports.tools = void 0;
4
4
  exports.resetMcpDefinitions = resetMcpDefinitions;
5
5
  exports.getToolsForChannel = getToolsForChannel;
6
6
  exports.execTool = execTool;
@@ -32,6 +32,7 @@ Object.defineProperty(exports, "settleTool", { enumerable: true, get: function (
32
32
  Object.defineProperty(exports, "observeTool", { enumerable: true, get: function () { return tools_base_2.observeTool; } });
33
33
  Object.defineProperty(exports, "ponderTool", { enumerable: true, get: function () { return tools_base_2.ponderTool; } });
34
34
  Object.defineProperty(exports, "restTool", { enumerable: true, get: function () { return tools_base_2.restTool; } });
35
+ Object.defineProperty(exports, "speakTool", { enumerable: true, get: function () { return tools_base_2.speakTool; } });
35
36
  // Re-export surface tool schema for consumers (e.g. heart/core.ts)
36
37
  var tools_surface_2 = require("./tools-surface");
37
38
  Object.defineProperty(exports, "surfaceToolDef", { enumerable: true, get: function () { return tools_surface_2.surfaceToolDef; } });
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.enrichReactionText = enrichReactionText;
37
37
  exports.createStatusBatcher = createStatusBatcher;
38
+ exports.createBlueBubblesCallbacks = createBlueBubblesCallbacks;
38
39
  exports.isAgentSelfHandle = isAgentSelfHandle;
39
40
  exports.getDiscoveredOwnHandles = getDiscoveredOwnHandles;
40
41
  exports.clearDiscoveredOwnHandles = clearDiscoveredOwnHandles;
@@ -514,7 +515,12 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
514
515
  },
515
516
  onReasoningChunk(_text) { },
516
517
  onToolStart(name, _args) {
517
- if (name === "observe") {
518
+ // observe + speak are flow-control: their visible output (or lack of it) is
519
+ // handled outside the tool-activity callbacks. speak in particular delivers
520
+ // its message via onTextChunk/flushNow — we MUST NOT enqueue a "speaking..."
521
+ // status sendText here, which would arrive as a separate iMessage right
522
+ // before the actual speak content.
523
+ if (name === "observe" || name === "speak") {
518
524
  (0, runtime_1.emitNervesEvent)({
519
525
  component: "senses",
520
526
  event: "senses.bluebubbles_tool_start",
@@ -535,7 +541,8 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
535
541
  });
536
542
  },
537
543
  onToolEnd(name, summary, success) {
538
- if (name !== "observe") {
544
+ // observe + speak skip the tool-activity end callback (no ✓/✗ status sent).
545
+ if (name !== "observe" && name !== "speak") {
539
546
  toolCallbacks.onToolEnd(name, summary, success);
540
547
  }
541
548
  (0, runtime_1.emitNervesEvent)({
@@ -558,6 +565,29 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
558
565
  onClearText() {
559
566
  textBuffer = "";
560
567
  },
568
+ async flushNow() {
569
+ // Contract: throws if delivery fails. We deliberately let `client.sendText`
570
+ // rejections propagate so the engine's speak interception can mark the
571
+ // tool call as failed and tell the agent the message did not reach the
572
+ // friend (rather than silently logging and pretending success).
573
+ const trimmed = textBuffer.trim();
574
+ if (!trimmed)
575
+ return;
576
+ textBuffer = "";
577
+ await client.sendText({
578
+ chat,
579
+ text: trimmed,
580
+ replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
581
+ });
582
+ // Note: do NOT call client.setTyping(chat, false) here — the agent is
583
+ // still mid-turn, so the typing indicator stays ACTIVE.
584
+ (0, runtime_1.emitNervesEvent)({
585
+ component: "senses",
586
+ event: "bluebubbles.speak_flush",
587
+ message: "bluebubbles flushed mid-turn speak",
588
+ meta: { messageLength: trimmed.length },
589
+ });
590
+ },
561
591
  async flush() {
562
592
  statusBatcher.flush();
563
593
  await queue;
@@ -73,8 +73,10 @@ function Header({ agentName, model, contextPercent, cwd, resumeInfo }) {
73
73
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line1 }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line2 }), (0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.scale, children: [TAIL3, (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.glow, children: line3text }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: HEAD3 })] }), resumeInfo ? ((0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.teal, children: [" resuming \u00b7 ", resumeInfo.messageCount, " messages \u00b7 last active ", resumeInfo.timeAgo] })) : null] }));
74
74
  }
75
75
  // ─── Message Rendering ──────────────────────────────────────────────
76
- // Flow control tools are invisible to the user — they are internal agent mechanics
77
- const FLOW_CONTROL_TOOLS = new Set(["settle", "ponder", "observe", "rest"]);
76
+ // Flow control tools are invisible to the user — they are internal agent mechanics.
77
+ // `speak` is included because its visible output is the message itself (rendered as
78
+ // regular assistant text), not a tool-result line or in-progress activity indicator.
79
+ const FLOW_CONTROL_TOOLS = new Set(["settle", "ponder", "observe", "rest", "speak"]);
78
80
  function ToolResultLine({ tc }) {
79
81
  const icon = tc.success !== false ? "✓" : "✗";
80
82
  const iconColor = tc.success !== false ? OURO.scale : OURO.fang;
@@ -13,8 +13,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.writeToolStart = writeToolStart;
14
14
  exports.writeToolEnd = writeToolEnd;
15
15
  const runtime_1 = require("../../nerves/runtime");
16
- // Flow control tools are invisible to the user — internal agent mechanics
17
- const FLOW_CONTROL_TOOLS = new Set(["settle", "ponder", "observe", "rest"]);
16
+ // Flow control tools are invisible to the user — internal agent mechanics.
17
+ // `speak` is included because its visible output is the message itself (delivered
18
+ // via onTextChunk/flushNow), not a "running speak..." spinner or tool-end status line.
19
+ const FLOW_CONTROL_TOOLS = new Set(["settle", "ponder", "observe", "rest", "speak"]);
18
20
  // Ouroboros teal: #4ec9b0 -> RGB escape
19
21
  const OURO_TEAL = "\x1b[38;2;78;201;176m";
20
22
  const GREEN = "\x1b[32m";
@@ -353,11 +353,20 @@ function createCliCallbacks() {
353
353
  /* v8 ignore stop */
354
354
  textDirty = text.length > 0 && !text.endsWith("\n");
355
355
  },
356
+ flushNow: () => {
357
+ // CLI flushes immediately on each onTextChunk; nothing buffered to push.
358
+ },
356
359
  onReasoningChunk: (_text) => {
357
360
  // Keep reasoning private in the CLI surface. The spinner continues to
358
361
  // represent active thinking until actual tool or answer output arrives.
359
362
  },
360
363
  onToolStart: (_name, _args) => {
364
+ // speak is flow-control: its visible output is the message itself (delivered
365
+ // via onTextChunk/flushNow). Do NOT start a tool spinner — that would write
366
+ // a "running speak..." phrase to stderr right before the actual message
367
+ // arrives, which is the visual churn the user explicitly does not want.
368
+ if (_name === "speak")
369
+ return;
361
370
  // Stop the model-start spinner: when the model returns only tool calls
362
371
  // (no content/reasoning), onModelStreamStart never fires, so the old
363
372
  // spinner's intervals would leak.
@@ -290,6 +290,26 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
290
290
  return false;
291
291
  }
292
292
  }
293
+ // Non-aborting awaitable emit — returns true on success, false on failure WITHOUT
294
+ // calling markStopped() / aborting the controller. Used by flushNow (speak) so a
295
+ // primary-stream failure followed by a successful sendMessage fallback does NOT
296
+ // poison the rest of the turn. tryEmit's abort-on-failure behavior is correct for
297
+ // end-of-turn flush() (no fallback path forward) but wrong for mid-turn speak,
298
+ // which has a sendMessage fallback that may still succeed. Caller (flushNow) is
299
+ // responsible for the `!stopped` precondition; no defensive guard here.
300
+ async function tryEmitNoAbort(text) {
301
+ try {
302
+ const result = stream.emit({ text, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
303
+ streamHasContent = true;
304
+ if (result && typeof result.then === "function") {
305
+ await result;
306
+ }
307
+ return true;
308
+ }
309
+ catch {
310
+ return false;
311
+ }
312
+ }
293
313
  // Safely send a status update to the stream.
294
314
  // On error (e.g. 403 from Teams stop button), abort the controller.
295
315
  function safeUpdate(text) {
@@ -427,6 +447,56 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
427
447
  onClearText: () => {
428
448
  textBuffer = "";
429
449
  },
450
+ flushNow: async () => {
451
+ const trimmed = textBuffer.trim();
452
+ if (!trimmed)
453
+ return;
454
+ // Cancel pending periodic flush — we're delivering now.
455
+ stopFlushTimer();
456
+ // The actual speak message replaces any "thinking..." phrase cycling.
457
+ stopPhraseRotation();
458
+ // Bypass MIN_INITIAL_CHARS threshold — speak delivers immediately.
459
+ firstContentEmitted = true;
460
+ textBuffer = "";
461
+ // Try the stream first via the NON-ABORTING variant; on failure, fall back
462
+ // to sendMessage. Critical: do NOT call markStopped() / abort the controller
463
+ // when only the primary stream fails — the sendMessage fallback may still
464
+ // deliver the speak, and a successful fallback must not poison the rest of
465
+ // the turn. Only abort when ALL delivery paths fail (handled below).
466
+ // Contract: throws if the message could not be delivered through any available path.
467
+ let delivered = false;
468
+ let lastError = null;
469
+ if (!stopped) {
470
+ const ok = await tryEmitNoAbort(trimmed);
471
+ if (ok)
472
+ delivered = true;
473
+ else
474
+ lastError = new Error("stream emit failed");
475
+ }
476
+ if (!delivered && sendMessage) {
477
+ try {
478
+ await sendMessage(trimmed);
479
+ delivered = true;
480
+ lastError = null;
481
+ }
482
+ catch (err) {
483
+ lastError = err instanceof Error ? err : new Error(String(err));
484
+ }
485
+ }
486
+ (0, runtime_1.emitNervesEvent)({
487
+ component: "senses",
488
+ event: "teams.speak_flush",
489
+ message: "teams flushed mid-turn speak",
490
+ meta: { messageLength: trimmed.length, delivered },
491
+ });
492
+ if (!delivered) {
493
+ // All delivery paths exhausted — now it is correct to abort the turn.
494
+ // markStopped() halts further stream activity and aborts the controller
495
+ // so the engine catches up and ends the turn cleanly.
496
+ markStopped();
497
+ throw new Error(`teams speak delivery failed: ${lastError?.message ?? "no fallback available"}`);
498
+ }
499
+ },
430
500
  ...(() => {
431
501
  const toolCbs = (0, tool_activity_callbacks_1.createToolActivityCallbacks)({
432
502
  onDescription: (text) => safeUpdate(text),
@@ -438,6 +508,13 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
438
508
  });
439
509
  return {
440
510
  onToolStart: (name, args) => {
511
+ // speak is flow-control: its visible output is the message itself
512
+ // (delivered via onTextChunk + flushNow). Do NOT stop phrase rotation
513
+ // here, do NOT emit the \u23f3 placeholder, do NOT post a tool-activity
514
+ // status update \u2014 all of those would create UI churn right before the
515
+ // actual speak content arrives.
516
+ if (name === "speak")
517
+ return;
441
518
  stopPhraseRotation();
442
519
  // Force-flush any accumulated text, bypassing MIN_INITIAL_CHARS threshold
443
520
  firstContentEmitted = true;
@@ -453,6 +530,12 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
453
530
  hadToolRun = true;
454
531
  },
455
532
  onToolEnd: (name, summary, success) => {
533
+ // speak is flow-control: skip phrase-rotation stop and tool-activity end
534
+ // callback (no safeUpdate for \u2713/\u2717). The flushNow call inside the engine
535
+ // already emitted the actual message and stopped any rotation as part of
536
+ // tryEmit's first-content-emitted flag.
537
+ if (name === "speak")
538
+ return;
456
539
  stopPhraseRotation();
457
540
  toolCbs.onToolEnd(name, summary, success);
458
541
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.514",
3
+ "version": "0.1.0-alpha.515",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",