@ouro.bot/cli 0.1.0-alpha.16 → 0.1.0-alpha.160

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.
Files changed (165) hide show
  1. package/AdoptionSpecialist.ouro/agent.json +3 -2
  2. package/AdoptionSpecialist.ouro/psyche/SOUL.md +3 -3
  3. package/AdoptionSpecialist.ouro/psyche/identities/monty.md +2 -2
  4. package/README.md +186 -191
  5. package/changelog.json +1081 -0
  6. package/dist/heart/active-work.js +633 -0
  7. package/dist/heart/bridges/manager.js +358 -0
  8. package/dist/heart/bridges/state-machine.js +135 -0
  9. package/dist/heart/bridges/store.js +123 -0
  10. package/dist/heart/commitments.js +105 -0
  11. package/dist/heart/config.js +40 -30
  12. package/dist/heart/core.js +608 -123
  13. package/dist/heart/cross-chat-delivery.js +146 -0
  14. package/dist/heart/daemon/agent-discovery.js +81 -0
  15. package/dist/heart/daemon/agent-service.js +360 -0
  16. package/dist/heart/daemon/auth-flow.js +477 -0
  17. package/dist/heart/daemon/cadence.js +70 -0
  18. package/dist/heart/daemon/daemon-cli.js +2467 -205
  19. package/dist/heart/daemon/daemon-entry.js +131 -4
  20. package/dist/heart/daemon/daemon-health.js +136 -0
  21. package/dist/heart/daemon/daemon-runtime-sync.js +212 -0
  22. package/dist/heart/daemon/daemon-tombstone.js +123 -0
  23. package/dist/heart/daemon/daemon.js +434 -17
  24. package/dist/heart/daemon/habit-migration.js +181 -0
  25. package/dist/heart/daemon/habit-parser.js +119 -0
  26. package/dist/heart/daemon/habit-scheduler.js +369 -0
  27. package/dist/heart/daemon/hatch-animation.js +10 -3
  28. package/dist/heart/daemon/hatch-flow.js +30 -111
  29. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  30. package/dist/heart/daemon/hooks/bundle-meta.js +92 -0
  31. package/dist/heart/daemon/inner-status.js +89 -0
  32. package/dist/heart/daemon/launchd.js +159 -0
  33. package/dist/heart/daemon/log-tailer.js +4 -3
  34. package/dist/heart/daemon/mcp-server.js +591 -0
  35. package/dist/heart/daemon/message-router.js +17 -8
  36. package/dist/heart/daemon/os-cron-deps.js +134 -0
  37. package/dist/heart/daemon/ouro-bot-global-installer.js +128 -0
  38. package/dist/heart/daemon/ouro-path-installer.js +78 -34
  39. package/dist/heart/daemon/ouro-version-manager.js +171 -0
  40. package/dist/heart/daemon/outlook-http.js +307 -0
  41. package/dist/heart/daemon/outlook-read.js +1281 -0
  42. package/dist/heart/daemon/outlook-render.js +1032 -0
  43. package/dist/heart/daemon/outlook-types.js +7 -0
  44. package/dist/heart/daemon/outlook-view.js +194 -0
  45. package/dist/heart/daemon/process-manager.js +75 -0
  46. package/dist/heart/daemon/run-hooks.js +39 -0
  47. package/dist/heart/daemon/runtime-logging.js +58 -15
  48. package/dist/heart/daemon/runtime-metadata.js +219 -0
  49. package/dist/heart/daemon/runtime-mode.js +67 -0
  50. package/dist/heart/daemon/safe-mode.js +161 -0
  51. package/dist/heart/daemon/sense-manager.js +50 -2
  52. package/dist/heart/daemon/session-id-resolver.js +131 -0
  53. package/dist/heart/daemon/skill-management-installer.js +94 -0
  54. package/dist/heart/daemon/socket-client.js +202 -0
  55. package/dist/heart/daemon/specialist-orchestrator.js +2 -2
  56. package/dist/heart/daemon/specialist-prompt.js +13 -8
  57. package/dist/heart/daemon/specialist-tools.js +53 -4
  58. package/dist/heart/daemon/staged-restart.js +114 -0
  59. package/dist/heart/daemon/task-scheduler.js +3 -25
  60. package/dist/heart/daemon/thoughts.js +507 -0
  61. package/dist/heart/daemon/update-checker.js +111 -0
  62. package/dist/heart/daemon/update-hooks.js +138 -0
  63. package/dist/heart/daemon/wrapper-publish-guard.js +86 -0
  64. package/dist/heart/delegation.js +62 -0
  65. package/dist/heart/identity.js +136 -55
  66. package/dist/heart/kicks.js +2 -20
  67. package/dist/heart/migrate-config.js +127 -0
  68. package/dist/heart/model-capabilities.js +48 -0
  69. package/dist/heart/obligations.js +197 -0
  70. package/dist/heart/progress-story.js +42 -0
  71. package/dist/heart/provider-failover.js +88 -0
  72. package/dist/heart/provider-ping.js +172 -0
  73. package/dist/heart/providers/anthropic-token.js +163 -0
  74. package/dist/heart/providers/anthropic.js +196 -36
  75. package/dist/heart/providers/azure.js +116 -10
  76. package/dist/heart/providers/github-copilot.js +157 -0
  77. package/dist/heart/providers/minimax.js +33 -3
  78. package/dist/heart/providers/openai-codex.js +51 -16
  79. package/dist/heart/session-activity.js +172 -0
  80. package/dist/heart/session-recall.js +216 -0
  81. package/dist/heart/streaming.js +117 -33
  82. package/dist/heart/target-resolution.js +127 -0
  83. package/dist/heart/tool-activity-callbacks.js +36 -0
  84. package/dist/heart/tool-description.js +135 -0
  85. package/dist/heart/tool-loop.js +200 -0
  86. package/dist/heart/turn-coordinator.js +28 -0
  87. package/dist/mind/associative-recall.js +122 -32
  88. package/dist/mind/bundle-manifest.js +14 -0
  89. package/dist/mind/context.js +125 -14
  90. package/dist/mind/{memory.js → diary.js} +70 -56
  91. package/dist/mind/first-impressions.js +16 -2
  92. package/dist/mind/friends/channel.js +56 -0
  93. package/dist/mind/friends/group-context.js +144 -0
  94. package/dist/mind/friends/store-file.js +19 -0
  95. package/dist/mind/friends/trust-explanation.js +74 -0
  96. package/dist/mind/friends/types.js +8 -0
  97. package/dist/mind/journal-index.js +161 -0
  98. package/dist/mind/obligation-steering.js +221 -0
  99. package/dist/mind/obligations.js +134 -0
  100. package/dist/mind/pending.js +76 -9
  101. package/dist/mind/phrases.js +1 -0
  102. package/dist/mind/prompt.js +651 -91
  103. package/dist/mind/token-estimate.js +8 -12
  104. package/dist/nerves/cli-logging.js +15 -2
  105. package/dist/nerves/coverage/run-artifacts.js +1 -1
  106. package/dist/nerves/event-buffer.js +111 -0
  107. package/dist/nerves/index.js +63 -3
  108. package/dist/nerves/observation.js +20 -0
  109. package/dist/nerves/runtime.js +5 -1
  110. package/dist/repertoire/ado-semantic.js +6 -0
  111. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  112. package/dist/repertoire/coding/context-pack.js +254 -0
  113. package/dist/repertoire/coding/feedback.js +301 -0
  114. package/dist/repertoire/coding/index.js +4 -1
  115. package/dist/repertoire/coding/manager.js +210 -4
  116. package/dist/repertoire/coding/spawner.js +58 -12
  117. package/dist/repertoire/coding/tools.js +176 -4
  118. package/dist/repertoire/guardrails.js +291 -0
  119. package/dist/repertoire/mcp-client.js +254 -0
  120. package/dist/repertoire/mcp-manager.js +198 -0
  121. package/dist/repertoire/skills.js +14 -23
  122. package/dist/repertoire/tasks/board.js +43 -5
  123. package/dist/repertoire/tasks/fix.js +182 -0
  124. package/dist/repertoire/tasks/index.js +28 -10
  125. package/dist/repertoire/tasks/lifecycle.js +2 -2
  126. package/dist/repertoire/tasks/parser.js +3 -2
  127. package/dist/repertoire/tasks/scanner.js +194 -37
  128. package/dist/repertoire/tasks/transitions.js +11 -28
  129. package/dist/repertoire/tools-base.js +992 -273
  130. package/dist/repertoire/tools-bluebubbles.js +94 -0
  131. package/dist/repertoire/tools-github.js +1 -6
  132. package/dist/repertoire/tools-teams.js +12 -59
  133. package/dist/repertoire/tools.js +281 -101
  134. package/dist/scripts/claude-code-hook.js +41 -0
  135. package/dist/scripts/claude-code-stop-hook.js +47 -0
  136. package/dist/senses/attention-queue.js +97 -0
  137. package/dist/senses/bluebubbles-client.js +258 -5
  138. package/dist/senses/bluebubbles-entry.js +2 -0
  139. package/dist/senses/bluebubbles-inbound-log.js +109 -0
  140. package/dist/senses/bluebubbles-media.js +339 -0
  141. package/dist/senses/bluebubbles-model.js +12 -4
  142. package/dist/senses/bluebubbles-mutation-log.js +45 -5
  143. package/dist/senses/bluebubbles-runtime-state.js +109 -0
  144. package/dist/senses/bluebubbles-session-cleanup.js +72 -0
  145. package/dist/senses/bluebubbles.js +1040 -45
  146. package/dist/senses/cli-entry.js +1 -1
  147. package/dist/senses/cli-layout.js +187 -0
  148. package/dist/senses/cli.js +419 -152
  149. package/dist/senses/commands.js +38 -1
  150. package/dist/senses/continuity.js +94 -0
  151. package/dist/senses/habit-turn-message.js +108 -0
  152. package/dist/senses/inner-dialog-worker.js +112 -19
  153. package/dist/senses/inner-dialog.js +592 -84
  154. package/dist/senses/pipeline.js +502 -0
  155. package/dist/senses/shared-turn.js +196 -0
  156. package/dist/senses/surface-tool.js +95 -0
  157. package/dist/senses/teams.js +667 -120
  158. package/dist/senses/trust-gate.js +112 -2
  159. package/package.json +12 -4
  160. package/skills/configure-dev-tools.md +81 -0
  161. package/subagents/README.md +4 -70
  162. package/dist/heart/daemon/subagent-installer.js +0 -134
  163. package/subagents/work-doer.md +0 -233
  164. package/subagents/work-merger.md +0 -624
  165. package/subagents/work-planner.md +0 -373
@@ -34,13 +34,20 @@ 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;
42
46
  exports.handleTeamsMessage = handleTeamsMessage;
47
+ exports.sendProactiveTeamsMessageToSession = sendProactiveTeamsMessageToSession;
48
+ exports.drainAndSendPendingTeams = drainAndSendPendingTeams;
43
49
  exports.startTeamsApp = startTeamsApp;
50
+ const fs = __importStar(require("fs"));
44
51
  const teams_apps_1 = require("@microsoft/teams.apps");
45
52
  const teams_dev_1 = require("@microsoft/teams.dev");
46
53
  const core_1 = require("../heart/core");
@@ -56,13 +63,32 @@ const commands_1 = require("./commands");
56
63
  const nerves_1 = require("../nerves");
57
64
  const runtime_1 = require("../nerves/runtime");
58
65
  const store_file_1 = require("../mind/friends/store-file");
66
+ const types_1 = require("../mind/friends/types");
59
67
  const resolver_1 = require("../mind/friends/resolver");
60
68
  const tokens_1 = require("../mind/friends/tokens");
61
69
  const turn_coordinator_1 = require("../heart/turn-coordinator");
62
70
  const identity_1 = require("../heart/identity");
71
+ const mcp_manager_1 = require("../repertoire/mcp-manager");
72
+ const progress_story_1 = require("../heart/progress-story");
73
+ const tool_activity_callbacks_1 = require("../heart/tool-activity-callbacks");
74
+ const commands_2 = require("./commands");
63
75
  const http = __importStar(require("http"));
64
76
  const path = __importStar(require("path"));
65
77
  const trust_gate_1 = require("./trust-gate");
78
+ const pipeline_1 = require("./pipeline");
79
+ const teamsFailoverStates = new Map();
80
+ const pending_1 = require("../mind/pending");
81
+ const continuity_1 = require("./continuity");
82
+ // AIGeneratedContent entity and feedbackLoopEnabled channelData for all outbound
83
+ // Teams messages. Required by Teams AI UX best practices.
84
+ function aiLabelEntities() {
85
+ return [{
86
+ type: "https://schema.org/Message",
87
+ "@type": "Message",
88
+ "@context": "https://schema.org",
89
+ additionalType: ["AIGeneratedContent"],
90
+ }];
91
+ }
66
92
  // Strip @mention markup from incoming messages.
67
93
  // Removes <at>...</at> tags and trims extra whitespace.
68
94
  // Fallback safety net -- the SDK's activity.mentions.stripText should handle
@@ -117,6 +143,46 @@ function splitMessage(text, maxLen) {
117
143
  }
118
144
  return chunks;
119
145
  }
146
+ // Sanitize user-provided feedback comments: truncate, strip control chars and newlines.
147
+ function sanitizeFeedbackComment(comment) {
148
+ const cleaned = comment.replace(/[\x00-\x1f\n\r]/g, "");
149
+ return cleaned.length > 200 ? cleaned.slice(0, 200) : cleaned;
150
+ }
151
+ // Build synthetic message text from a Teams feedback reaction.
152
+ function buildFeedbackSyntheticText(reaction, comment) {
153
+ const emoji = reaction === "like" ? "thumbs-up" : "thumbs-down";
154
+ if (comment) {
155
+ const sanitized = sanitizeFeedbackComment(comment);
156
+ return `[reacted with ${emoji} to your message: "${sanitized}"]`;
157
+ }
158
+ return `[reacted with ${emoji} to your message]`;
159
+ }
160
+ // Build a welcome Adaptive Card with prompt starters for new bot installs.
161
+ function buildWelcomeCard() {
162
+ const promptStarters = [
163
+ "What can you help me with?",
164
+ "Tell me about yourself",
165
+ "What's on my calendar today?",
166
+ "Summarize my recent emails",
167
+ ];
168
+ return {
169
+ type: "AdaptiveCard",
170
+ version: "1.5",
171
+ body: [
172
+ {
173
+ type: "TextBlock",
174
+ text: "Hey! I'm here and ready to help. Try one of these to get started, or just ask me anything.",
175
+ wrap: true,
176
+ size: "Medium",
177
+ },
178
+ ],
179
+ actions: promptStarters.map((prompt) => ({
180
+ type: "Action.Submit",
181
+ title: prompt,
182
+ data: { msteams: { type: "messageBack", text: prompt, displayText: prompt } },
183
+ })),
184
+ };
185
+ }
120
186
  // Create Teams-specific callbacks for the agent loop.
121
187
  // The SDK handles cumulative text, debouncing (500ms), and the streaming
122
188
  // protocol (streamSequence, streamId, informative/streaming/final types).
@@ -129,12 +195,16 @@ function splitMessage(text, maxLen) {
129
195
  // (transient status) or safeSend (terminal errors). Reasoning is accumulated
130
196
  // and periodically pushed via safeUpdate on the same flush timer tick.
131
197
  function createTeamsCallbacks(stream, controller, sendMessage, options) {
198
+ const MIN_INITIAL_CHARS = 20;
132
199
  let stopped = false; // set when stream signals cancellation (403)
133
200
  let hadToolRun = false;
134
201
  let hadRealOutput = false; // true once reasoning/tool output shown; suppresses phrases
135
202
  let reasoningBuf = ""; // accumulated reasoning text for status display
203
+ let totalEmitted = 0; // cumulative chars emitted via safeEmit (for >4000 finalization)
204
+ let streamFinalized = false; // true after stream.close() — subsequent flushes go to safeSend
136
205
  let textBuffer = ""; // accumulated text output for chunked streaming
137
206
  let streamHasContent = false; // tracks whether primary output has received content
207
+ let firstContentEmitted = false; // true after first content push — disables MIN_INITIAL_CHARS threshold
138
208
  let phraseTimer = null;
139
209
  let lastPhrase = "";
140
210
  let flushTimer = null;
@@ -184,15 +254,16 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
184
254
  result.catch(() => markStopped());
185
255
  }
186
256
  }
187
- // Safely emit a text delta to the stream.
257
+ // Safely emit a text delta to the stream with AI labels.
188
258
  // On error (e.g. 403 from Teams stop button), abort the controller.
189
259
  function safeEmit(text) {
190
260
  /* v8 ignore next -- defensive guard: stopped set by prior 403; tested via flush abort path @preserve */
191
261
  if (stopped)
192
262
  return;
193
263
  try {
194
- catchAsync(stream.emit(text));
264
+ catchAsync(stream.emit({ text, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } }));
195
265
  streamHasContent = true;
266
+ totalEmitted += text.length;
196
267
  }
197
268
  catch {
198
269
  markStopped();
@@ -207,7 +278,7 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
207
278
  try {
208
279
  // stream.emit() is typed as void but the Teams SDK returns a Promise
209
280
  // internally (async HTTP). Cast to capture the result for awaiting.
210
- const result = stream.emit(text);
281
+ const result = stream.emit({ text, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
211
282
  streamHasContent = true;
212
283
  if (result && typeof result.then === "function") {
213
284
  await result;
@@ -263,11 +334,49 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
263
334
  // emitted text into a single streaming message (cumulative), so every
264
335
  // periodic flush appends to the same response — not separate messages.
265
336
  // No preemptive splitting — sends full text. Error recovery happens in flush().
337
+ // Hybrid MIN_INITIAL_CHARS: hold back until >= MIN_INITIAL_CHARS accumulated
338
+ // before the first content emit, so phrase rotation shows while real content
339
+ // buffers. After first emit, flush normally (no threshold).
266
340
  function flushTextBuffer() {
267
341
  if (!textBuffer)
268
342
  return;
343
+ if (!firstContentEmitted && textBuffer.length < MIN_INITIAL_CHARS)
344
+ return;
345
+ // Proactive >4000 finalization: if cumulative emitted + buffer >= RECOVERY_CHUNK_SIZE,
346
+ // finalize the stream and send overflow via safeSend (follow-up message).
347
+ if (!streamFinalized && totalEmitted + textBuffer.length >= RECOVERY_CHUNK_SIZE) {
348
+ const remaining = RECOVERY_CHUNK_SIZE - totalEmitted;
349
+ /* v8 ignore next 2 -- defensive: remaining always > 0 because finalization runs once @preserve */
350
+ if (remaining > 0)
351
+ safeEmit(textBuffer.slice(0, remaining));
352
+ try {
353
+ stream.close();
354
+ }
355
+ catch { /* stream may already be dead */ }
356
+ streamFinalized = true;
357
+ /* v8 ignore next -- defensive ternary: remaining always > 0 at first finalization @preserve */
358
+ const overflow = textBuffer.slice(remaining > 0 ? remaining : 0);
359
+ textBuffer = "";
360
+ if (overflow)
361
+ safeSend(overflow);
362
+ if (!firstContentEmitted) {
363
+ firstContentEmitted = true;
364
+ stopPhraseRotation();
365
+ }
366
+ return;
367
+ }
368
+ if (streamFinalized) {
369
+ // After finalization, all content goes to safeSend (follow-up messages)
370
+ safeSend(textBuffer);
371
+ textBuffer = "";
372
+ return;
373
+ }
269
374
  safeEmit(textBuffer);
270
375
  textBuffer = "";
376
+ if (!firstContentEmitted) {
377
+ firstContentEmitted = true;
378
+ stopPhraseRotation();
379
+ }
271
380
  }
272
381
  function startPhraseRotation(pool) {
273
382
  stopPhraseRotation();
@@ -310,32 +419,45 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
310
419
  onTextChunk: (text) => {
311
420
  if (stopped)
312
421
  return;
313
- stopPhraseRotation();
422
+ // Don't stop phrase rotation here — let it continue until first content
423
+ // emit (handled in flushTextBuffer when MIN_INITIAL_CHARS threshold met).
314
424
  textBuffer += text;
315
425
  startFlushTimer();
316
426
  },
317
427
  onClearText: () => {
318
428
  textBuffer = "";
319
429
  },
320
- onToolStart: (name, args) => {
321
- stopPhraseRotation();
322
- flushTextBuffer();
323
- // Emit a placeholder to satisfy the 15s Copilot timeout for initial
324
- // stream.emit(). Without this, long tool chains (e.g. ADO batch ops)
325
- // never emit before the timeout and the user sees "this response was
326
- // stopped". The placeholder is replaced by actual content on next emit.
327
- // https://learn.microsoft.com/en-us/answers/questions/2288017/m365-custom-engine-agents-timeout-message-after-15
328
- if (!streamHasContent)
329
- safeEmit("⏳");
330
- const argSummary = (0, tools_1.summarizeArgs)(name, args) || Object.keys(args).join(", ");
331
- safeUpdate(`running ${name} (${argSummary})...`);
332
- hadToolRun = true;
333
- },
334
- onToolEnd: (name, summary, success) => {
335
- stopPhraseRotation();
336
- const msg = (0, format_1.formatToolResult)(name, summary, success);
337
- safeUpdate(msg);
338
- },
430
+ ...(() => {
431
+ const toolCbs = (0, tool_activity_callbacks_1.createToolActivityCallbacks)({
432
+ onDescription: (text) => safeUpdate(text),
433
+ /* v8 ignore next -- onResult only called in debug mode; tested via tool-activity-callbacks.test.ts @preserve */
434
+ onResult: (text) => safeUpdate(text),
435
+ /* v8 ignore next -- onFailure tested via onToolEnd failure test @preserve */
436
+ onFailure: (text) => safeUpdate(text),
437
+ isDebug: commands_2.getDebugMode,
438
+ });
439
+ return {
440
+ onToolStart: (name, args) => {
441
+ stopPhraseRotation();
442
+ // Force-flush any accumulated text, bypassing MIN_INITIAL_CHARS threshold
443
+ firstContentEmitted = true;
444
+ flushTextBuffer();
445
+ // Emit a placeholder to satisfy the 15s Copilot timeout for initial
446
+ // stream.emit(). Without this, long tool chains (e.g. ADO batch ops)
447
+ // never emit before the timeout and the user sees "this response was
448
+ // stopped". The placeholder is replaced by actual content on next emit.
449
+ // https://learn.microsoft.com/en-us/answers/questions/2288017/m365-custom-engine-agents-timeout-message-after-15
450
+ if (!streamHasContent)
451
+ safeEmit("\u23f3");
452
+ toolCbs.onToolStart(name, args);
453
+ hadToolRun = true;
454
+ },
455
+ onToolEnd: (name, summary, success) => {
456
+ stopPhraseRotation();
457
+ toolCbs.onToolEnd(name, summary, success);
458
+ },
459
+ };
460
+ })(),
339
461
  onKick: () => {
340
462
  stopPhraseRotation();
341
463
  const msg = (0, format_1.formatKick)();
@@ -345,7 +467,11 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
345
467
  stopPhraseRotation();
346
468
  if (stopped)
347
469
  return;
348
- const msg = (0, format_1.formatError)(error);
470
+ const msg = (0, progress_story_1.renderProgressStory)((0, progress_story_1.buildProgressStory)({
471
+ scope: "shared-work",
472
+ phase: "errored",
473
+ outcomeText: (0, format_1.formatError)(error),
474
+ }));
349
475
  if (severity === "transient") {
350
476
  safeUpdate(msg);
351
477
  }
@@ -373,18 +499,25 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
373
499
  : undefined,
374
500
  flush: async () => {
375
501
  stopFlushTimer();
502
+ stopPhraseRotation();
376
503
  if (textBuffer) {
504
+ // Bypass MIN_INITIAL_CHARS threshold — flush delivers all remaining content
505
+ firstContentEmitted = true;
377
506
  const text = textBuffer;
378
507
  textBuffer = "";
379
- if (!stopped) {
508
+ if (streamFinalized && sendMessage) {
509
+ // Stream already finalized (>4000 path) — send remaining content as follow-up
510
+ safeSend(text);
511
+ }
512
+ else if (!stopped) {
380
513
  // Stream is alive — await the emit so we can catch async 413/failure
381
514
  // and fall through to sendMessage recovery.
382
515
  const ok = await tryEmit(text);
383
516
  if (!ok)
384
517
  markStopped();
385
518
  }
386
- if (stopped && sendMessage) {
387
- // Stream is dead — fall back to sendMessage; split on failure as recovery.
519
+ if (stopped && !streamFinalized && sendMessage) {
520
+ // Stream is dead (not from finalization) — fall back to sendMessage; split on failure as recovery.
388
521
  try {
389
522
  await sendMessage(text);
390
523
  }
@@ -395,8 +528,8 @@ function createTeamsCallbacks(stream, controller, sendMessage, options) {
395
528
  }
396
529
  }
397
530
  }
398
- else if (!streamHasContent) {
399
- safeEmit("(completed with tool calls only \u2014 no text response)");
531
+ else if (!streamHasContent && !options?.suppressEmptyStreamMessage) {
532
+ safeEmit("(completed with tool calls only — no text response)");
400
533
  }
401
534
  },
402
535
  };
@@ -431,126 +564,234 @@ async function withConversationLock(convId, fn) {
431
564
  // Create a fresh friend store per request so mkdirSync re-runs if directories
432
565
  // are deleted while the process is alive.
433
566
  function getFriendStore() {
434
- // On Azure App Service, os.homedir() returns /root which is ephemeral.
435
- // Use /home/.agentstate/ (persistent) when WEBSITE_SITE_NAME is set.
436
- /* v8 ignore next 3 -- Azure vs local path branch; environment-specific @preserve */
437
- const friendsPath = process.env.WEBSITE_SITE_NAME
438
- ? path.join("/home", ".agentstate", (0, identity_1.getAgentName)(), "friends")
439
- : path.join((0, identity_1.getAgentRoot)(), "friends");
567
+ const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
440
568
  return new store_file_1.FileFriendStore(friendsPath);
441
569
  }
570
+ function createTeamsCommandRegistry() {
571
+ const registry = (0, commands_1.createCommandRegistry)();
572
+ (0, commands_1.registerDefaultCommands)(registry);
573
+ return registry;
574
+ }
575
+ /* v8 ignore start -- superseding follow-up slash command handler; tested via startTeamsApp integration tests @preserve */
576
+ function handleTeamsSlashCommand(text, registry, friendId, conversationId, stream, emitResponse = true) {
577
+ const parsed = (0, commands_1.parseSlashCommand)(text);
578
+ if (!parsed)
579
+ return null;
580
+ const dispatchResult = registry.dispatch(parsed.command, { channel: "teams" });
581
+ if (!dispatchResult.handled || !dispatchResult.result) {
582
+ return null;
583
+ }
584
+ if (dispatchResult.result.action === "new") {
585
+ (0, context_1.deleteSession)((0, config_2.sessionPath)(friendId, "teams", conversationId));
586
+ if (emitResponse) {
587
+ stream.emit("session cleared");
588
+ }
589
+ return "new";
590
+ }
591
+ if (dispatchResult.result.action === "response") {
592
+ if (emitResponse) {
593
+ stream.emit(dispatchResult.result.message || "");
594
+ }
595
+ return "response";
596
+ }
597
+ return null;
598
+ }
599
+ /* v8 ignore stop */
442
600
  // Handle an incoming Teams message
443
- async function handleTeamsMessage(text, stream, conversationId, teamsContext, sendMessage) {
601
+ async function handleTeamsMessage(text, stream, conversationId, teamsContext, sendMessage, reactionOverrides) {
444
602
  const turnKey = teamsTurnKey(conversationId);
445
603
  // NOTE: Confirmation resolution is handled in the app.on("message") handler
446
604
  // BEFORE the conversation lock. By the time we get here, any pending
447
605
  // confirmation has already been resolved and the reply consumed.
448
606
  // Send first thinking phrase immediately so the user sees feedback
449
607
  // before sync I/O (session load, trim) blocks the event loop.
450
- stream.update((0, phrases_1.pickPhrase)((0, phrases_1.getPhrases)().thinking) + "...");
608
+ // Skip for reaction signals — they should be processed quietly.
609
+ if (!reactionOverrides) {
610
+ stream.update((0, phrases_1.pickPhrase)((0, phrases_1.getPhrases)().thinking) + "...");
611
+ }
451
612
  await new Promise(r => setImmediate(r));
452
- // Resolve context kernel (identity + channel) early so we can use the friend UUID for session path
613
+ // Resolve identity provider early for friend resolution + slash command session path
453
614
  const store = getFriendStore();
454
615
  const provider = teamsContext?.aadObjectId ? "aad" : "teams-conversation";
455
616
  const externalId = teamsContext?.aadObjectId || conversationId;
456
- const toolContext = teamsContext ? {
617
+ // Build FriendResolver for the pipeline
618
+ const resolver = new resolver_1.FriendResolver(store, {
619
+ provider,
620
+ externalId,
621
+ tenantId: teamsContext?.tenantId,
622
+ displayName: teamsContext?.displayName || "Unknown",
623
+ channel: "teams",
624
+ });
625
+ // Pre-resolve friend for session path + slash commands (pipeline will re-use the cached result)
626
+ const resolvedContext = await resolver.resolve();
627
+ const friendId = resolvedContext.friend.id;
628
+ // ── Teams adapter concerns: controller, callbacks, session path ──────────
629
+ const controller = new AbortController();
630
+ const channelConfig = (0, config_2.getTeamsChannelConfig)();
631
+ const callbacks = createTeamsCallbacks(stream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs, ...(reactionOverrides?.suppressEmptyStreamMessage ? { suppressEmptyStreamMessage: true } : {}) });
632
+ const traceId = (0, nerves_1.createTraceId)();
633
+ const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
634
+ const teamsCapabilities = (0, channel_1.getChannelCapabilities)("teams");
635
+ const pendingDir = (0, pending_1.getPendingDir)((0, identity_1.getAgentName)(), friendId, "teams", conversationId);
636
+ // Build Teams-specific toolContext fields for injection into the pipeline
637
+ const teamsToolContext = teamsContext ? {
457
638
  graphToken: teamsContext.graphToken,
458
639
  adoToken: teamsContext.adoToken,
459
640
  githubToken: teamsContext.githubToken,
460
641
  signin: teamsContext.signin,
461
- friendStore: store,
462
- summarize: (0, core_1.createSummarize)(),
642
+ summarize: (0, core_1.createSummarize)("human"),
463
643
  tenantId: teamsContext.tenantId,
464
644
  botApi: teamsContext.botApi,
465
- } : undefined;
466
- if (toolContext) {
467
- const resolver = new resolver_1.FriendResolver(store, {
468
- provider,
469
- externalId,
470
- tenantId: teamsContext?.tenantId,
471
- displayName: teamsContext?.displayName || "Unknown",
645
+ } : {};
646
+ let currentText = text;
647
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
648
+ while (true) {
649
+ let drainedSteeringFollowUps = [];
650
+ // Build runAgentOptions with Teams-specific fields
651
+ const agentOptions = {
652
+ traceId,
653
+ toolContext: teamsToolContext,
654
+ mcpManager,
655
+ drainSteeringFollowUps: () => {
656
+ drainedSteeringFollowUps = _turnCoordinator.drainFollowUps(turnKey)
657
+ .map(({ text: followUpText, effect }) => ({ text: followUpText, effect }));
658
+ return drainedSteeringFollowUps;
659
+ },
660
+ ...(reactionOverrides?.isReactionSignal ? { isReactionSignal: true } : {}),
661
+ };
662
+ if (channelConfig.skipConfirmation)
663
+ agentOptions.skipConfirmation = true;
664
+ // ── Call shared pipeline ──────────────────────────────────────────
665
+ // Capture terminal errors — failover message replaces the error card if it triggers
666
+ let capturedTerminalError = null;
667
+ const teamsFailoverState = (() => {
668
+ if (!teamsFailoverStates.has(conversationId)) {
669
+ teamsFailoverStates.set(conversationId, { pending: null });
670
+ }
671
+ return teamsFailoverStates.get(conversationId);
672
+ })();
673
+ /* v8 ignore start -- failover-aware callback wrapper: tested via pipeline integration @preserve */
674
+ const failoverAwareCallbacks = {
675
+ ...callbacks,
676
+ onError: (error, severity) => {
677
+ if (severity === "terminal" && teamsFailoverState) {
678
+ capturedTerminalError = error;
679
+ return;
680
+ }
681
+ callbacks.onError(error, severity);
682
+ },
683
+ };
684
+ /* v8 ignore stop */
685
+ const result = await (0, pipeline_1.handleInboundTurn)({
472
686
  channel: "teams",
473
- });
474
- toolContext.context = await resolver.resolve();
475
- }
476
- const friendId = toolContext?.context?.friend?.id || "default";
477
- if (toolContext?.context?.friend) {
478
- const trustGate = (0, trust_gate_1.enforceTrustGate)({
479
- friend: toolContext.context.friend,
687
+ sessionKey: conversationId,
688
+ capabilities: teamsCapabilities,
689
+ messages: [{ role: "user", content: currentText }],
690
+ continuityIngressTexts: [currentText],
691
+ callbacks: failoverAwareCallbacks,
692
+ friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
693
+ sessionLoader: {
694
+ loadOrCreate: async () => {
695
+ const existing = (0, context_1.loadSession)(sessPath);
696
+ const messages = existing?.messages && existing.messages.length > 0
697
+ ? existing.messages
698
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("teams", { mcpManager }, resolvedContext) }];
699
+ (0, core_1.repairOrphanedToolCalls)(messages);
700
+ return {
701
+ messages,
702
+ sessionPath: sessPath,
703
+ state: existing?.state,
704
+ };
705
+ },
706
+ },
707
+ pendingDir,
708
+ friendStore: store,
480
709
  provider,
481
710
  externalId,
482
711
  tenantId: teamsContext?.tenantId,
483
- channel: "teams",
712
+ isGroupChat: false,
713
+ groupHasFamilyMember: false,
714
+ hasExistingGroupWithFamily: false,
715
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
716
+ drainPending: pending_1.drainPending,
717
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)((0, identity_1.getAgentName)(), deferredFriendId),
718
+ runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
719
+ ...opts,
720
+ toolContext: {
721
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
722
+ signin: async () => undefined,
723
+ ...opts?.toolContext,
724
+ summarize: teamsToolContext.summarize,
725
+ },
726
+ }),
727
+ postTurn: context_1.postTurn,
728
+ accumulateFriendTokens: tokens_1.accumulateFriendTokens,
729
+ signal: controller.signal,
730
+ runAgentOptions: agentOptions,
731
+ failoverState: teamsFailoverState,
484
732
  });
485
- if (!trustGate.allowed) {
486
- if (trustGate.reason === "stranger_first_reply") {
487
- stream.emit(trustGate.autoReply);
488
- }
489
- return;
490
- }
491
- }
492
- const registry = (0, commands_1.createCommandRegistry)();
493
- (0, commands_1.registerDefaultCommands)(registry);
494
- // Check for slash commands
495
- const parsed = (0, commands_1.parseSlashCommand)(text);
496
- if (parsed) {
497
- const dispatchResult = registry.dispatch(parsed.command, { channel: "teams" });
498
- if (dispatchResult.handled && dispatchResult.result) {
499
- if (dispatchResult.result.action === "new") {
500
- const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
733
+ // ── Handle pipeline-intercepted commands ────────────────────────
734
+ if (result.turnOutcome === "command") {
735
+ if (result.commandAction === "new") {
501
736
  (0, context_1.deleteSession)(sessPath);
502
737
  stream.emit("session cleared");
503
- return;
504
738
  }
505
- else if (dispatchResult.result.action === "response") {
506
- stream.emit(dispatchResult.result.message || "");
507
- return;
739
+ // For "response" commands: pipeline already emitted the response via onTextChunk
740
+ await callbacks.flush();
741
+ return;
742
+ }
743
+ /* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
744
+ if (result.failoverMessage) {
745
+ stream.emit(result.failoverMessage);
746
+ }
747
+ else if (capturedTerminalError) {
748
+ callbacks.onError(capturedTerminalError, "terminal");
749
+ }
750
+ /* v8 ignore stop */
751
+ // ── Handle gate result ────────────────────────────────────────
752
+ if (!result.gateResult.allowed) {
753
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
754
+ stream.emit(result.gateResult.autoReply);
508
755
  }
756
+ return;
757
+ }
758
+ // Flush any remaining accumulated text at end of turn
759
+ await callbacks.flush();
760
+ // After the agent loop, check if any tool returned AUTH_REQUIRED and trigger signin.
761
+ // This must happen after the stream is done so the OAuth card renders properly.
762
+ if (teamsContext && result.messages) {
763
+ const allContent = result.messages.map(m => typeof m.content === "string" ? m.content : "").join("\n");
764
+ if (allContent.includes("AUTH_REQUIRED:graph") && teamsContext.graphConnectionName)
765
+ await teamsContext.signin(teamsContext.graphConnectionName);
766
+ if (allContent.includes("AUTH_REQUIRED:ado") && teamsContext.adoConnectionName)
767
+ await teamsContext.signin(teamsContext.adoConnectionName);
768
+ if (allContent.includes("AUTH_REQUIRED:github") && teamsContext.githubConnectionName)
769
+ await teamsContext.signin(teamsContext.githubConnectionName);
509
770
  }
771
+ if (result.turnOutcome !== "superseded") {
772
+ return;
773
+ }
774
+ const supersedingIndex = drainedSteeringFollowUps
775
+ .map((followUp) => followUp.effect)
776
+ .lastIndexOf("clear_and_supersede");
777
+ if (supersedingIndex < 0) {
778
+ return;
779
+ }
780
+ const supersedingFollowUp = drainedSteeringFollowUps[supersedingIndex];
781
+ const replayTail = drainedSteeringFollowUps
782
+ .slice(supersedingIndex + 1)
783
+ .map((followUp) => followUp.text.trim())
784
+ .filter((followUpText) => followUpText.length > 0)
785
+ .join("\n");
786
+ if (replayTail) {
787
+ currentText = replayTail;
788
+ continue;
789
+ }
790
+ if (handleTeamsSlashCommand(supersedingFollowUp.text, createTeamsCommandRegistry(), friendId, conversationId, stream, false)) {
791
+ return;
792
+ }
793
+ currentText = supersedingFollowUp.text;
510
794
  }
511
- // Load or create session
512
- const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
513
- const existing = (0, context_1.loadSession)(sessPath);
514
- const messages = existing?.messages && existing.messages.length > 0
515
- ? existing.messages
516
- : [{ role: "system", content: await (0, prompt_1.buildSystem)("teams", undefined, toolContext?.context) }];
517
- // Repair any orphaned tool calls from a previous aborted turn
518
- (0, core_1.repairOrphanedToolCalls)(messages);
519
- // Push user message
520
- messages.push({ role: "user", content: text });
521
- // Run agent
522
- const controller = new AbortController();
523
- const channelConfig = (0, config_2.getTeamsChannelConfig)();
524
- const callbacks = createTeamsCallbacks(stream, controller, sendMessage, { conversationId, flushIntervalMs: channelConfig.flushIntervalMs });
525
- const traceId = (0, nerves_1.createTraceId)();
526
- const agentOptions = {};
527
- agentOptions.traceId = traceId;
528
- if (toolContext)
529
- agentOptions.toolContext = toolContext;
530
- if (channelConfig.skipConfirmation)
531
- agentOptions.skipConfirmation = true;
532
- agentOptions.drainSteeringFollowUps = () => _turnCoordinator.drainFollowUps(turnKey).map((m) => ({ text: m.text }));
533
- const result = await (0, core_1.runAgent)(messages, callbacks, "teams", controller.signal, agentOptions);
534
- // Flush any remaining accumulated text at end of turn
535
- await callbacks.flush();
536
- // After the agent loop, check if any tool returned AUTH_REQUIRED and trigger signin.
537
- // This must happen after the stream is done so the OAuth card renders properly.
538
- if (teamsContext) {
539
- const allContent = messages.map(m => typeof m.content === "string" ? m.content : "").join("\n");
540
- if (allContent.includes("AUTH_REQUIRED:graph") && teamsContext.graphConnectionName)
541
- await teamsContext.signin(teamsContext.graphConnectionName);
542
- if (allContent.includes("AUTH_REQUIRED:ado") && teamsContext.adoConnectionName)
543
- await teamsContext.signin(teamsContext.adoConnectionName);
544
- if (allContent.includes("AUTH_REQUIRED:github") && teamsContext.githubConnectionName)
545
- await teamsContext.signin(teamsContext.githubConnectionName);
546
- }
547
- // Trim context and save session
548
- (0, context_1.postTurn)(messages, sessPath, result.usage);
549
- // Accumulate token usage on friend record
550
- if (toolContext?.context?.friend?.id) {
551
- await (0, tokens_1.accumulateFriendTokens)(store, toolContext.context.friend.id, result.usage);
552
- }
553
- // SDK auto-closes the stream after our handler returns (app.process.js)
554
795
  }
555
796
  // Internal port for the secondary bot App (not exposed externally).
556
797
  // The primary app proxies /api/messages-secondary → localhost:SECONDARY_PORT/api/messages.
@@ -646,6 +887,72 @@ function registerBotHandlers(app, label) {
646
887
  (0, runtime_1.emitNervesEvent)({ level: "warn", event: "channel.verify_state", component: "channels", message: `[${label}] verify-state failed for all connections`, meta: {} });
647
888
  return { status: 412 };
648
889
  });
890
+ // Handle Teams feedback reactions (thumbs up/down on AI-generated messages).
891
+ // SDK routes message/submitAction with actionName "feedback" to this event.
892
+ /* v8 ignore start -- Teams SDK invoke handler; requires live SDK context @preserve */
893
+ app.on("message.submit.feedback", async (ctx) => {
894
+ const { stream, activity } = ctx;
895
+ const reaction = activity.value?.actionValue?.reaction;
896
+ const comment = activity.value?.actionValue?.feedback;
897
+ const convId = activity.conversation?.id || "unknown";
898
+ const turnKey = teamsTurnKey(convId);
899
+ // Validate payload — graceful no-op for malformed invocations
900
+ if (activity.value?.actionName !== "feedback" || !reaction) {
901
+ return;
902
+ }
903
+ const syntheticText = buildFeedbackSyntheticText(reaction, comment);
904
+ // Turn coordination: if a turn is active, enqueue as steering follow-up
905
+ if (!_turnCoordinator.tryBeginTurn(turnKey)) {
906
+ _turnCoordinator.enqueueFollowUp(turnKey, {
907
+ conversationId: convId,
908
+ text: syntheticText,
909
+ receivedAt: Date.now(),
910
+ effect: (0, continuity_1.classifySteeringFollowUpEffect)(syntheticText),
911
+ });
912
+ return;
913
+ }
914
+ try {
915
+ const teamsContext = {
916
+ signin: async () => undefined,
917
+ aadObjectId: activity.from?.aadObjectId,
918
+ tenantId: activity.conversation?.tenantId,
919
+ displayName: activity.from?.name,
920
+ };
921
+ const ctxSend = async (t) => {
922
+ await ctx.send({ type: "message", text: t, replyToId: activity.replyToId, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
923
+ };
924
+ await handleTeamsMessage(syntheticText, stream, convId, teamsContext, ctxSend, { isReactionSignal: true, suppressEmptyStreamMessage: true });
925
+ }
926
+ catch (err) {
927
+ const msg = err instanceof Error ? err.message : String(err);
928
+ (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.feedback_handler_error", component: "channels", message: msg.slice(0, 200), meta: {} });
929
+ }
930
+ finally {
931
+ _turnCoordinator.endTurn(turnKey);
932
+ }
933
+ });
934
+ /* v8 ignore stop */
935
+ // Handle bot install — send welcome Adaptive Card with prompt starters.
936
+ /* v8 ignore start -- Teams SDK install handler; requires live SDK context @preserve */
937
+ app.on("install.add", async (ctx) => {
938
+ try {
939
+ const card = buildWelcomeCard();
940
+ await ctx.send({
941
+ type: "message",
942
+ attachments: [{
943
+ contentType: "application/vnd.microsoft.card.adaptive",
944
+ content: card,
945
+ }],
946
+ entities: aiLabelEntities(),
947
+ channelData: { feedbackLoopEnabled: true },
948
+ });
949
+ }
950
+ catch (err) {
951
+ const msg = err instanceof Error ? err.message : String(err);
952
+ (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.welcome_handler_error", component: "channels", message: msg.slice(0, 200), meta: {} });
953
+ }
954
+ });
955
+ /* v8 ignore stop */
649
956
  app.on("message", async (ctx) => {
650
957
  const { stream, activity, api, signin } = ctx;
651
958
  const text = activity.text || "";
@@ -661,6 +968,41 @@ function registerBotHandlers(app, label) {
661
968
  if (resolvePendingConfirmation(convId, text)) {
662
969
  return;
663
970
  }
971
+ const commandRegistry = createTeamsCommandRegistry();
972
+ const parsedSlashCommand = (0, commands_1.parseSlashCommand)(text);
973
+ if (parsedSlashCommand) {
974
+ const dispatchResult = commandRegistry.dispatch(parsedSlashCommand.command, { channel: "teams" });
975
+ if (dispatchResult.handled && dispatchResult.result) {
976
+ if (dispatchResult.result.action === "response") {
977
+ stream.emit(dispatchResult.result.message || "");
978
+ return;
979
+ }
980
+ if (dispatchResult.result.action === "new") {
981
+ const commandStore = getFriendStore();
982
+ const commandProvider = activity.from?.aadObjectId ? "aad" : "teams-conversation";
983
+ const commandExternalId = activity.from?.aadObjectId || convId;
984
+ const commandResolver = new resolver_1.FriendResolver(commandStore, {
985
+ provider: commandProvider,
986
+ externalId: commandExternalId,
987
+ tenantId: activity.conversation?.tenantId,
988
+ displayName: activity.from?.name || "Unknown",
989
+ channel: "teams",
990
+ });
991
+ const commandContext = await commandResolver.resolve();
992
+ (0, context_1.deleteSession)((0, config_2.sessionPath)(commandContext.friend.id, "teams", convId));
993
+ stream.emit("session cleared");
994
+ if (_turnCoordinator.isTurnActive(turnKey)) {
995
+ _turnCoordinator.enqueueFollowUp(turnKey, {
996
+ conversationId: convId,
997
+ text,
998
+ receivedAt: Date.now(),
999
+ effect: "clear_and_supersede",
1000
+ });
1001
+ }
1002
+ return;
1003
+ }
1004
+ }
1005
+ }
664
1006
  // If this conversation already has an active turn, steer follow-up input
665
1007
  // into that turn and avoid starting a second concurrent turn.
666
1008
  if (!_turnCoordinator.tryBeginTurn(turnKey)) {
@@ -668,6 +1010,7 @@ function registerBotHandlers(app, label) {
668
1010
  conversationId: convId,
669
1011
  text,
670
1012
  receivedAt: Date.now(),
1013
+ effect: (0, continuity_1.classifySteeringFollowUpEffect)(text),
671
1014
  });
672
1015
  return;
673
1016
  }
@@ -725,7 +1068,7 @@ function registerBotHandlers(app, label) {
725
1068
  const ctxSend = async (t) => {
726
1069
  // Use send with replyToId (not reply, which adds a blockquote).
727
1070
  // replyToId anchors the message after the user's message in Copilot Chat.
728
- await ctx.send({ type: "message", text: t, replyToId: activity.id });
1071
+ await ctx.send({ type: "message", text: t, replyToId: activity.id, entities: aiLabelEntities(), channelData: { feedbackLoopEnabled: true } });
729
1072
  };
730
1073
  await handleTeamsMessage(text, stream, convId, teamsContext, ctxSend);
731
1074
  }
@@ -742,6 +1085,210 @@ function registerBotHandlers(app, label) {
742
1085
  (0, runtime_1.emitNervesEvent)({ level: "error", event: "channel.app_error", component: "channels", message: `[${label}] ${msg}`, meta: {} });
743
1086
  });
744
1087
  }
1088
+ function findAadObjectId(friend) {
1089
+ for (const ext of friend.externalIds) {
1090
+ if (ext.provider === "aad" && !ext.externalId.startsWith("group:")) {
1091
+ return { aadObjectId: ext.externalId, tenantId: ext.tenantId };
1092
+ }
1093
+ }
1094
+ return undefined;
1095
+ }
1096
+ function resolveTeamsFriendStore(deps) {
1097
+ return deps.store
1098
+ ?? deps.createFriendStore?.()
1099
+ ?? new store_file_1.FileFriendStore(path.join((0, identity_1.getAgentRoot)(), "friends"));
1100
+ }
1101
+ function getTeamsConversations(botApi) {
1102
+ return botApi.conversations;
1103
+ }
1104
+ function hasExplicitCrossChatAuthorization(params) {
1105
+ return params.intent === "explicit_cross_chat"
1106
+ && types_1.TRUSTED_LEVELS.has(params.authorizingSession?.trustLevel ?? "stranger");
1107
+ }
1108
+ async function sendProactiveTeamsMessageToSession(params, deps) {
1109
+ const store = resolveTeamsFriendStore(deps);
1110
+ const conversations = getTeamsConversations(deps.botApi);
1111
+ let friend;
1112
+ try {
1113
+ friend = await store.get(params.friendId);
1114
+ }
1115
+ catch {
1116
+ friend = null;
1117
+ }
1118
+ if (!friend) {
1119
+ (0, runtime_1.emitNervesEvent)({
1120
+ level: "warn",
1121
+ component: "senses",
1122
+ event: "senses.teams_proactive_no_friend",
1123
+ message: "proactive send skipped: friend not found",
1124
+ meta: { friendId: params.friendId, sessionKey: params.sessionKey },
1125
+ });
1126
+ return { delivered: false, reason: "friend_not_found" };
1127
+ }
1128
+ if (!hasExplicitCrossChatAuthorization(params) && !types_1.TRUSTED_LEVELS.has(friend.trustLevel ?? "stranger")) {
1129
+ (0, runtime_1.emitNervesEvent)({
1130
+ component: "senses",
1131
+ event: "senses.teams_proactive_trust_skip",
1132
+ message: "proactive send skipped: trust level not allowed",
1133
+ meta: {
1134
+ friendId: params.friendId,
1135
+ trustLevel: friend.trustLevel ?? "unknown",
1136
+ intent: params.intent ?? "generic_outreach",
1137
+ authorizingTrustLevel: params.authorizingSession?.trustLevel ?? null,
1138
+ },
1139
+ });
1140
+ return { delivered: false, reason: "trust_skip" };
1141
+ }
1142
+ const aadInfo = findAadObjectId(friend);
1143
+ if (!aadInfo) {
1144
+ (0, runtime_1.emitNervesEvent)({
1145
+ level: "warn",
1146
+ component: "senses",
1147
+ event: "senses.teams_proactive_no_aad_id",
1148
+ message: "proactive send skipped: no AAD object ID found",
1149
+ meta: { friendId: params.friendId, sessionKey: params.sessionKey },
1150
+ });
1151
+ return { delivered: false, reason: "missing_target" };
1152
+ }
1153
+ try {
1154
+ const conversation = await conversations.create({
1155
+ bot: { id: deps.botApi.id },
1156
+ members: [{ id: aadInfo.aadObjectId, role: "user", name: friend.name || aadInfo.aadObjectId }],
1157
+ tenantId: aadInfo.tenantId,
1158
+ isGroup: false,
1159
+ });
1160
+ await conversations.activities(conversation.id).create({
1161
+ type: "message",
1162
+ text: params.text,
1163
+ });
1164
+ (0, runtime_1.emitNervesEvent)({
1165
+ component: "senses",
1166
+ event: "senses.teams_proactive_sent",
1167
+ message: "proactive teams message sent",
1168
+ meta: { friendId: params.friendId, aadObjectId: aadInfo.aadObjectId, sessionKey: params.sessionKey },
1169
+ });
1170
+ return { delivered: true };
1171
+ }
1172
+ catch (error) {
1173
+ (0, runtime_1.emitNervesEvent)({
1174
+ level: "error",
1175
+ component: "senses",
1176
+ event: "senses.teams_proactive_send_error",
1177
+ message: "proactive teams send failed",
1178
+ meta: {
1179
+ friendId: params.friendId,
1180
+ aadObjectId: aadInfo.aadObjectId,
1181
+ sessionKey: params.sessionKey,
1182
+ reason: error instanceof Error ? error.message : String(error),
1183
+ },
1184
+ });
1185
+ return { delivered: false, reason: "send_error" };
1186
+ }
1187
+ }
1188
+ function scanPendingTeamsFiles(pendingRoot) {
1189
+ const results = [];
1190
+ let friendIds;
1191
+ try {
1192
+ friendIds = fs.readdirSync(pendingRoot);
1193
+ }
1194
+ catch {
1195
+ return results;
1196
+ }
1197
+ for (const friendId of friendIds) {
1198
+ const teamsDir = path.join(pendingRoot, friendId, "teams");
1199
+ let keys;
1200
+ try {
1201
+ keys = fs.readdirSync(teamsDir);
1202
+ }
1203
+ catch {
1204
+ continue;
1205
+ }
1206
+ for (const key of keys) {
1207
+ const keyDir = path.join(teamsDir, key);
1208
+ let files;
1209
+ try {
1210
+ files = fs.readdirSync(keyDir);
1211
+ }
1212
+ catch {
1213
+ continue;
1214
+ }
1215
+ for (const file of files.filter((f) => f.endsWith(".json")).sort()) {
1216
+ const filePath = path.join(keyDir, file);
1217
+ try {
1218
+ const content = fs.readFileSync(filePath, "utf-8");
1219
+ results.push({ friendId, key, filePath, content });
1220
+ }
1221
+ catch {
1222
+ // skip unreadable files
1223
+ }
1224
+ }
1225
+ }
1226
+ }
1227
+ return results;
1228
+ }
1229
+ async function drainAndSendPendingTeams(store, botApi, pendingRoot) {
1230
+ const root = pendingRoot ?? path.join((0, identity_1.getAgentRoot)(), "state", "pending");
1231
+ const pendingFiles = scanPendingTeamsFiles(root);
1232
+ const result = { sent: 0, skipped: 0, failed: 0 };
1233
+ for (const { friendId, key, filePath, content } of pendingFiles) {
1234
+ let parsed;
1235
+ try {
1236
+ parsed = JSON.parse(content);
1237
+ }
1238
+ catch {
1239
+ result.failed++;
1240
+ try {
1241
+ fs.unlinkSync(filePath);
1242
+ }
1243
+ catch { /* ignore */ }
1244
+ continue;
1245
+ }
1246
+ const messageText = typeof parsed.content === "string" ? parsed.content : "";
1247
+ if (!messageText.trim()) {
1248
+ result.skipped++;
1249
+ try {
1250
+ fs.unlinkSync(filePath);
1251
+ }
1252
+ catch { /* ignore */ }
1253
+ continue;
1254
+ }
1255
+ const sendResult = await sendProactiveTeamsMessageToSession({
1256
+ friendId,
1257
+ sessionKey: key,
1258
+ text: messageText,
1259
+ intent: "generic_outreach",
1260
+ }, {
1261
+ botApi,
1262
+ store,
1263
+ });
1264
+ if (sendResult.delivered) {
1265
+ result.sent++;
1266
+ try {
1267
+ fs.unlinkSync(filePath);
1268
+ }
1269
+ catch { /* ignore */ }
1270
+ continue;
1271
+ }
1272
+ if (sendResult.reason === "friend_not_found" || sendResult.reason === "trust_skip" || sendResult.reason === "missing_target") {
1273
+ result.skipped++;
1274
+ try {
1275
+ fs.unlinkSync(filePath);
1276
+ }
1277
+ catch { /* ignore */ }
1278
+ continue;
1279
+ }
1280
+ result.failed++;
1281
+ }
1282
+ if (result.sent > 0 || result.skipped > 0 || result.failed > 0) {
1283
+ (0, runtime_1.emitNervesEvent)({
1284
+ component: "senses",
1285
+ event: "senses.teams_proactive_drain_complete",
1286
+ message: "teams proactive drain complete",
1287
+ meta: { sent: result.sent, skipped: result.skipped, failed: result.failed },
1288
+ });
1289
+ }
1290
+ return result;
1291
+ }
745
1292
  // Start the Teams app in DevtoolsPlugin mode (local dev) or Bot Service mode (real Teams).
746
1293
  // Mode is determined by getTeamsConfig().clientId.
747
1294
  // Text is always accumulated in textBuffer and flushed periodically (chunked streaming).