@ouro.bot/cli 0.1.0-alpha.41 → 0.1.0-alpha.43

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,19 @@
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.43",
6
+ "changes": [
7
+ "Continuity-aware onboarding now stays contextual: when an active task goes idle, onboarding guidance can reappear during genuine lulls instead of staying hidden just because no-handoff state was previously persisted.",
8
+ "Teams active-turn controls are safer: `/new` still clears the current session during long-running turns, and superseding follow-ups keep the user's replacement ask instead of dropping it."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.42",
13
+ "changes": [
14
+ "Associative recall now skips corrupt JSONL lines instead of crashing — matches the resilient pattern already used in memory.ts."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.1.0-alpha.41",
6
19
  "changes": [
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasToolIntent = exports.buildSystem = exports.toResponsesTools = exports.toResponsesInput = exports.streamResponsesApi = exports.streamChatCompletion = exports.getToolsForChannel = exports.summarizeArgs = exports.execTool = exports.tools = void 0;
3
4
  exports.createProviderRegistry = createProviderRegistry;
4
5
  exports.resetProviderRuntime = resetProviderRuntime;
5
6
  exports.getModel = getModel;
@@ -110,6 +111,53 @@ function getProviderDisplayLabel() {
110
111
  };
111
112
  return providerLabelBuilders[getProvider()]();
112
113
  }
114
+ // Re-export tools, execTool, summarizeArgs from ./tools for backward compat
115
+ var tools_2 = require("../repertoire/tools");
116
+ Object.defineProperty(exports, "tools", { enumerable: true, get: function () { return tools_2.tools; } });
117
+ Object.defineProperty(exports, "execTool", { enumerable: true, get: function () { return tools_2.execTool; } });
118
+ Object.defineProperty(exports, "summarizeArgs", { enumerable: true, get: function () { return tools_2.summarizeArgs; } });
119
+ Object.defineProperty(exports, "getToolsForChannel", { enumerable: true, get: function () { return tools_2.getToolsForChannel; } });
120
+ // Re-export streaming functions for backward compat
121
+ var streaming_1 = require("./streaming");
122
+ Object.defineProperty(exports, "streamChatCompletion", { enumerable: true, get: function () { return streaming_1.streamChatCompletion; } });
123
+ Object.defineProperty(exports, "streamResponsesApi", { enumerable: true, get: function () { return streaming_1.streamResponsesApi; } });
124
+ Object.defineProperty(exports, "toResponsesInput", { enumerable: true, get: function () { return streaming_1.toResponsesInput; } });
125
+ Object.defineProperty(exports, "toResponsesTools", { enumerable: true, get: function () { return streaming_1.toResponsesTools; } });
126
+ // Re-export prompt functions for backward compat
127
+ var prompt_2 = require("../mind/prompt");
128
+ Object.defineProperty(exports, "buildSystem", { enumerable: true, get: function () { return prompt_2.buildSystem; } });
129
+ function parseFinalAnswerPayload(argumentsText) {
130
+ try {
131
+ const parsed = JSON.parse(argumentsText);
132
+ if (typeof parsed === "string") {
133
+ return { answer: parsed };
134
+ }
135
+ if (!parsed || typeof parsed !== "object") {
136
+ return {};
137
+ }
138
+ const answer = typeof parsed.answer === "string" ? parsed.answer : undefined;
139
+ const rawIntent = parsed.intent;
140
+ const intent = rawIntent === "complete" || rawIntent === "blocked" || rawIntent === "direct_reply"
141
+ ? rawIntent
142
+ : undefined;
143
+ return { answer, intent };
144
+ }
145
+ catch {
146
+ return {};
147
+ }
148
+ }
149
+ function getFinalAnswerRetryError(mustResolveBeforeHandoff, intent, sawSteeringFollowUp) {
150
+ if (mustResolveBeforeHandoff && !intent) {
151
+ return "your final_answer is missing required intent. when you must keep going until done or blocked, call final_answer again with answer plus intent=complete, blocked, or direct_reply.";
152
+ }
153
+ if (mustResolveBeforeHandoff && intent === "direct_reply" && !sawSteeringFollowUp) {
154
+ return "your final_answer used intent=direct_reply without a newer steering follow-up. continue the unresolved work, or call final_answer again with intent=complete or blocked when appropriate.";
155
+ }
156
+ return "your final_answer was incomplete or malformed. call final_answer again with your complete response.";
157
+ }
158
+ // Re-export kick utilities for backward compat
159
+ var kicks_1 = require("./kicks");
160
+ Object.defineProperty(exports, "hasToolIntent", { enumerable: true, get: function () { return kicks_1.hasToolIntent; } });
113
161
  function upsertSystemPrompt(messages, systemText) {
114
162
  const systemMessage = { role: "system", content: systemText };
115
163
  if (messages[0]?.role === "system") {
@@ -310,6 +358,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
310
358
  let lastUsage;
311
359
  let overflowRetried = false;
312
360
  let retryCount = 0;
361
+ let outcome = "complete";
362
+ let sawSteeringFollowUp = false;
363
+ let mustResolveBeforeHandoffActive = options?.mustResolveBeforeHandoff === true;
313
364
  // Prevent MaxListenersExceeded warning — each iteration adds a listener
314
365
  try {
315
366
  require("events").setMaxListeners(50, signal);
@@ -328,6 +379,18 @@ async function runAgent(messages, callbacks, channel, signal, options) {
328
379
  const activeTools = toolChoiceRequired ? [...baseTools, tools_1.finalAnswerTool] : baseTools;
329
380
  const steeringFollowUps = options?.drainSteeringFollowUps?.() ?? [];
330
381
  if (steeringFollowUps.length > 0) {
382
+ const hasSupersedingFollowUp = steeringFollowUps.some((followUp) => followUp.effect === "clear_and_supersede");
383
+ if (hasSupersedingFollowUp) {
384
+ mustResolveBeforeHandoffActive = false;
385
+ options?.setMustResolveBeforeHandoff?.(false);
386
+ outcome = "superseded";
387
+ break;
388
+ }
389
+ if (steeringFollowUps.some((followUp) => followUp.effect === "set_no_handoff")) {
390
+ mustResolveBeforeHandoffActive = true;
391
+ options?.setMustResolveBeforeHandoff?.(true);
392
+ }
393
+ sawSteeringFollowUp = true;
331
394
  for (const followUp of steeringFollowUps) {
332
395
  messages.push({ role: "user", content: followUp.text });
333
396
  }
@@ -335,8 +398,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
335
398
  }
336
399
  // Yield so pending I/O (stdin Ctrl-C) can be processed between iterations
337
400
  await new Promise((r) => setImmediate(r));
338
- if (signal?.aborted)
401
+ if (signal?.aborted) {
402
+ outcome = "aborted";
339
403
  break;
404
+ }
340
405
  try {
341
406
  callbacks.onModelStart();
342
407
  const result = await providerRuntime.streamTurn({
@@ -381,22 +446,13 @@ async function runAgent(messages, callbacks, channel, signal, options) {
381
446
  const isSoleFinalAnswer = result.toolCalls.length === 1 && result.toolCalls[0].name === "final_answer";
382
447
  if (isSoleFinalAnswer) {
383
448
  // Extract answer from the tool call arguments.
384
- // Supports: {"answer":"text"}, "text" (JSON string), retry on failure.
385
- let answer;
386
- try {
387
- const parsed = JSON.parse(result.toolCalls[0].arguments);
388
- if (typeof parsed === "string") {
389
- answer = parsed;
390
- }
391
- else if (parsed.answer != null) {
392
- answer = parsed.answer;
393
- }
394
- // else: valid JSON but no answer field — answer stays undefined (retry)
395
- }
396
- catch {
397
- // JSON parsing failed (e.g. truncated output) — answer stays undefined (retry)
398
- }
399
- if (answer != null) {
449
+ // Supports: {"answer":"text","intent":"..."} or "text" (JSON string).
450
+ const { answer, intent } = parseFinalAnswerPayload(result.toolCalls[0].arguments);
451
+ const validDirectReply = mustResolveBeforeHandoffActive && intent === "direct_reply" && sawSteeringFollowUp;
452
+ const validTerminalIntent = intent === "complete" || intent === "blocked";
453
+ const validClosure = answer != null
454
+ && (!mustResolveBeforeHandoffActive || validDirectReply || validTerminalIntent);
455
+ if (validClosure) {
400
456
  if (result.finalAnswerStreamed) {
401
457
  // The streaming layer already parsed and emitted the answer
402
458
  // progressively via FinalAnswerParser. Skip clearing and
@@ -409,19 +465,26 @@ async function runAgent(messages, callbacks, channel, signal, options) {
409
465
  // Never truncate -- channel adapters handle splitting long messages.
410
466
  callbacks.onTextChunk(answer);
411
467
  }
412
- // Keep the full assistant message (with tool_calls) for debuggability,
413
- // plus a synthetic tool response so the conversation stays valid on resume.
414
468
  messages.push(msg);
415
- messages.push({ role: "tool", tool_call_id: result.toolCalls[0].id, content: "(delivered)" });
416
- providerRuntime.appendToolOutput(result.toolCalls[0].id, "(delivered)");
417
- done = true;
469
+ if (validDirectReply) {
470
+ const resumeWork = "direct reply delivered. resume the unresolved obligation now and keep working until you can finish or clearly report that you are blocked.";
471
+ messages.push({ role: "tool", tool_call_id: result.toolCalls[0].id, content: resumeWork });
472
+ providerRuntime.appendToolOutput(result.toolCalls[0].id, resumeWork);
473
+ }
474
+ else {
475
+ const delivered = "(delivered)";
476
+ messages.push({ role: "tool", tool_call_id: result.toolCalls[0].id, content: delivered });
477
+ providerRuntime.appendToolOutput(result.toolCalls[0].id, delivered);
478
+ outcome = intent === "blocked" ? "blocked" : "complete";
479
+ done = true;
480
+ }
418
481
  }
419
482
  else {
420
483
  // Answer is undefined -- the model's final_answer was incomplete or
421
484
  // malformed. Clear any partial streamed text or noise, then push the
422
485
  // assistant msg + error tool result and let the model try again.
423
486
  callbacks.onClearText?.();
424
- const retryError = "your final_answer was incomplete or malformed. call final_answer again with your complete response.";
487
+ const retryError = getFinalAnswerRetryError(mustResolveBeforeHandoffActive, intent, sawSteeringFollowUp);
425
488
  messages.push(msg);
426
489
  messages.push({ role: "tool", tool_call_id: result.toolCalls[0].id, content: retryError });
427
490
  providerRuntime.appendToolOutput(result.toolCalls[0].id, retryError);
@@ -485,6 +548,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
485
548
  // Abort is not an error — just stop cleanly
486
549
  if (signal?.aborted) {
487
550
  stripLastToolCalls(messages);
551
+ outcome = "aborted";
488
552
  break;
489
553
  }
490
554
  // Context overflow: trim aggressively and retry once
@@ -519,6 +583,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
519
583
  });
520
584
  if (aborted) {
521
585
  stripLastToolCalls(messages);
586
+ outcome = "aborted";
522
587
  break;
523
588
  }
524
589
  providerRuntime.resetTurnState(messages);
@@ -534,6 +599,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
534
599
  meta: {},
535
600
  });
536
601
  stripLastToolCalls(messages);
602
+ outcome = "errored";
537
603
  done = true;
538
604
  }
539
605
  }
@@ -544,5 +610,5 @@ async function runAgent(messages, callbacks, channel, signal, options) {
544
610
  message: "runAgent turn completed",
545
611
  meta: { done },
546
612
  });
547
- return { usage: lastUsage };
613
+ return { usage: lastUsage, outcome };
548
614
  }
@@ -87,7 +87,19 @@ function readFacts(memoryRoot) {
87
87
  const raw = fs.readFileSync(factsPath, "utf8").trim();
88
88
  if (!raw)
89
89
  return [];
90
- return raw.split("\n").map((line) => JSON.parse(line));
90
+ const facts = [];
91
+ for (const line of raw.split("\n")) {
92
+ const trimmed = line.trim();
93
+ if (!trimmed)
94
+ continue;
95
+ try {
96
+ facts.push(JSON.parse(trimmed));
97
+ }
98
+ catch {
99
+ // Skip corrupt lines (e.g. partial write from a crash).
100
+ }
101
+ }
102
+ return facts;
91
103
  }
92
104
  function getLatestUserText(messages) {
93
105
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -190,7 +202,7 @@ async function injectAssociativeRecall(messages, options) {
190
202
  event: "mind.associative_recall_error",
191
203
  message: "associative recall failed",
192
204
  meta: {
193
- reason: error instanceof Error ? error.message : String(error),
205
+ reason: error instanceof Error ? error.message : /* v8 ignore start -- defensive: non-Error catch branch @preserve */ String(error) /* v8 ignore stop */,
194
206
  },
195
207
  });
196
208
  }
@@ -227,7 +227,7 @@ function repairSessionMessages(messages) {
227
227
  });
228
228
  return result;
229
229
  }
230
- function saveSession(filePath, messages, lastUsage) {
230
+ function saveSession(filePath, messages, lastUsage, state) {
231
231
  const violations = validateSessionMessages(messages);
232
232
  if (violations.length > 0) {
233
233
  (0, runtime_1.emitNervesEvent)({
@@ -243,6 +243,9 @@ function saveSession(filePath, messages, lastUsage) {
243
243
  const envelope = { version: 1, messages };
244
244
  if (lastUsage)
245
245
  envelope.lastUsage = lastUsage;
246
+ if (state?.mustResolveBeforeHandoff === true) {
247
+ envelope.state = { mustResolveBeforeHandoff: true };
248
+ }
246
249
  fs.writeFileSync(filePath, JSON.stringify(envelope, null, 2));
247
250
  }
248
251
  function loadSession(filePath) {
@@ -263,13 +266,18 @@ function loadSession(filePath) {
263
266
  });
264
267
  messages = repairSessionMessages(messages);
265
268
  }
266
- return { messages, lastUsage: data.lastUsage };
269
+ const state = data?.state && typeof data.state === "object" && data.state !== null
270
+ && typeof data.state.mustResolveBeforeHandoff === "boolean"
271
+ && data.state.mustResolveBeforeHandoff === true
272
+ ? { mustResolveBeforeHandoff: true }
273
+ : undefined;
274
+ return { messages, lastUsage: data.lastUsage, state };
267
275
  }
268
276
  catch {
269
277
  return null;
270
278
  }
271
279
  }
272
- function postTurn(messages, sessPath, usage, hooks) {
280
+ function postTurn(messages, sessPath, usage, hooks, state) {
273
281
  if (hooks?.beforeTrim) {
274
282
  try {
275
283
  hooks.beforeTrim([...messages]);
@@ -289,7 +297,7 @@ function postTurn(messages, sessPath, usage, hooks) {
289
297
  const { maxTokens, contextMargin } = (0, config_1.getContextConfig)();
290
298
  const trimmed = trimMessages(messages, maxTokens, contextMargin, usage?.input_tokens);
291
299
  messages.splice(0, messages.length, ...trimmed);
292
- saveSession(sessPath, messages, usage);
300
+ saveSession(sessPath, messages, usage, state);
293
301
  }
294
302
  function deleteSession(filePath) {
295
303
  try {
@@ -11,9 +11,22 @@ exports.ONBOARDING_TOKEN_THRESHOLD = 100_000;
11
11
  function isOnboarding(friend) {
12
12
  return (friend.totalTokens ?? 0) < exports.ONBOARDING_TOKEN_THRESHOLD;
13
13
  }
14
- function getFirstImpressions(friend) {
14
+ function hasLiveContinuityPressure(state) {
15
+ if (!state)
16
+ return false;
17
+ if (typeof state.currentObligation === "string" && state.currentObligation.trim().length > 0)
18
+ return true;
19
+ if (state.mustResolveBeforeHandoff === true)
20
+ return true;
21
+ if (state.hasQueuedFollowUp === true)
22
+ return true;
23
+ return false;
24
+ }
25
+ function getFirstImpressions(friend, state) {
15
26
  if (!isOnboarding(friend))
16
27
  return "";
28
+ if (hasLiveContinuityPressure(state))
29
+ return "";
17
30
  (0, runtime_1.emitNervesEvent)({
18
31
  component: "mind",
19
32
  event: "mind.first_impressions",
@@ -423,7 +423,7 @@ tool_choice is set to "required" -- i must call a tool on every turn.
423
423
  \`final_answer\` must be the ONLY tool call in that turn. do not combine it with other tool calls.
424
424
  do NOT call no-op tools just before \`final_answer\`. if i am done, i call \`final_answer\` directly.`;
425
425
  }
426
- function contextSection(context) {
426
+ function contextSection(context, options) {
427
427
  if (!context)
428
428
  return "";
429
429
  const lines = ["## friend context"];
@@ -459,7 +459,7 @@ function contextSection(context) {
459
459
  lines.push("when i learn something that might invalidate an existing note, i check related notes and update or override any that are stale.");
460
460
  lines.push("i save ANYTHING i learn about my friend immediately with save_friend_note -- names, preferences, what they do, what they care about. when in doubt, save it. saving comes BEFORE responding: i call save_friend_note first, then final_answer on the next turn.");
461
461
  // Onboarding instructions (only below token threshold -- drop once exceeded)
462
- const impressions = (0, first_impressions_1.getFirstImpressions)(friend);
462
+ const impressions = (0, first_impressions_1.getFirstImpressions)(friend, options);
463
463
  if (impressions) {
464
464
  lines.push(impressions);
465
465
  }
@@ -553,7 +553,7 @@ async function buildSystem(channel = "cli", options, context) {
553
553
  }),
554
554
  memoryFriendToolContractSection(),
555
555
  toolBehaviorSection(options),
556
- contextSection(context),
556
+ contextSection(context, options),
557
557
  ]
558
558
  .filter(Boolean)
559
559
  .join("\n\n");
@@ -690,7 +690,10 @@ exports.finalAnswerTool = {
690
690
  description: "respond to the user with your message. call this tool when you are ready to deliver your response.",
691
691
  parameters: {
692
692
  type: "object",
693
- properties: { answer: { type: "string" } },
693
+ properties: {
694
+ answer: { type: "string" },
695
+ intent: { type: "string", enum: ["complete", "blocked", "direct_reply"] },
696
+ },
694
697
  required: ["answer"],
695
698
  },
696
699
  },
@@ -147,6 +147,10 @@ function extractMessageText(content) {
147
147
  .filter(Boolean)
148
148
  .join("\n");
149
149
  }
150
+ function isHistoricalLaneMetadataLine(line) {
151
+ return /^\[(conversation scope|recent active lanes|routing control):?/i.test(line)
152
+ || /^- (top_level|thread:[^:]+):/i.test(line);
153
+ }
150
154
  function extractHistoricalLaneSummary(messages) {
151
155
  const seen = new Set();
152
156
  const summaries = [];
@@ -171,7 +175,7 @@ function extractHistoricalLaneSummary(messages) {
171
175
  .split("\n")
172
176
  .slice(1)
173
177
  .map((line) => line.trim())
174
- .find(Boolean)
178
+ .find((line) => line.length > 0 && !isHistoricalLaneMetadataLine(line))
175
179
  ?.slice(0, 80) ?? "(no recent text)";
176
180
  summaries.push({
177
181
  key: laneKey,
@@ -230,6 +234,23 @@ function buildInboundContent(event, existingMessages) {
230
234
  ...event.inputPartsForAgent,
231
235
  ];
232
236
  }
237
+ function getBlueBubblesContinuityIngressTexts(event) {
238
+ if (event.kind !== "message")
239
+ return [];
240
+ const text = event.textForAgent.trim();
241
+ if (text.length > 0)
242
+ return [text];
243
+ const fallbackText = (event.inputPartsForAgent ?? [])
244
+ .map((part) => {
245
+ if (part.type === "text" && typeof part.text === "string") {
246
+ return part.text.trim();
247
+ }
248
+ return "";
249
+ })
250
+ .filter(Boolean)
251
+ .join("\n");
252
+ return fallbackText ? [fallbackText] : [];
253
+ }
233
254
  function createReplyTargetController(event) {
234
255
  const defaultTargetLabel = event.kind === "message" && event.threadOriginatorGuid?.trim() ? "current_lane" : "top_level";
235
256
  let selection = event.kind === "message" && event.threadOriginatorGuid?.trim()
@@ -510,9 +531,10 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
510
531
  channel: "bluebubbles",
511
532
  capabilities: bbCapabilities,
512
533
  messages: [userMessage],
534
+ continuityIngressTexts: getBlueBubblesContinuityIngressTexts(event),
513
535
  callbacks,
514
536
  friendResolver: { resolve: () => Promise.resolve(context) },
515
- sessionLoader: { loadOrCreate: () => Promise.resolve({ messages: sessionMessages, sessionPath: sessPath }) },
537
+ sessionLoader: { loadOrCreate: () => Promise.resolve({ messages: sessionMessages, sessionPath: sessPath, state: existing?.state }) },
516
538
  pendingDir,
517
539
  friendStore: store,
518
540
  provider: "imessage-handle",
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MarkdownStreamer = exports.InputController = exports.Spinner = void 0;
37
37
  exports.formatPendingPrefix = formatPendingPrefix;
38
+ exports.getCliContinuityIngressTexts = getCliContinuityIngressTexts;
38
39
  exports.handleSigint = handleSigint;
39
40
  exports.addHistory = addHistory;
40
41
  exports.renderMarkdown = renderMarkdown;
@@ -80,6 +81,10 @@ function formatPendingPrefix(messages, agentName) {
80
81
  : `[message from ${msg.from}: ${msg.content}]`)
81
82
  .join("\n");
82
83
  }
84
+ function getCliContinuityIngressTexts(input) {
85
+ const trimmed = input.trim();
86
+ return trimmed ? [trimmed] : [];
87
+ }
83
88
  // spinner that only touches stderr, cleans up after itself
84
89
  // exported for direct testability (stop-without-start branch)
85
90
  class Spinner {
@@ -729,6 +734,7 @@ async function main(agentName, options) {
729
734
  }
730
735
  // Load existing session or start fresh
731
736
  const existing = (0, context_1.loadSession)(sessPath);
737
+ let sessionState = existing?.state;
732
738
  const sessionMessages = existing?.messages && existing.messages.length > 0
733
739
  ? existing.messages
734
740
  : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", undefined, resolvedContext) }];
@@ -748,9 +754,10 @@ async function main(agentName, options) {
748
754
  channel: "cli",
749
755
  capabilities: cliCapabilities,
750
756
  messages: [{ role: "user", content: userInput }],
757
+ continuityIngressTexts: getCliContinuityIngressTexts(userInput),
751
758
  callbacks,
752
759
  friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
753
- sessionLoader: { loadOrCreate: () => Promise.resolve({ messages, sessionPath: sessPath }) },
760
+ sessionLoader: { loadOrCreate: () => Promise.resolve({ messages, sessionPath: sessPath, state: sessionState }) },
754
761
  pendingDir,
755
762
  friendStore,
756
763
  provider: "local",
@@ -766,7 +773,10 @@ async function main(agentName, options) {
766
773
  summarize,
767
774
  },
768
775
  }),
769
- postTurn: context_1.postTurn,
776
+ postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
777
+ (0, context_1.postTurn)(turnMessages, sessionPathArg, usage, hooks, state);
778
+ sessionState = state;
779
+ },
770
780
  accumulateFriendTokens: tokens_1.accumulateFriendTokens,
771
781
  signal,
772
782
  runAgentOptions: {
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeContinuityClauses = normalizeContinuityClauses;
4
+ exports.resolveMustResolveBeforeHandoff = resolveMustResolveBeforeHandoff;
5
+ exports.classifySteeringFollowUpEffect = classifySteeringFollowUpEffect;
6
+ const runtime_1 = require("../nerves/runtime");
7
+ const NO_HANDOFF_CLAUSES = new Set([
8
+ "dont return control until complete or blocked",
9
+ "do not return control until complete or blocked",
10
+ "dont hand back control until complete or blocked",
11
+ "do not hand back control until complete or blocked",
12
+ "keep going until youre done",
13
+ "keep going until you are done",
14
+ "keep working until youre done",
15
+ "keep working until you are done",
16
+ "dont stop until youre done",
17
+ "do not stop until youre done",
18
+ "only come back if blocked",
19
+ "only return if blocked",
20
+ "only respond if blocked",
21
+ "work autonomously on this",
22
+ "work on this autonomously",
23
+ "handle this autonomously",
24
+ ]);
25
+ const CANCEL_SUPERSEDE_CLAUSES = new Set([
26
+ "stop",
27
+ "cancel that",
28
+ "cancel this",
29
+ "never mind",
30
+ "nevermind",
31
+ "forget it",
32
+ "ignore that",
33
+ "ignore this",
34
+ "hold off",
35
+ "ill take it from here",
36
+ "i will take it from here",
37
+ "lets do something else",
38
+ "stop working on that",
39
+ "stop working on this",
40
+ "dont do that",
41
+ "do not do that",
42
+ ]);
43
+ function normalizeContinuityClauses(text) {
44
+ return text
45
+ .toLowerCase()
46
+ .split(/[\n.!?;]+/)
47
+ .map((clause) => clause.replace(/[^a-z\s]/g, "").replace(/\s+/g, " ").trim())
48
+ .filter(Boolean);
49
+ }
50
+ function resolveMustResolveBeforeHandoff(initialValue, ingressTexts) {
51
+ let current = initialValue;
52
+ for (const text of ingressTexts ?? []) {
53
+ for (const clause of normalizeContinuityClauses(text)) {
54
+ if (CANCEL_SUPERSEDE_CLAUSES.has(clause)) {
55
+ current = false;
56
+ continue;
57
+ }
58
+ if (NO_HANDOFF_CLAUSES.has(clause)) {
59
+ current = true;
60
+ }
61
+ }
62
+ }
63
+ (0, runtime_1.emitNervesEvent)({
64
+ component: "senses",
65
+ event: "senses.continuity_state_resolved",
66
+ message: "resolved continuity handoff state from ingress text",
67
+ meta: {
68
+ initialValue,
69
+ finalValue: current,
70
+ ingressCount: ingressTexts?.length ?? 0,
71
+ },
72
+ });
73
+ return current;
74
+ }
75
+ function classifySteeringFollowUpEffect(text) {
76
+ const clauses = normalizeContinuityClauses(text);
77
+ let effect = "none";
78
+ if (clauses.some((clause) => CANCEL_SUPERSEDE_CLAUSES.has(clause))) {
79
+ effect = "clear_and_supersede";
80
+ }
81
+ else if (clauses.some((clause) => NO_HANDOFF_CLAUSES.has(clause))) {
82
+ effect = "set_no_handoff";
83
+ }
84
+ (0, runtime_1.emitNervesEvent)({
85
+ component: "senses",
86
+ event: "senses.continuity_follow_up_classified",
87
+ message: "classified steering follow-up continuity effect",
88
+ meta: {
89
+ effect,
90
+ clauseCount: clauses.length,
91
+ },
92
+ });
93
+ return effect;
94
+ }
@@ -248,6 +248,7 @@ async function runInnerDialogTurn(options) {
248
248
  channel: "inner",
249
249
  capabilities: innerCapabilities,
250
250
  messages: [userMessage],
251
+ continuityIngressTexts: [],
251
252
  callbacks,
252
253
  friendResolver: { resolve: () => Promise.resolve(selfContext) },
253
254
  sessionLoader,
@@ -7,6 +7,7 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.handleInboundTurn = handleInboundTurn;
9
9
  const runtime_1 = require("../nerves/runtime");
10
+ const continuity_1 = require("./continuity");
10
11
  // ── Pipeline ──────────────────────────────────────────────────────
11
12
  async function handleInboundTurn(input) {
12
13
  // Step 1: Resolve friend
@@ -54,6 +55,11 @@ async function handleInboundTurn(input) {
54
55
  // Step 3: Load/create session
55
56
  const session = await input.sessionLoader.loadOrCreate();
56
57
  const sessionMessages = session.messages;
58
+ let mustResolveBeforeHandoff = (0, continuity_1.resolveMustResolveBeforeHandoff)(session.state?.mustResolveBeforeHandoff === true, input.continuityIngressTexts);
59
+ const currentObligation = input.continuityIngressTexts
60
+ ?.map((text) => text.trim())
61
+ .filter((text) => text.length > 0)
62
+ .at(-1);
57
63
  // Step 4: Drain pending messages
58
64
  const pending = input.drainPending(input.pendingDir);
59
65
  // Assemble messages: session messages + pending (formatted) + inbound user messages
@@ -92,6 +98,11 @@ async function handleInboundTurn(input) {
92
98
  const existingToolContext = input.runAgentOptions?.toolContext;
93
99
  const runAgentOptions = {
94
100
  ...input.runAgentOptions,
101
+ currentObligation,
102
+ mustResolveBeforeHandoff,
103
+ setMustResolveBeforeHandoff: (value) => {
104
+ mustResolveBeforeHandoff = value;
105
+ },
95
106
  toolContext: {
96
107
  /* v8 ignore next -- default no-op signin satisfies interface; real signin injected by sense adapter @preserve */
97
108
  signin: async () => undefined,
@@ -102,7 +113,12 @@ async function handleInboundTurn(input) {
102
113
  };
103
114
  const result = await input.runAgent(sessionMessages, input.callbacks, input.channel, input.signal, runAgentOptions);
104
115
  // Step 6: postTurn
105
- input.postTurn(sessionMessages, session.sessionPath, result.usage);
116
+ const nextState = result.outcome === "complete" || result.outcome === "blocked" || result.outcome === "superseded"
117
+ ? undefined
118
+ : mustResolveBeforeHandoff
119
+ ? { mustResolveBeforeHandoff: true }
120
+ : undefined;
121
+ input.postTurn(sessionMessages, session.sessionPath, result.usage, undefined, nextState);
106
122
  // Step 7: Token accumulation
107
123
  await input.accumulateFriendTokens(input.friendStore, resolvedContext.friend.id, result.usage);
108
124
  (0, runtime_1.emitNervesEvent)({
@@ -118,6 +134,7 @@ async function handleInboundTurn(input) {
118
134
  resolvedContext,
119
135
  gateResult,
120
136
  usage: result.usage,
137
+ turnOutcome: result.outcome,
121
138
  sessionPath: session.sessionPath,
122
139
  messages: sessionMessages,
123
140
  };
@@ -68,6 +68,7 @@ const path = __importStar(require("path"));
68
68
  const trust_gate_1 = require("./trust-gate");
69
69
  const pipeline_1 = require("./pipeline");
70
70
  const pending_1 = require("../mind/pending");
71
+ const continuity_1 = require("./continuity");
71
72
  // Strip @mention markup from incoming messages.
72
73
  // Removes <at>...</at> tags and trims extra whitespace.
73
74
  // Fallback safety net -- the SDK's activity.mentions.stripText should handle
@@ -439,6 +440,34 @@ function getFriendStore() {
439
440
  const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
440
441
  return new store_file_1.FileFriendStore(friendsPath);
441
442
  }
443
+ function createTeamsCommandRegistry() {
444
+ const registry = (0, commands_1.createCommandRegistry)();
445
+ (0, commands_1.registerDefaultCommands)(registry);
446
+ return registry;
447
+ }
448
+ function handleTeamsSlashCommand(text, registry, friendId, conversationId, stream, emitResponse = true) {
449
+ const parsed = (0, commands_1.parseSlashCommand)(text);
450
+ if (!parsed)
451
+ return null;
452
+ const dispatchResult = registry.dispatch(parsed.command, { channel: "teams" });
453
+ if (!dispatchResult.handled || !dispatchResult.result) {
454
+ return null;
455
+ }
456
+ if (dispatchResult.result.action === "new") {
457
+ (0, context_1.deleteSession)((0, config_2.sessionPath)(friendId, "teams", conversationId));
458
+ if (emitResponse) {
459
+ stream.emit("session cleared");
460
+ }
461
+ return "new";
462
+ }
463
+ if (dispatchResult.result.action === "response") {
464
+ if (emitResponse) {
465
+ stream.emit(dispatchResult.result.message || "");
466
+ }
467
+ return "response";
468
+ }
469
+ return null;
470
+ }
442
471
  // Handle an incoming Teams message
443
472
  async function handleTeamsMessage(text, stream, conversationId, teamsContext, sendMessage) {
444
473
  const turnKey = teamsTurnKey(conversationId);
@@ -464,24 +493,10 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
464
493
  // Pre-resolve friend for session path + slash commands (pipeline will re-use the cached result)
465
494
  const resolvedContext = await resolver.resolve();
466
495
  const friendId = resolvedContext.friend.id;
467
- const registry = (0, commands_1.createCommandRegistry)();
468
- (0, commands_1.registerDefaultCommands)(registry);
496
+ const registry = createTeamsCommandRegistry();
469
497
  // Check for slash commands (before pipeline -- these are transport-level concerns)
470
- const parsed = (0, commands_1.parseSlashCommand)(text);
471
- if (parsed) {
472
- const dispatchResult = registry.dispatch(parsed.command, { channel: "teams" });
473
- if (dispatchResult.handled && dispatchResult.result) {
474
- if (dispatchResult.result.action === "new") {
475
- const sessPath = (0, config_2.sessionPath)(friendId, "teams", conversationId);
476
- (0, context_1.deleteSession)(sessPath);
477
- stream.emit("session cleared");
478
- return;
479
- }
480
- else if (dispatchResult.result.action === "response") {
481
- stream.emit(dispatchResult.result.message || "");
482
- return;
483
- }
484
- }
498
+ if (handleTeamsSlashCommand(text, registry, friendId, conversationId, stream)) {
499
+ return;
485
500
  }
486
501
  // ── Teams adapter concerns: controller, callbacks, session path ──────────
487
502
  const controller = new AbortController();
@@ -501,76 +516,107 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
501
516
  tenantId: teamsContext.tenantId,
502
517
  botApi: teamsContext.botApi,
503
518
  } : {};
504
- // Build runAgentOptions with Teams-specific fields
505
- const agentOptions = {
506
- traceId,
507
- toolContext: teamsToolContext,
508
- drainSteeringFollowUps: () => _turnCoordinator.drainFollowUps(turnKey).map((m) => ({ text: m.text })),
509
- };
510
- if (channelConfig.skipConfirmation)
511
- agentOptions.skipConfirmation = true;
512
- // ── Call shared pipeline ──────────────────────────────────────────
513
- const result = await (0, pipeline_1.handleInboundTurn)({
514
- channel: "teams",
515
- capabilities: teamsCapabilities,
516
- messages: [{ role: "user", content: text }],
517
- callbacks,
518
- friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
519
- sessionLoader: {
520
- loadOrCreate: async () => {
521
- const existing = (0, context_1.loadSession)(sessPath);
522
- const messages = existing?.messages && existing.messages.length > 0
523
- ? existing.messages
524
- : [{ role: "system", content: await (0, prompt_1.buildSystem)("teams", undefined, resolvedContext) }];
525
- (0, core_1.repairOrphanedToolCalls)(messages);
526
- return { messages, sessionPath: sessPath };
519
+ let currentText = text;
520
+ while (true) {
521
+ let drainedSteeringFollowUps = [];
522
+ // Build runAgentOptions with Teams-specific fields
523
+ const agentOptions = {
524
+ traceId,
525
+ toolContext: teamsToolContext,
526
+ drainSteeringFollowUps: () => {
527
+ drainedSteeringFollowUps = _turnCoordinator.drainFollowUps(turnKey)
528
+ .map(({ text: followUpText, effect }) => ({ text: followUpText, effect }));
529
+ return drainedSteeringFollowUps;
527
530
  },
528
- },
529
- pendingDir,
530
- friendStore: store,
531
- provider,
532
- externalId,
533
- tenantId: teamsContext?.tenantId,
534
- isGroupChat: false,
535
- groupHasFamilyMember: false,
536
- hasExistingGroupWithFamily: false,
537
- enforceTrustGate: trust_gate_1.enforceTrustGate,
538
- drainPending: pending_1.drainPending,
539
- runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
540
- ...opts,
541
- toolContext: {
542
- /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
543
- signin: async () => undefined,
544
- ...opts?.toolContext,
545
- summarize: teamsToolContext.summarize,
531
+ };
532
+ if (channelConfig.skipConfirmation)
533
+ agentOptions.skipConfirmation = true;
534
+ // ── Call shared pipeline ──────────────────────────────────────────
535
+ const result = await (0, pipeline_1.handleInboundTurn)({
536
+ channel: "teams",
537
+ capabilities: teamsCapabilities,
538
+ messages: [{ role: "user", content: currentText }],
539
+ continuityIngressTexts: [currentText],
540
+ callbacks,
541
+ friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
542
+ sessionLoader: {
543
+ loadOrCreate: async () => {
544
+ const existing = (0, context_1.loadSession)(sessPath);
545
+ const messages = existing?.messages && existing.messages.length > 0
546
+ ? existing.messages
547
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("teams", undefined, resolvedContext) }];
548
+ (0, core_1.repairOrphanedToolCalls)(messages);
549
+ return { messages, sessionPath: sessPath, state: existing?.state };
550
+ },
546
551
  },
547
- }),
548
- postTurn: context_1.postTurn,
549
- accumulateFriendTokens: tokens_1.accumulateFriendTokens,
550
- signal: controller.signal,
551
- runAgentOptions: agentOptions,
552
- });
553
- // ── Handle gate result ────────────────────────────────────────
554
- if (!result.gateResult.allowed) {
555
- if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
556
- stream.emit(result.gateResult.autoReply);
552
+ pendingDir,
553
+ friendStore: store,
554
+ provider,
555
+ externalId,
556
+ tenantId: teamsContext?.tenantId,
557
+ isGroupChat: false,
558
+ groupHasFamilyMember: false,
559
+ hasExistingGroupWithFamily: false,
560
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
561
+ drainPending: pending_1.drainPending,
562
+ runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
563
+ ...opts,
564
+ toolContext: {
565
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
566
+ signin: async () => undefined,
567
+ ...opts?.toolContext,
568
+ summarize: teamsToolContext.summarize,
569
+ },
570
+ }),
571
+ postTurn: context_1.postTurn,
572
+ accumulateFriendTokens: tokens_1.accumulateFriendTokens,
573
+ signal: controller.signal,
574
+ runAgentOptions: agentOptions,
575
+ });
576
+ // ── Handle gate result ────────────────────────────────────────
577
+ if (!result.gateResult.allowed) {
578
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
579
+ stream.emit(result.gateResult.autoReply);
580
+ }
581
+ return;
557
582
  }
558
- return;
583
+ // Flush any remaining accumulated text at end of turn
584
+ await callbacks.flush();
585
+ // After the agent loop, check if any tool returned AUTH_REQUIRED and trigger signin.
586
+ // This must happen after the stream is done so the OAuth card renders properly.
587
+ if (teamsContext && result.messages) {
588
+ const allContent = result.messages.map(m => typeof m.content === "string" ? m.content : "").join("\n");
589
+ if (allContent.includes("AUTH_REQUIRED:graph") && teamsContext.graphConnectionName)
590
+ await teamsContext.signin(teamsContext.graphConnectionName);
591
+ if (allContent.includes("AUTH_REQUIRED:ado") && teamsContext.adoConnectionName)
592
+ await teamsContext.signin(teamsContext.adoConnectionName);
593
+ if (allContent.includes("AUTH_REQUIRED:github") && teamsContext.githubConnectionName)
594
+ await teamsContext.signin(teamsContext.githubConnectionName);
595
+ }
596
+ if (result.turnOutcome !== "superseded") {
597
+ return;
598
+ }
599
+ const supersedingIndex = drainedSteeringFollowUps
600
+ .map((followUp) => followUp.effect)
601
+ .lastIndexOf("clear_and_supersede");
602
+ if (supersedingIndex < 0) {
603
+ return;
604
+ }
605
+ const supersedingFollowUp = drainedSteeringFollowUps[supersedingIndex];
606
+ const replayTail = drainedSteeringFollowUps
607
+ .slice(supersedingIndex + 1)
608
+ .map((followUp) => followUp.text.trim())
609
+ .filter((followUpText) => followUpText.length > 0)
610
+ .join("\n");
611
+ if (replayTail) {
612
+ currentText = replayTail;
613
+ continue;
614
+ }
615
+ if (handleTeamsSlashCommand(supersedingFollowUp.text, registry, friendId, conversationId, stream, false)) {
616
+ return;
617
+ }
618
+ currentText = supersedingFollowUp.text;
559
619
  }
560
- // Flush any remaining accumulated text at end of turn
561
- await callbacks.flush();
562
- // After the agent loop, check if any tool returned AUTH_REQUIRED and trigger signin.
563
- // This must happen after the stream is done so the OAuth card renders properly.
564
- if (teamsContext && result.messages) {
565
- const allContent = result.messages.map(m => typeof m.content === "string" ? m.content : "").join("\n");
566
- if (allContent.includes("AUTH_REQUIRED:graph") && teamsContext.graphConnectionName)
567
- await teamsContext.signin(teamsContext.graphConnectionName);
568
- if (allContent.includes("AUTH_REQUIRED:ado") && teamsContext.adoConnectionName)
569
- await teamsContext.signin(teamsContext.adoConnectionName);
570
- if (allContent.includes("AUTH_REQUIRED:github") && teamsContext.githubConnectionName)
571
- await teamsContext.signin(teamsContext.githubConnectionName);
572
- }
573
- // SDK auto-closes the stream after our handler returns (app.process.js)
574
620
  }
575
621
  // Internal port for the secondary bot App (not exposed externally).
576
622
  // The primary app proxies /api/messages-secondary → localhost:SECONDARY_PORT/api/messages.
@@ -681,6 +727,41 @@ function registerBotHandlers(app, label) {
681
727
  if (resolvePendingConfirmation(convId, text)) {
682
728
  return;
683
729
  }
730
+ const commandRegistry = createTeamsCommandRegistry();
731
+ const parsedSlashCommand = (0, commands_1.parseSlashCommand)(text);
732
+ if (parsedSlashCommand) {
733
+ const dispatchResult = commandRegistry.dispatch(parsedSlashCommand.command, { channel: "teams" });
734
+ if (dispatchResult.handled && dispatchResult.result) {
735
+ if (dispatchResult.result.action === "response") {
736
+ stream.emit(dispatchResult.result.message || "");
737
+ return;
738
+ }
739
+ if (dispatchResult.result.action === "new") {
740
+ const commandStore = getFriendStore();
741
+ const commandProvider = activity.from?.aadObjectId ? "aad" : "teams-conversation";
742
+ const commandExternalId = activity.from?.aadObjectId || convId;
743
+ const commandResolver = new resolver_1.FriendResolver(commandStore, {
744
+ provider: commandProvider,
745
+ externalId: commandExternalId,
746
+ tenantId: activity.conversation?.tenantId,
747
+ displayName: activity.from?.name || "Unknown",
748
+ channel: "teams",
749
+ });
750
+ const commandContext = await commandResolver.resolve();
751
+ (0, context_1.deleteSession)((0, config_2.sessionPath)(commandContext.friend.id, "teams", convId));
752
+ stream.emit("session cleared");
753
+ if (_turnCoordinator.isTurnActive(turnKey)) {
754
+ _turnCoordinator.enqueueFollowUp(turnKey, {
755
+ conversationId: convId,
756
+ text,
757
+ receivedAt: Date.now(),
758
+ effect: "clear_and_supersede",
759
+ });
760
+ }
761
+ return;
762
+ }
763
+ }
764
+ }
684
765
  // If this conversation already has an active turn, steer follow-up input
685
766
  // into that turn and avoid starting a second concurrent turn.
686
767
  if (!_turnCoordinator.tryBeginTurn(turnKey)) {
@@ -688,6 +769,7 @@ function registerBotHandlers(app, label) {
688
769
  conversationId: convId,
689
770
  text,
690
771
  receivedAt: Date.now(),
772
+ effect: (0, continuity_1.classifySteeringFollowUpEffect)(text),
691
773
  });
692
774
  return;
693
775
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.41",
3
+ "version": "0.1.0-alpha.43",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",