@ouro.bot/cli 0.1.0-alpha.145 → 0.1.0-alpha.147

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,24 @@
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.147",
6
+ "changes": [
7
+ "Feedback signals as social cues: Teams thumbs-up/down and BlueBubbles reactions reach the agent as first-class social signals with full cognitive tools.",
8
+ "AI-generated labels: all outbound Teams messages include AIGeneratedContent entity and feedbackLoopEnabled for native AI badge and feedback UI.",
9
+ "BlueBubbles reaction enrichment: reactions include original message text (truncated to 80 chars) via best-effort BB API lookup.",
10
+ "Streaming refinements: hybrid MIN_INITIAL_CHARS (phrase rotation until 20+ real chars), proactive >4000 finalization with follow-up messages.",
11
+ "Welcome card: Adaptive Card with prompt starters on Teams bot install.",
12
+ "Observe gate: available in 1:1 chats for reaction signals (natural silence case).",
13
+ "Per-context prompt framing: 1:1 vs group framing for feedback signal processing."
14
+ ]
15
+ },
16
+ {
17
+ "version": "0.1.0-alpha.146",
18
+ "changes": [
19
+ "fix(hook): target specific agent only, ouro up breaks dev dispatch loop"
20
+ ]
21
+ },
4
22
  {
5
23
  "version": "0.1.0-alpha.145",
6
24
  "changes": [
@@ -539,7 +539,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
539
539
  ...filteredBaseTools,
540
540
  tools_1.ponderTool,
541
541
  ...(isInnerDialog ? [surface_tool_1.surfaceToolDef, tools_1.restTool] : []),
542
- ...(currentContext?.isGroupChat && !isInnerDialog ? [tools_1.observeTool] : []),
542
+ ...((currentContext?.isGroupChat || options?.isReactionSignal) && !isInnerDialog ? [tools_1.observeTool] : []),
543
543
  ...(!isInnerDialog ? [tools_1.settleTool] : []),
544
544
  ];
545
545
  const steeringFollowUps = options?.drainSteeringFollowUps?.() ?? [];
@@ -725,7 +725,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
725
725
  (0, runtime_1.emitNervesEvent)({
726
726
  component: "engine",
727
727
  event: "engine.observe",
728
- message: "agent declined to respond in group chat",
728
+ message: "agent observed without responding",
729
729
  meta: { ...(reason ? { reason } : {}) },
730
730
  });
731
731
  callbacks.onToolEnd("observe", (0, tools_1.summarizeArgs)("observe", observeArgs), true);
@@ -947,10 +947,25 @@ function parseOuroCommand(args) {
947
947
  return parseOuroCommand(args.slice(2));
948
948
  }
949
949
  if (head === "hook") {
950
- const event = second;
950
+ const hookArgs = args.slice(1);
951
+ let event;
952
+ let hookAgent;
953
+ for (let i = 0; i < hookArgs.length; i++) {
954
+ if (hookArgs[i] === "--agent" && hookArgs[i + 1]) {
955
+ hookAgent = hookArgs[++i];
956
+ continue;
957
+ }
958
+ /* v8 ignore start -- false branch: extra positional args after event are ignored */
959
+ if (!event) {
960
+ event = hookArgs[i];
961
+ }
962
+ /* v8 ignore stop */
963
+ }
951
964
  if (!event)
952
965
  throw new Error("hook requires an event name (session-start, stop, post-tool-use)");
953
- return { kind: "hook", event };
966
+ if (!hookAgent)
967
+ throw new Error("hook requires --agent <name>");
968
+ return { kind: "hook", event, agent: hookAgent };
954
969
  }
955
970
  if (head === "up")
956
971
  return { kind: "daemon.up" };
@@ -1987,6 +2002,16 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
1987
2002
  meta: { kind: command.kind },
1988
2003
  });
1989
2004
  if (command.kind === "daemon.up") {
2005
+ // ── dev mode cleanup: delete dev-config.json so the wrapper stops dispatching to dev repo ──
2006
+ /* v8 ignore start -- dev-config cleanup: requires real filesystem state @preserve */
2007
+ try {
2008
+ const devConfigPath = getDevConfigPath();
2009
+ if (fs.existsSync(devConfigPath)) {
2010
+ fs.unlinkSync(devConfigPath);
2011
+ }
2012
+ }
2013
+ catch { /* best effort */ }
2014
+ /* v8 ignore stop */
1990
2015
  // ── dev mode delegation: ouro up from a dev repo delegates to installed binary ──
1991
2016
  // Only runs when detectMode is explicitly injected (via createDefaultOuroCliDeps or tests)
1992
2017
  if (deps.detectMode) {
@@ -2336,15 +2361,10 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
2336
2361
  else {
2337
2362
  content = `[Claude Code hook: ${eventType} in session ${sessionId}]`;
2338
2363
  }
2339
- // Send to daemon (best effort — daemon may not be running)
2364
+ // Send to the specific agent configured for this hook
2340
2365
  try {
2341
- const agents = require("fs").readdirSync(path.join(os.homedir(), "AgentBundles"))
2342
- .filter((f) => f.endsWith(".ouro"))
2343
- .map((f) => f.replace(".ouro", ""));
2344
- for (const agent of agents) {
2345
- await deps.sendCommand(deps.socketPath, { kind: "message.send", from: `claude-code:${sessionId}`, to: agent, content });
2346
- }
2347
- await deps.sendCommand(deps.socketPath, { kind: "inner.wake", agent: agents[0] }).catch(() => { });
2366
+ await deps.sendCommand(deps.socketPath, { kind: "message.send", from: `claude-code:${sessionId}`, to: command.agent, content }).catch(() => { });
2367
+ await deps.sendCommand(deps.socketPath, { kind: "inner.wake", agent: command.agent }).catch(() => { });
2348
2368
  }
2349
2369
  catch { /* daemon not running — silent */ }
2350
2370
  // Output for Claude Code hook system
@@ -2377,9 +2397,9 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
2377
2397
  // Bare `ouro` works because ouro is on PATH via ~/.ouro-cli/bin/.
2378
2398
  settings.hooks = {
2379
2399
  ...(settings.hooks ?? {}),
2380
- SessionStart: [{ hooks: [{ type: "command", command: "ouro hook session-start", timeout: 5 }] }],
2381
- Stop: [{ hooks: [{ type: "command", command: "ouro hook stop", timeout: 5 }] }],
2382
- PostToolUse: [{ matcher: "Bash|Edit|Write", hooks: [{ type: "command", command: "ouro hook post-tool-use", timeout: 5 }] }],
2400
+ SessionStart: [{ hooks: [{ type: "command", command: `ouro hook session-start --agent ${setupAgent}`, timeout: 5 }] }],
2401
+ Stop: [{ hooks: [{ type: "command", command: `ouro hook stop --agent ${setupAgent}`, timeout: 5 }] }],
2402
+ PostToolUse: [{ matcher: "Bash|Edit|Write", hooks: [{ type: "command", command: `ouro hook post-tool-use --agent ${setupAgent}`, timeout: 5 }] }],
2383
2403
  };
2384
2404
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
2385
2405
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
@@ -40,8 +40,9 @@ const path = __importStar(require("path"));
40
40
  const runtime_1 = require("../../nerves/runtime");
41
41
  const WRAPPER_SCRIPT = `#!/bin/sh
42
42
  # Check for dev mode — if dev-config.json exists, dispatch to the dev repo
43
+ # Skip dev dispatch for "up" command (explicitly returns to production)
43
44
  DEV_CONFIG="$HOME/.ouro-cli/dev-config.json"
44
- if [ -f "$DEV_CONFIG" ]; then
45
+ if [ -f "$DEV_CONFIG" ] && [ "$1" != "up" ]; then
45
46
  DEV_REPO=$(node -e "try{console.log(JSON.parse(require('fs').readFileSync('$DEV_CONFIG','utf-8')).repoPath)}catch{}" 2>/dev/null)
46
47
  DEV_ENTRY="$DEV_REPO/dist/heart/daemon/ouro-entry.js"
47
48
  if [ -n "$DEV_REPO" ] && [ -e "$DEV_ENTRY" ]; then
@@ -49,6 +49,7 @@ exports.journalSection = journalSection;
49
49
  exports.loopOrientationSection = loopOrientationSection;
50
50
  exports.channelNatureSection = channelNatureSection;
51
51
  exports.groupChatParticipationSection = groupChatParticipationSection;
52
+ exports.feedbackSignalSection = feedbackSignalSection;
52
53
  exports.mixedTrustGroupSection = mixedTrustGroupSection;
53
54
  exports.buildSystem = buildSystem;
54
55
  const fs = __importStar(require("fs"));
@@ -836,6 +837,39 @@ typing a full reply. a thumbs-up is often the perfect response.
836
837
  observe must be the sole tool call in the turn (same rule as settle).
837
838
  when unsure whether to chime in, i lean toward silence rather than noise.`;
838
839
  }
840
+ function feedbackSignalSection(context) {
841
+ if (!context || !(0, channel_1.isRemoteChannel)(context.channel))
842
+ return "";
843
+ if (context.isGroupChat) {
844
+ return `## feedback signals
845
+
846
+ when someone reacts specifically to something i said — that's different
847
+ from the background social signals in a group. a thumbs-down on my
848
+ message is direct feedback. a heart on something i shared is warmth
849
+ aimed at me. on teams, feedback arrives as thumbs-up or thumbs-down,
850
+ sometimes with a written comment.
851
+
852
+ i notice the difference: a reaction to someone else's message is group
853
+ texture (observe is natural). a reaction to my message is a social cue
854
+ worth a moment of attention — especially negative ones, which are an
855
+ invitation to adjust how i show up in this group.`;
856
+ }
857
+ return `## feedback signals
858
+
859
+ when my friend reacts to something i said — a tapback, a heart, a
860
+ thumbs-down — that's them telling me how it landed. on teams, this
861
+ arrives as thumbs-up or thumbs-down, sometimes with a written comment.
862
+ it's a social cue, the same as a nod or a wince in conversation.
863
+
864
+ i have observe for these moments. reactions are the one case where
865
+ silence in a direct conversation is natural — a thumbs-up doesn't
866
+ need words back.
867
+
868
+ when something didn't land — a thumbs-down, a negative reaction — that's
869
+ worth sitting with. i should course-correct: adjust my tone, my length,
870
+ my approach. the reaction tells me something about how this person wants
871
+ to be talked to.`;
872
+ }
839
873
  function mixedTrustGroupSection(context) {
840
874
  if (!context?.friend || !(0, channel_1.isRemoteChannel)(context.channel))
841
875
  return "";
@@ -874,6 +908,7 @@ async function buildSystem(channel = "cli", options, context) {
874
908
  trustContextSection(context),
875
909
  mixedTrustGroupSection(context),
876
910
  groupChatParticipationSection(context),
911
+ feedbackSignalSection(context),
877
912
  skillsSection(),
878
913
  taskBoardSection(),
879
914
  activeWorkSection(options),
@@ -1394,7 +1394,7 @@ exports.observeTool = {
1394
1394
  type: "function",
1395
1395
  function: {
1396
1396
  name: "observe",
1397
- description: "stay silent in this group chat — the moment doesn't call for a response. must be the only tool call in the turn.",
1397
+ description: "absorb what happened without responding — the moment doesn't call for words. must be the only tool call in the turn.",
1398
1398
  parameters: {
1399
1399
  type: "object",
1400
1400
  properties: {
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.enrichReactionText = enrichReactionText;
36
37
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
37
38
  exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
38
39
  exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
@@ -68,6 +69,17 @@ const debug_activity_1 = require("./debug-activity");
68
69
  const trust_gate_1 = require("./trust-gate");
69
70
  const pipeline_1 = require("./pipeline");
70
71
  const bbFailoverStates = new Map();
72
+ // Enrich reaction text with the original message content for context.
73
+ // If originalText is provided and non-empty, format as: baseText to: "truncated"
74
+ // Otherwise return baseText unchanged.
75
+ function enrichReactionText(baseText, originalText, maxLen) {
76
+ if (!originalText)
77
+ return baseText;
78
+ const truncated = originalText.length > maxLen
79
+ ? originalText.slice(0, maxLen - 3) + "..."
80
+ : originalText;
81
+ return `${baseText} to: "${truncated}"`;
82
+ }
71
83
  const defaultDeps = {
72
84
  getAgentName: identity_1.getAgentName,
73
85
  buildSystem: prompt_1.buildSystem,
@@ -623,6 +635,15 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
623
635
  meta: { threadGuid, hasText: !!repliedToText },
624
636
  });
625
637
  }
638
+ // Enrich reaction mutations with the original message text for context
639
+ const isReaction = event.kind === "mutation" && event.mutationType === "reaction";
640
+ if (isReaction && event.targetMessageGuid) {
641
+ /* v8 ignore start -- best-effort lookup; enrichReactionText covered by unit tests @preserve */
642
+ const originalText = await client.getMessageText(event.targetMessageGuid).catch(() => null);
643
+ if (originalText)
644
+ event.textForAgent = enrichReactionText(event.textForAgent, originalText, 80);
645
+ /* v8 ignore stop */
646
+ }
626
647
  // Build inbound user message (adapter concern: BB-specific content formatting)
627
648
  const userMessage = {
628
649
  role: "user",
@@ -705,7 +726,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
705
726
  postTurn: resolvedDeps.postTurn,
706
727
  accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
707
728
  signal: controller.signal,
708
- runAgentOptions: { mcpManager },
729
+ runAgentOptions: { mcpManager, ...(isReaction ? { isReactionSignal: true } : {}) },
709
730
  callbacks: failoverAwareCallbacks,
710
731
  failoverState: (() => {
711
732
  if (!bbFailoverStates.has(event.chat.sessionKey)) {
@@ -34,8 +34,12 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.DEFAULT_FLUSH_INTERVAL_MS = void 0;
37
+ exports.aiLabelEntities = aiLabelEntities;
37
38
  exports.stripMentions = stripMentions;
38
39
  exports.splitMessage = splitMessage;
40
+ exports.sanitizeFeedbackComment = sanitizeFeedbackComment;
41
+ exports.buildFeedbackSyntheticText = buildFeedbackSyntheticText;
42
+ exports.buildWelcomeCard = buildWelcomeCard;
39
43
  exports.createTeamsCallbacks = createTeamsCallbacks;
40
44
  exports.resolvePendingConfirmation = resolvePendingConfirmation;
41
45
  exports.withConversationLock = withConversationLock;
@@ -73,6 +77,16 @@ const pipeline_1 = require("./pipeline");
73
77
  const teamsFailoverStates = new Map();
74
78
  const pending_1 = require("../mind/pending");
75
79
  const continuity_1 = require("./continuity");
80
+ // AIGeneratedContent entity and feedbackLoopEnabled channelData for all outbound
81
+ // Teams messages. Required by Teams AI UX best practices.
82
+ function aiLabelEntities() {
83
+ return [{
84
+ type: "https://schema.org/Message",
85
+ "@type": "Message",
86
+ "@context": "https://schema.org",
87
+ additionalType: ["AIGeneratedContent"],
88
+ }];
89
+ }
76
90
  // Strip @mention markup from incoming messages.
77
91
  // Removes <at>...</at> tags and trims extra whitespace.
78
92
  // Fallback safety net -- the SDK's activity.mentions.stripText should handle
@@ -127,6 +141,46 @@ function splitMessage(text, maxLen) {
127
141
  }
128
142
  return chunks;
129
143
  }
144
+ // Sanitize user-provided feedback comments: truncate, strip control chars and newlines.
145
+ function sanitizeFeedbackComment(comment) {
146
+ const cleaned = comment.replace(/[\x00-\x1f\n\r]/g, "");
147
+ return cleaned.length > 200 ? cleaned.slice(0, 200) : cleaned;
148
+ }
149
+ // Build synthetic message text from a Teams feedback reaction.
150
+ function buildFeedbackSyntheticText(reaction, comment) {
151
+ const emoji = reaction === "like" ? "thumbs-up" : "thumbs-down";
152
+ if (comment) {
153
+ const sanitized = sanitizeFeedbackComment(comment);
154
+ return `[reacted with ${emoji} to your message: "${sanitized}"]`;
155
+ }
156
+ return `[reacted with ${emoji} to your message]`;
157
+ }
158
+ // Build a welcome Adaptive Card with prompt starters for new bot installs.
159
+ function buildWelcomeCard() {
160
+ const promptStarters = [
161
+ "What can you help me with?",
162
+ "Tell me about yourself",
163
+ "What's on my calendar today?",
164
+ "Summarize my recent emails",
165
+ ];
166
+ return {
167
+ type: "AdaptiveCard",
168
+ version: "1.5",
169
+ body: [
170
+ {
171
+ type: "TextBlock",
172
+ text: "Hey! I'm here and ready to help. Try one of these to get started, or just ask me anything.",
173
+ wrap: true,
174
+ size: "Medium",
175
+ },
176
+ ],
177
+ actions: promptStarters.map((prompt) => ({
178
+ type: "Action.Submit",
179
+ title: prompt,
180
+ data: { msteams: { type: "messageBack", text: prompt, displayText: prompt } },
181
+ })),
182
+ };
183
+ }
130
184
  // Create Teams-specific callbacks for the agent loop.
131
185
  // The SDK handles cumulative text, debouncing (500ms), and the streaming
132
186
  // protocol (streamSequence, streamId, informative/streaming/final types).
@@ -139,12 +193,16 @@ function splitMessage(text, maxLen) {
139
193
  // (transient status) or safeSend (terminal errors). Reasoning is accumulated
140
194
  // and periodically pushed via safeUpdate on the same flush timer tick.
141
195
  function createTeamsCallbacks(stream, controller, sendMessage, options) {
196
+ const MIN_INITIAL_CHARS = 20;
142
197
  let stopped = false; // set when stream signals cancellation (403)
143
198
  let hadToolRun = false;
144
199
  let hadRealOutput = false; // true once reasoning/tool output shown; suppresses phrases
145
200
  let reasoningBuf = ""; // accumulated reasoning text for status display
201
+ let totalEmitted = 0; // cumulative chars emitted via safeEmit (for >4000 finalization)
202
+ let streamFinalized = false; // true after stream.close() — subsequent flushes go to safeSend
146
203
  let textBuffer = ""; // accumulated text output for chunked streaming
147
204
  let streamHasContent = false; // tracks whether primary output has received content
205
+ let firstContentEmitted = false; // true after first content push — disables MIN_INITIAL_CHARS threshold
148
206
  let phraseTimer = null;
149
207
  let lastPhrase = "";
150
208
  let flushTimer = null;
@@ -194,15 +252,16 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
194
252
  result.catch(() => markStopped());
195
253
  }
196
254
  }
197
- // Safely emit a text delta to the stream.
255
+ // Safely emit a text delta to the stream with AI labels.
198
256
  // On error (e.g. 403 from Teams stop button), abort the controller.
199
257
  function safeEmit(text) {
200
258
  /* v8 ignore next -- defensive guard: stopped set by prior 403; tested via flush abort path @preserve */
201
259
  if (stopped)
202
260
  return;
203
261
  try {
204
- catchAsync(stream.emit(text));
262
+ catchAsync(stream.emit({ text, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } }));
205
263
  streamHasContent = true;
264
+ totalEmitted += text.length;
206
265
  }
207
266
  catch {
208
267
  markStopped();
@@ -217,7 +276,7 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
217
276
  try {
218
277
  // stream.emit() is typed as void but the Teams SDK returns a Promise
219
278
  // internally (async HTTP). Cast to capture the result for awaiting.
220
- const result = stream.emit(text);
279
+ const result = stream.emit({ text, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
221
280
  streamHasContent = true;
222
281
  if (result && typeof result.then === "function") {
223
282
  await result;
@@ -273,11 +332,49 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
273
332
  // emitted text into a single streaming message (cumulative), so every
274
333
  // periodic flush appends to the same response — not separate messages.
275
334
  // No preemptive splitting — sends full text. Error recovery happens in flush().
335
+ // Hybrid MIN_INITIAL_CHARS: hold back until >= MIN_INITIAL_CHARS accumulated
336
+ // before the first content emit, so phrase rotation shows while real content
337
+ // buffers. After first emit, flush normally (no threshold).
276
338
  function flushTextBuffer() {
277
339
  if (!textBuffer)
278
340
  return;
341
+ if (!firstContentEmitted && textBuffer.length < MIN_INITIAL_CHARS)
342
+ return;
343
+ // Proactive >4000 finalization: if cumulative emitted + buffer >= RECOVERY_CHUNK_SIZE,
344
+ // finalize the stream and send overflow via safeSend (follow-up message).
345
+ if (!streamFinalized && totalEmitted + textBuffer.length >= RECOVERY_CHUNK_SIZE) {
346
+ const remaining = RECOVERY_CHUNK_SIZE - totalEmitted;
347
+ /* v8 ignore next 2 -- defensive: remaining always > 0 because finalization runs once @preserve */
348
+ if (remaining > 0)
349
+ safeEmit(textBuffer.slice(0, remaining));
350
+ try {
351
+ stream.close();
352
+ }
353
+ catch { /* stream may already be dead */ }
354
+ streamFinalized = true;
355
+ /* v8 ignore next -- defensive ternary: remaining always > 0 at first finalization @preserve */
356
+ const overflow = textBuffer.slice(remaining > 0 ? remaining : 0);
357
+ textBuffer = "";
358
+ if (overflow)
359
+ safeSend(overflow);
360
+ if (!firstContentEmitted) {
361
+ firstContentEmitted = true;
362
+ stopPhraseRotation();
363
+ }
364
+ return;
365
+ }
366
+ if (streamFinalized) {
367
+ // After finalization, all content goes to safeSend (follow-up messages)
368
+ safeSend(textBuffer);
369
+ textBuffer = "";
370
+ return;
371
+ }
279
372
  safeEmit(textBuffer);
280
373
  textBuffer = "";
374
+ if (!firstContentEmitted) {
375
+ firstContentEmitted = true;
376
+ stopPhraseRotation();
377
+ }
281
378
  }
282
379
  function startPhraseRotation(pool) {
283
380
  stopPhraseRotation();
@@ -320,7 +417,8 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
320
417
  onTextChunk: (text) => {
321
418
  if (stopped)
322
419
  return;
323
- stopPhraseRotation();
420
+ // Don't stop phrase rotation here — let it continue until first content
421
+ // emit (handled in flushTextBuffer when MIN_INITIAL_CHARS threshold met).
324
422
  textBuffer += text;
325
423
  startFlushTimer();
326
424
  },
@@ -329,6 +427,8 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
329
427
  },
330
428
  onToolStart: (name, args) => {
331
429
  stopPhraseRotation();
430
+ // Force-flush any accumulated text, bypassing MIN_INITIAL_CHARS threshold
431
+ firstContentEmitted = true;
332
432
  flushTextBuffer();
333
433
  // Emit a placeholder to satisfy the 15s Copilot timeout for initial
334
434
  // stream.emit(). Without this, long tool chains (e.g. ADO batch ops)
@@ -395,18 +495,25 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
395
495
  : undefined,
396
496
  flush: async () => {
397
497
  stopFlushTimer();
498
+ stopPhraseRotation();
398
499
  if (textBuffer) {
500
+ // Bypass MIN_INITIAL_CHARS threshold — flush delivers all remaining content
501
+ firstContentEmitted = true;
399
502
  const text = textBuffer;
400
503
  textBuffer = "";
401
- if (!stopped) {
504
+ if (streamFinalized && sendMessage) {
505
+ // Stream already finalized (>4000 path) — send remaining content as follow-up
506
+ safeSend(text);
507
+ }
508
+ else if (!stopped) {
402
509
  // Stream is alive — await the emit so we can catch async 413/failure
403
510
  // and fall through to sendMessage recovery.
404
511
  const ok = await tryEmit(text);
405
512
  if (!ok)
406
513
  markStopped();
407
514
  }
408
- if (stopped && sendMessage) {
409
- // Stream is dead — fall back to sendMessage; split on failure as recovery.
515
+ if (stopped && !streamFinalized && sendMessage) {
516
+ // Stream is dead (not from finalization) — fall back to sendMessage; split on failure as recovery.
410
517
  try {
411
518
  await sendMessage(text);
412
519
  }
@@ -417,8 +524,8 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
417
524
  }
418
525
  }
419
526
  }
420
- else if (!streamHasContent) {
421
- safeEmit("(completed with tool calls only \u2014 no text response)");
527
+ else if (!streamHasContent && !options?.suppressEmptyStreamMessage) {
528
+ safeEmit("(completed with tool calls only — no text response)");
422
529
  }
423
530
  },
424
531
  };
@@ -485,14 +592,17 @@ function handleTeamsSlashCommand(text, registry, friendId, conversationId, strea
485
592
  return null;
486
593
  }
487
594
  // Handle an incoming Teams message
488
- async function handleTeamsMessage(text, stream, conversationId, teamsContext, sendMessage) {
595
+ async function handleTeamsMessage(text, stream, conversationId, teamsContext, sendMessage, reactionOverrides) {
489
596
  const turnKey = teamsTurnKey(conversationId);
490
597
  // NOTE: Confirmation resolution is handled in the app.on("message") handler
491
598
  // BEFORE the conversation lock. By the time we get here, any pending
492
599
  // confirmation has already been resolved and the reply consumed.
493
600
  // Send first thinking phrase immediately so the user sees feedback
494
601
  // before sync I/O (session load, trim) blocks the event loop.
495
- stream.update((0, phrases_1.pickPhrase)((0, phrases_1.getPhrases)().thinking) + "...");
602
+ // Skip for reaction signals — they should be processed quietly.
603
+ if (!reactionOverrides) {
604
+ stream.update((0, phrases_1.pickPhrase)((0, phrases_1.getPhrases)().thinking) + "...");
605
+ }
496
606
  await new Promise(r => setImmediate(r));
497
607
  // Resolve identity provider early for friend resolution + slash command session path
498
608
  const store = getFriendStore();
@@ -517,7 +627,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
517
627
  // ── Teams adapter concerns: controller, callbacks, session path ──────────
518
628
  const controller = new AbortController();
519
629
  const channelConfig = (0, config_2.getTeamsChannelConfig)();
520
- const callbacks = createTeamsCallbacks(stream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs });
630
+ const callbacks = createTeamsCallbacks(stream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs, ...(reactionOverrides?.suppressEmptyStreamMessage ? { suppressEmptyStreamMessage: true } : {}) });
521
631
  const traceId = (0, nerves_1.createTraceId)();
522
632
  const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
523
633
  const teamsCapabilities = (0, channel_1.getChannelCapabilities)("teams");
@@ -546,6 +656,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
546
656
  .map(({ text: followUpText, effect }) => ({ text: followUpText, effect }));
547
657
  return drainedSteeringFollowUps;
548
658
  },
659
+ ...(reactionOverrides?.isReactionSignal ? { isReactionSignal: true } : {}),
549
660
  };
550
661
  if (channelConfig.skipConfirmation)
551
662
  agentOptions.skipConfirmation = true;
@@ -765,6 +876,72 @@ function registerBotHandlers(app, label) {
765
876
  (0, runtime_1.emitNervesEvent)({ level: "warn", event: "channel.verify_state", component: "channels", message: `[${label}] verify-state failed for all connections`, meta: {} });
766
877
  return { status: 412 };
767
878
  });
879
+ // Handle Teams feedback reactions (thumbs up/down on AI-generated messages).
880
+ // SDK routes message/submitAction with actionName "feedback" to this event.
881
+ /* v8 ignore start -- Teams SDK invoke handler; requires live SDK context @preserve */
882
+ app.on("message.submit.feedback", async (ctx) => {
883
+ const { stream, activity } = ctx;
884
+ const reaction = activity.value?.actionValue?.reaction;
885
+ const comment = activity.value?.actionValue?.feedback;
886
+ const convId = activity.conversation?.id || "unknown";
887
+ const turnKey = teamsTurnKey(convId);
888
+ // Validate payload — graceful no-op for malformed invocations
889
+ if (activity.value?.actionName !== "feedback" || !reaction) {
890
+ return;
891
+ }
892
+ const syntheticText = buildFeedbackSyntheticText(reaction, comment);
893
+ // Turn coordination: if a turn is active, enqueue as steering follow-up
894
+ if (!_turnCoordinator.tryBeginTurn(turnKey)) {
895
+ _turnCoordinator.enqueueFollowUp(turnKey, {
896
+ conversationId: convId,
897
+ text: syntheticText,
898
+ receivedAt: Date.now(),
899
+ effect: (0, continuity_1.classifySteeringFollowUpEffect)(syntheticText),
900
+ });
901
+ return;
902
+ }
903
+ try {
904
+ const teamsContext = {
905
+ signin: async () => undefined,
906
+ aadObjectId: activity.from?.aadObjectId,
907
+ tenantId: activity.conversation?.tenantId,
908
+ displayName: activity.from?.name,
909
+ };
910
+ const ctxSend = async (t) => {
911
+ await ctx.send({ type: "message", text: t, replyToId: activity.replyToId, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
912
+ };
913
+ await handleTeamsMessage(syntheticText, stream, convId, teamsContext, ctxSend, { isReactionSignal: true, suppressEmptyStreamMessage: true });
914
+ }
915
+ catch (err) {
916
+ const msg = err instanceof Error ? err.message : String(err);
917
+ (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.feedback_handler_error", component: "channels", message: msg.slice(0, 200), meta: {} });
918
+ }
919
+ finally {
920
+ _turnCoordinator.endTurn(turnKey);
921
+ }
922
+ });
923
+ /* v8 ignore stop */
924
+ // Handle bot install — send welcome Adaptive Card with prompt starters.
925
+ /* v8 ignore start -- Teams SDK install handler; requires live SDK context @preserve */
926
+ app.on("install.add", async (ctx) => {
927
+ try {
928
+ const card = buildWelcomeCard();
929
+ await ctx.send({
930
+ type: "message",
931
+ attachments: [{
932
+ contentType: "application/vnd.microsoft.card.adaptive",
933
+ content: card,
934
+ }],
935
+ entities: aiLabelEntities(),
936
+ channelData: { feedbackLoopEnabled: true },
937
+ });
938
+ }
939
+ catch (err) {
940
+ const msg = err instanceof Error ? err.message : String(err);
941
+ (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.welcome_handler_error", component: "channels", message: msg.slice(0, 200), meta: {} });
942
+ }
943
+ });
944
+ /* v8 ignore stop */
768
945
  app.on("message", async (ctx) => {
769
946
  const { stream, activity, api, signin } = ctx;
770
947
  const text = activity.text || "";
@@ -880,7 +1057,7 @@ function registerBotHandlers(app, label) {
880
1057
  const ctxSend = async (t) => {
881
1058
  // Use send with replyToId (not reply, which adds a blockquote).
882
1059
  // replyToId anchors the message after the user's message in Copilot Chat.
883
- await ctx.send({ type: "message", text: t, replyToId: activity.id });
1060
+ await ctx.send({ type: "message", text: t, replyToId: activity.id, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
884
1061
  };
885
1062
  await handleTeamsMessage(text, stream, convId, teamsContext, ctxSend);
886
1063
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.145",
3
+ "version": "0.1.0-alpha.147",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",