@ouro.bot/cli 0.1.0-alpha.146 → 0.1.0-alpha.148

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.
@@ -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.146",
3
+ "version": "0.1.0-alpha.148",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",
@@ -1,132 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HeartbeatTimer = exports.DEFAULT_CADENCE_MS = void 0;
4
- exports.parseCadenceMs = parseCadenceMs;
5
- const runtime_1 = require("../../nerves/runtime");
6
- const parser_1 = require("../../repertoire/tasks/parser");
7
- exports.DEFAULT_CADENCE_MS = 30 * 60 * 1000; // 30 minutes
8
- function parseCadenceMs(raw) {
9
- if (typeof raw !== "string")
10
- return null;
11
- const value = raw.trim();
12
- if (!value)
13
- return null;
14
- const match = /^(\d+)(m|h|d)$/.exec(value);
15
- if (!match)
16
- return null;
17
- const interval = Number.parseInt(match[1], 10);
18
- if (!Number.isFinite(interval) || interval <= 0)
19
- return null;
20
- const unit = match[2];
21
- if (unit === "m")
22
- return interval * 60 * 1000;
23
- if (unit === "h")
24
- return interval * 60 * 60 * 1000;
25
- return interval * 24 * 60 * 60 * 1000;
26
- }
27
- class HeartbeatTimer {
28
- agent;
29
- sendToAgent;
30
- readFileSync;
31
- readdirSync;
32
- heartbeatTaskDir;
33
- runtimeStatePath;
34
- now;
35
- pendingTimer = null;
36
- constructor(options) {
37
- this.agent = options.agent;
38
- this.sendToAgent = options.sendToAgent;
39
- this.readFileSync = options.deps.readFileSync;
40
- this.readdirSync = options.deps.readdirSync;
41
- this.heartbeatTaskDir = options.deps.heartbeatTaskDir;
42
- this.runtimeStatePath = options.deps.runtimeStatePath;
43
- this.now = options.deps.now ?? (() => Date.now());
44
- }
45
- start() {
46
- this.scheduleNext();
47
- }
48
- stop() {
49
- if (this.pendingTimer !== null) {
50
- clearTimeout(this.pendingTimer);
51
- this.pendingTimer = null;
52
- }
53
- }
54
- scheduleNext() {
55
- const cadenceMs = this.readCadence();
56
- const lastCompletedAt = this.readLastCompletedAt();
57
- const nowMs = this.now();
58
- let delay;
59
- if (lastCompletedAt === null) {
60
- // Never run before — fire once after a full cadence (not immediately, to avoid spin loop
61
- // when the agent has no inner dialog state to write lastCompletedAt)
62
- delay = cadenceMs;
63
- }
64
- else {
65
- const elapsed = nowMs - lastCompletedAt;
66
- delay = Math.max(0, cadenceMs - elapsed);
67
- }
68
- this.pendingTimer = setTimeout(() => {
69
- this.fire();
70
- }, delay);
71
- }
72
- fire() {
73
- this.pendingTimer = null;
74
- (0, runtime_1.emitNervesEvent)({
75
- component: "daemon",
76
- event: "daemon.heartbeat_fire",
77
- message: "heartbeat timer fired",
78
- meta: { agent: this.agent },
79
- });
80
- this.sendToAgent(this.agent, { type: "heartbeat" });
81
- this.scheduleNext();
82
- }
83
- readCadence() {
84
- // Scan habits dir for *-heartbeat.md
85
- let files;
86
- try {
87
- files = this.readdirSync(this.heartbeatTaskDir);
88
- }
89
- catch {
90
- return exports.DEFAULT_CADENCE_MS;
91
- }
92
- const heartbeatFile = files.find((f) => f.endsWith("-heartbeat.md"));
93
- if (!heartbeatFile)
94
- return exports.DEFAULT_CADENCE_MS;
95
- try {
96
- const content = this.readFileSync(`${this.heartbeatTaskDir}/${heartbeatFile}`, "utf-8");
97
- const cadence = this.extractCadenceFromTaskFile(content);
98
- return cadence ?? exports.DEFAULT_CADENCE_MS;
99
- }
100
- catch {
101
- return exports.DEFAULT_CADENCE_MS;
102
- }
103
- }
104
- extractCadenceFromTaskFile(content) {
105
- // Parse frontmatter from task file to get cadence value
106
- const lines = content.split(/\r?\n/);
107
- if (lines[0]?.trim() !== "---")
108
- return null;
109
- const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
110
- if (closing === -1)
111
- return null;
112
- const rawFrontmatter = lines.slice(1, closing).join("\n");
113
- const frontmatter = (0, parser_1.parseFrontmatter)(rawFrontmatter);
114
- return parseCadenceMs(frontmatter.cadence);
115
- }
116
- readLastCompletedAt() {
117
- try {
118
- const raw = this.readFileSync(this.runtimeStatePath, "utf-8");
119
- const state = JSON.parse(raw);
120
- if (typeof state.lastCompletedAt !== "string")
121
- return null;
122
- const ms = new Date(state.lastCompletedAt).getTime();
123
- if (Number.isNaN(ms))
124
- return null;
125
- return ms;
126
- }
127
- catch {
128
- return null;
129
- }
130
- }
131
- }
132
- exports.HeartbeatTimer = HeartbeatTimer;