@ouro.bot/cli 0.1.0-alpha.801 → 0.1.0-alpha.802

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,12 @@
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.802",
6
+ "changes": [
7
+ "Prefer validated completion text for settled replies and bind delivery causality only when the newest validated current-turn session event matches, preventing stale or duplicate Telegram answers."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.801",
6
12
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.801",
2
+ "runtimeVersion": "0.1.0-alpha.802",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.801</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.802</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -52,6 +52,7 @@ exports.stampIngressTime = stampIngressTime;
52
52
  exports.getIngressTime = getIngressTime;
53
53
  exports.stampIngressRelations = stampIngressRelations;
54
54
  exports.getIngressRelations = getIngressRelations;
55
+ exports.projectedSessionEventIds = projectedSessionEventIds;
55
56
  exports.projectProviderMessages = projectProviderMessages;
56
57
  exports.annotateMessageTimestamps = annotateMessageTimestamps;
57
58
  exports.bestEventTimestamp = bestEventTimestamp;
@@ -936,10 +937,13 @@ function buildEventFromMessage(message, sequence, recordedAt, captureKind, sourc
936
937
  },
937
938
  };
938
939
  }
939
- function projectProviderMessages(envelope) {
940
- const eventIds = envelope.projection.eventIds.length > 0
940
+ function projectedSessionEventIds(envelope) {
941
+ return envelope.projection.eventIds.length > 0
941
942
  ? envelope.projection.eventIds
942
943
  : envelope.events.map((event) => event.id);
944
+ }
945
+ function projectProviderMessages(envelope) {
946
+ const eventIds = projectedSessionEventIds(envelope);
943
947
  const byId = new Map(envelope.events.map((event) => [event.id, event]));
944
948
  return eventIds
945
949
  .map((id) => byId.get(id))
@@ -958,9 +962,7 @@ function projectProviderMessages(envelope) {
958
962
  * System and tool messages are untouched.
959
963
  */
960
964
  function annotateMessageTimestamps(envelope, messages, nowMs = Date.now()) {
961
- const eventIds = envelope.projection.eventIds.length > 0
962
- ? envelope.projection.eventIds
963
- : envelope.events.map((event) => event.id);
965
+ const eventIds = projectedSessionEventIds(envelope);
964
966
  const byId = new Map(envelope.events.map((event) => [event.id, event]));
965
967
  const events = eventIds
966
968
  .map((id) => byId.get(id))
@@ -349,6 +349,7 @@ function loadSession(filePath) {
349
349
  return {
350
350
  messages: (0, session_events_1.sanitizeProviderMessages)((0, session_events_1.projectProviderMessages)(envelope)),
351
351
  events: envelope.events,
352
+ projectionEventIds: [...(0, session_events_1.projectedSessionEventIds)(envelope)],
352
353
  structuredOutputs: envelope.structuredOutputs ?? [],
353
354
  lastUsage: envelope.lastUsage ?? undefined,
354
355
  state: denormalizeContinuityState(envelope.state),
@@ -177,70 +177,134 @@ function extractOutwardSenseDeliveryText(messages) {
177
177
  : assistantContentText(assistant.content);
178
178
  }
179
179
  function hasAcceptedOutwardSessionAck(events, assistantIndex, toolCallId, toolName) {
180
+ if (typeof toolCallId !== "string" || !toolCallId.trim())
181
+ return false;
180
182
  const expectedAck = OUTWARD_DELIVERY_TOOL_ACKS.get(toolName);
181
183
  for (let index = assistantIndex + 1; index < events.length; index++) {
182
184
  const candidate = events[index];
183
185
  if (candidate.role !== "tool")
184
186
  return false;
185
- if (candidate.toolCallId === toolCallId && typeof candidate.content === "string" && candidate.content.trim() === expectedAck)
186
- return true;
187
+ if (candidate.toolCallId !== toolCallId && candidate.relations?.toolCallId !== toolCallId)
188
+ continue;
189
+ return candidate.toolCallId === toolCallId
190
+ && candidate.relations?.toolCallId === toolCallId
191
+ && candidate.provenance?.captureKind === "live"
192
+ && Array.isArray(candidate.toolCalls)
193
+ && candidate.toolCalls.length === 0
194
+ && typeof candidate.content === "string"
195
+ && candidate.content === expectedAck;
187
196
  }
188
197
  return false;
189
198
  }
190
- function newOutwardCoordinates(events, existingEventIds, afterEventId) {
191
- const boundaryIndex = events.findIndex((event) => event.id === afterEventId);
192
- if (boundaryIndex < 0)
193
- return [];
199
+ function newOutwardCoordinates(events, existingEventIds, boundaryIndex) {
194
200
  return events.flatMap((event, eventIndex) => {
195
- if (eventIndex <= boundaryIndex || existingEventIds.has(event.id) || event.role !== "assistant" || event.provenance?.captureKind === "synthetic")
201
+ if (eventIndex <= boundaryIndex || existingEventIds.has(event.id) || event.role !== "assistant" || event.provenance?.captureKind !== "live" || !Array.isArray(event.toolCalls))
196
202
  return [];
197
203
  const outwardTools = event.toolCalls.flatMap((call) => {
198
- if (call.function.name !== "speak" && call.function.name !== "settle")
204
+ const toolName = call?.function?.name;
205
+ if (toolName !== "speak" && toolName !== "settle")
199
206
  return [];
200
- if (!hasAcceptedOutwardSessionAck(events, eventIndex, call.id, call.function.name))
207
+ if (!hasAcceptedOutwardSessionAck(events, eventIndex, call.id, toolName))
201
208
  return [];
202
- const text = stripThinkBlocks(parseToolStringArg(call, call.function.name, call.function.name === "speak" ? "message" : "answer") ?? "");
203
- return text ? [{ kind: call.function.name, eventId: event.id, text }] : [];
209
+ const text = stripThinkBlocks(parseToolStringArg(call, toolName, toolName === "speak" ? "message" : "answer") ?? "");
210
+ return text ? [{ kind: toolName, eventId: event.id, eventIndex, text }] : [];
204
211
  });
205
212
  if (outwardTools.length > 0)
206
213
  return outwardTools;
207
214
  if (event.toolCalls.length > 0)
208
215
  return [];
209
216
  const text = stripThinkBlocks(typeof event.content === "string" ? event.content : "");
210
- return text ? [{ kind: "text", eventId: event.id, text }] : [];
217
+ return text ? [{ kind: "text", eventId: event.id, eventIndex, text }] : [];
211
218
  });
212
219
  }
213
- function newestPlainAssistantText(messages) {
214
- const message = messages.findLast((candidate) => candidate.role === "assistant"
215
- && (!("tool_calls" in candidate) || !Array.isArray(candidate.tool_calls) || candidate.tool_calls.length === 0)
216
- && typeof candidate.content === "string"
217
- && candidate.content.trim().length > 0);
218
- return message ? assistantContentText(message.content) : null;
219
- }
220
- function causalSessionEventIds(events, existingEventIds, attempts, afterEventId) {
221
- if (!afterEventId)
220
+ function alignedDeliveryCoordinates(view, attempts, finalAttemptIndex, finalCoordinate) {
221
+ if (!view)
222
222
  return attempts.flatMap((attempt) => attempt.delivered ? [null] : []);
223
- const coordinates = newOutwardCoordinates(events, existingEventIds, afterEventId);
223
+ const terminalCoordinate = finalCoordinate ?? (finalAttemptIndex === undefined ? view.terminal : undefined);
224
+ const reservedAttemptIndex = terminalCoordinate
225
+ ? finalAttemptIndex !== undefined
226
+ ? (attempts[finalAttemptIndex]?.delivered && attempts[finalAttemptIndex]?.kind === terminalCoordinate.kind && attempts[finalAttemptIndex]?.text === terminalCoordinate.text ? finalAttemptIndex : -1)
227
+ : attempts.findLastIndex((attempt) => attempt.delivered && attempt.kind === terminalCoordinate.kind && attempt.text === terminalCoordinate.text)
228
+ : -1;
229
+ const availableCoordinates = terminalCoordinate ? view.coordinates.filter((coordinate) => coordinate !== terminalCoordinate) : view.coordinates;
224
230
  let nextCoordinate = 0;
225
- return attempts.flatMap((attempt) => {
231
+ return attempts.flatMap((attempt, attemptIndex) => {
226
232
  if (!attempt.delivered)
227
233
  return [];
228
- const coordinateIndex = coordinates.findIndex((coordinate, index) => index >= nextCoordinate && coordinate.kind === attempt.kind && coordinate.text === attempt.text);
234
+ if (attemptIndex === finalAttemptIndex)
235
+ return finalCoordinate && finalCoordinate.kind === attempt.kind && finalCoordinate.text === attempt.text ? [finalCoordinate] : [null];
236
+ if (attemptIndex === reservedAttemptIndex && terminalCoordinate)
237
+ return [terminalCoordinate];
238
+ const coordinateIndex = availableCoordinates.findIndex((coordinate, index) => index >= nextCoordinate && coordinate.kind === attempt.kind && coordinate.text === attempt.text);
229
239
  if (coordinateIndex < 0)
230
240
  return [null];
231
241
  nextCoordinate = coordinateIndex + 1;
232
- return [coordinates[coordinateIndex].eventId];
242
+ return [availableCoordinates[coordinateIndex]];
233
243
  });
234
244
  }
245
+ function causalSessionEventIds(view, attempts, finalAttemptIndex, finalCoordinate) {
246
+ return alignedDeliveryCoordinates(view, attempts, finalAttemptIndex, finalCoordinate).map((coordinate) => coordinate?.eventId ?? null);
247
+ }
235
248
  function currentIngressEventId(events, existingEventIds, userMessage, precommittedIngress, ingressRelations) {
236
- if (precommittedIngress)
237
- return precommittedIngress.eventId;
238
249
  const reference = ingressRelations?.references[0];
239
- return events.findLast((event) => (!existingEventIds.has(event.id)
240
- && event.role === "user"
250
+ const carriesReference = (event, expected) => Array.isArray(event.relations?.references) && event.relations.references.includes(expected);
251
+ const matches = events.filter((event) => (event.role === "user"
241
252
  && event.content === userMessage
242
- && event.provenance?.captureKind !== "synthetic"
243
- && (!reference || event.relations?.references.includes(reference))))?.id;
253
+ && event.provenance?.captureKind === "live"
254
+ && (precommittedIngress
255
+ ? ((event.id === precommittedIngress.eventId && carriesReference(event, precommittedIngress.reference))
256
+ || (!existingEventIds.has(event.id) && carriesReference(event, precommittedIngress.reference)))
257
+ : !existingEventIds.has(event.id) && (!reference || carriesReference(event, reference)))));
258
+ return matches.length === 1 ? matches[0].id : undefined;
259
+ }
260
+ function rawSessionEvents(value) {
261
+ if (!value || typeof value !== "object" || Array.isArray(value))
262
+ return [];
263
+ const record = value;
264
+ return record.version === 2 && Array.isArray(record.events) ? record.events : [];
265
+ }
266
+ function exactProjectedIngressMessage(existing, messages, eventId) {
267
+ const eventsById = new Map(existing.events.map((event) => [event.id, event]));
268
+ if (eventsById.size !== existing.events.length)
269
+ return null;
270
+ const seenProjectionIds = new Set();
271
+ const projectedEvents = [];
272
+ for (const projectedId of existing.projectionEventIds) {
273
+ if (typeof projectedId !== "string" || !projectedId.trim() || seenProjectionIds.has(projectedId))
274
+ return null;
275
+ const event = eventsById.get(projectedId);
276
+ if (!event)
277
+ return null;
278
+ seenProjectionIds.add(projectedId);
279
+ projectedEvents.push(event);
280
+ }
281
+ if (existing.projectionEventIds.filter((projectedId) => projectedId === eventId).length !== 1)
282
+ return null;
283
+ const projectedUsers = projectedEvents.filter((event) => event.role === "user");
284
+ const providerUsers = messages.filter((message) => message.role === "user");
285
+ if (projectedUsers.at(-1)?.id !== eventId || projectedUsers.length !== providerUsers.length)
286
+ return null;
287
+ return providerUsers.at(-1);
288
+ }
289
+ function currentTurnEventView(events, existingEventIds, userMessage, precommittedIngress, ingressRelations) {
290
+ const eventIds = new Set();
291
+ let previousSequence = 0;
292
+ for (const event of events) {
293
+ if (!event || typeof event !== "object" || typeof event.id !== "string" || !event.id.trim() || eventIds.has(event.id) || !Number.isSafeInteger(event.sequence) || event.sequence <= previousSequence)
294
+ return null;
295
+ eventIds.add(event.id);
296
+ previousSequence = event.sequence;
297
+ }
298
+ const ingressEventId = currentIngressEventId(events, existingEventIds, userMessage, precommittedIngress, ingressRelations);
299
+ if (!ingressEventId)
300
+ return null;
301
+ const ingressIndex = events.findIndex((event) => event.id === ingressEventId);
302
+ if (events.some((event, eventIndex) => eventIndex > ingressIndex && existingEventIds.has(event.id)))
303
+ return null;
304
+ const coordinates = newOutwardCoordinates(events, existingEventIds, ingressIndex);
305
+ const newestAssistantIndex = events.findLastIndex((event, eventIndex) => eventIndex > ingressIndex && event.role === "assistant");
306
+ const terminal = coordinates.findLast((coordinate) => coordinate.eventIndex === newestAssistantIndex);
307
+ return { coordinates, ...(terminal ? { terminal } : {}) };
244
308
  }
245
309
  function getSenseSessionPath(agentName, friendId, channel, sessionKey, agentRootOverride) {
246
310
  return path.join(agentRootOverride ?? (0, identity_1.getAgentRoot)(agentName), "state", "sessions", friendId, channel, `${(0, config_1.sanitizeKey)(sessionKey)}.json`);
@@ -309,11 +373,13 @@ async function runSenseTurn(options) {
309
373
  return runWithLease(sessPath, async (sessionTurnLease) => {
310
374
  const baseSessionRevision = (0, session_transaction_1.readSessionTransaction)(sessPath, sessionTurnLease).revision;
311
375
  const existing = (0, context_1.loadSession)(sessPath);
376
+ const precommittedIngressEvent = options.precommittedIngress
377
+ ? existing?.events?.find((candidate) => candidate.id === options.precommittedIngress.eventId)
378
+ : undefined;
312
379
  if (options.precommittedIngress) {
313
- const event = existing?.events?.find((candidate) => candidate.id === options.precommittedIngress.eventId);
314
380
  const latestUserEvent = existing?.events?.filter((candidate) => candidate.role === "user").at(-1);
315
- if (!event || event !== latestUserEvent || event.role !== "user" || event.content !== userMessage
316
- || !event.relations.references.includes(options.precommittedIngress.reference)) {
381
+ if (!precommittedIngressEvent || precommittedIngressEvent !== latestUserEvent || precommittedIngressEvent.role !== "user" || precommittedIngressEvent.content !== userMessage
382
+ || !precommittedIngressEvent.relations.references.includes(options.precommittedIngress.reference)) {
317
383
  throw new Error("shared turn precommitted ingress is missing, mismatched, or no longer current");
318
384
  }
319
385
  }
@@ -323,7 +389,16 @@ async function runSenseTurn(options) {
323
389
  const sessionMessages = existing?.messages && existing.messages.length > 0
324
390
  ? existing.messages
325
391
  : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await (0, prompt_1.buildSystem)(channel, {}, undefined)) }];
326
- const preTurnMessageCount = sessionMessages.length;
392
+ if (precommittedIngressEvent) {
393
+ const projectedIngress = exactProjectedIngressMessage(existing, sessionMessages, precommittedIngressEvent.id);
394
+ if (!projectedIngress || projectedIngress.role !== "user" || projectedIngress.content !== userMessage)
395
+ throw new Error("shared turn precommitted ingress is absent from the provider projection");
396
+ (0, session_events_1.stampIngressRelations)(projectedIngress, {
397
+ replyToEventId: precommittedIngressEvent.relations.replyToEventId,
398
+ threadRootEventId: precommittedIngressEvent.relations.threadRootEventId,
399
+ references: precommittedIngressEvent.relations.references,
400
+ });
401
+ }
327
402
  // Pending dir
328
403
  const pendingDir = (0, pending_1.getPendingDir)(agentName, friendId, channel, sessionKey);
329
404
  // Accumulate outward text through the same callback boundary used by chat
@@ -352,9 +427,10 @@ async function runSenseTurn(options) {
352
427
  const text = stripThinkBlocks(pendingResponseText);
353
428
  pendingResponseText = "";
354
429
  if (!text)
355
- return;
430
+ return undefined;
356
431
  const delivery = { kind, text };
357
432
  const attempt = { kind, text, delivered: false };
433
+ const attemptIndex = deliveryAttempts.length;
358
434
  deliveryAttempts.push(attempt);
359
435
  try {
360
436
  await options.deliverySink?.onDelivery(delivery);
@@ -376,6 +452,7 @@ async function runSenseTurn(options) {
376
452
  throw error;
377
453
  commitResponseText(text);
378
454
  }
455
+ return attemptIndex;
379
456
  };
380
457
  /* v8 ignore start — callback stubs are exercised through the pipeline integration */
381
458
  const callbacks = {
@@ -393,7 +470,7 @@ async function runSenseTurn(options) {
393
470
  },
394
471
  onError: () => { },
395
472
  onClearText: () => { pendingResponseText = ""; },
396
- flushNow: () => deliverPending("speak", { throwOnError: true }),
473
+ flushNow: async () => { await deliverPending("speak", { throwOnError: true }); },
397
474
  };
398
475
  /* v8 ignore stop */
399
476
  // Run the pipeline
@@ -469,27 +546,28 @@ async function runSenseTurn(options) {
469
546
  };
470
547
  }
471
548
  const persistedEvents = persistPromise ? await persistPromise : [];
472
- const ingressEventId = currentIngressEventId(persistedEvents, existingEventIds, userMessage, options.precommittedIngress, options.ingressRelations);
473
- const finalDeliveryKind = terminalDeliveryKind;
549
+ const terminalEvents = persistedEvents.length > 0
550
+ ? persistedEvents
551
+ : rawSessionEvents((0, session_transaction_1.readSessionTransaction)(sessPath, sessionTurnLease).value);
552
+ const eventView = currentTurnEventView(terminalEvents, existingEventIds, userMessage, options.precommittedIngress, options.ingressRelations);
553
+ let finalDeliveryKind = terminalDeliveryKind;
474
554
  const acceptedTerminalOutcome = turnResult.turnOutcome === "settled" || turnResult.turnOutcome === "blocked";
475
555
  const failoverText = turnResult.turnOutcome === "errored" ? turnResult.failoverMessage?.trim() : undefined;
476
556
  const expectsOutwardResponse = acceptedTerminalOutcome || turnResult.turnOutcome === "command" || Boolean(failoverText);
477
- const hadPendingCallbackText = stripThinkBlocks(pendingResponseText).length > 0;
478
- let recoveredTerminalEventId;
557
+ let finalCausalCoordinate;
479
558
  if (acceptedTerminalOutcome) {
480
- const completionText = turnResult.completion?.answer.trim();
481
- const currentTurnMessages = Array.isArray(turnResult.messages) ? turnResult.messages.slice(preTurnMessageCount) : [];
482
- const plainTerminalText = newestPlainAssistantText(currentTurnMessages);
483
- const acknowledgedDeliveryText = finalDeliveryKind === "settle"
484
- ? extractOutwardSenseDeliveryText(currentTurnMessages)
485
- : null;
486
- const authoritativeText = completionText || acknowledgedDeliveryText || plainTerminalText;
487
- if (authoritativeText)
488
- pendingResponseText = authoritativeText;
489
- else
490
- pendingResponseText = "";
491
- if (!hadPendingCallbackText && plainTerminalText)
492
- recoveredTerminalEventId = causalSessionEventIds(persistedEvents, existingEventIds, [{ kind: "text", text: stripThinkBlocks(plainTerminalText), delivered: true }], ingressEventId)[0] ?? undefined;
559
+ const completionText = stripThinkBlocks(turnResult.completion?.answer ?? "");
560
+ let authoritativeText = completionText;
561
+ if (!authoritativeText && eventView?.terminal) {
562
+ authoritativeText = eventView.terminal.text;
563
+ finalDeliveryKind = eventView.terminal.kind;
564
+ }
565
+ if (eventView?.terminal?.kind === finalDeliveryKind && eventView.terminal.text === authoritativeText)
566
+ finalCausalCoordinate = eventView.terminal;
567
+ const terminalAlreadyDelivered = finalCausalCoordinate
568
+ ? alignedDeliveryCoordinates(eventView, deliveryAttempts).includes(finalCausalCoordinate)
569
+ : false;
570
+ pendingResponseText = authoritativeText && !terminalAlreadyDelivered ? authoritativeText : "";
493
571
  }
494
572
  else if (turnResult.turnOutcome === "command") {
495
573
  // Slash-command text is emitted directly by the pipeline and has no assistant event.
@@ -500,28 +578,20 @@ async function runSenseTurn(options) {
500
578
  else {
501
579
  pendingResponseText = "";
502
580
  }
503
- await deliverPending(finalDeliveryKind, { throwOnError: false });
581
+ const finalDeliveryAttemptIndex = await deliverPending(finalDeliveryKind, { throwOnError: false });
504
582
  const ponderDeferred = false;
505
583
  // Build response
506
584
  let finalResponse;
507
- let responseCausalSessionEventId = recoveredTerminalEventId;
585
+ const finalDeliveryAttempt = finalDeliveryAttemptIndex === undefined ? undefined : deliveryAttempts[finalDeliveryAttemptIndex];
586
+ const responseDeliveryFailure = finalDeliveryAttempt && !finalDeliveryAttempt.delivered ? deliveryFailures.at(-1) : undefined;
587
+ const responseCausalSessionEventId = finalDeliveryAttempt && !finalDeliveryAttempt.delivered ? finalCausalCoordinate?.eventId : undefined;
508
588
  if (committedResponseText.length === 0) {
509
589
  if (!expectsOutwardResponse) {
510
590
  finalResponse = "";
511
591
  }
512
592
  else {
513
- // The terminal turn had no committed text — check its session transcript for the delivered answer.
514
- const postTurnSession = (0, context_1.loadSession)(sessPath);
515
593
  const emptyFallback = options.emptyResponseFallback?.();
516
- if (postTurnSession?.messages) {
517
- const recovered = extractOutwardSenseDeliveryText(postTurnSession.messages.slice(preTurnMessageCount));
518
- finalResponse = recovered ?? emptyFallback ?? (hadReasoningChunk ? "" : "(agent responded but response was empty)");
519
- if (recovered)
520
- responseCausalSessionEventId = causalSessionEventIds(persistedEvents, existingEventIds, [{ kind: finalDeliveryKind, text: stripThinkBlocks(recovered), delivered: true }], ingressEventId)[0] ?? undefined;
521
- }
522
- else {
523
- finalResponse = emptyFallback ?? (hadReasoningChunk ? "" : "(agent responded but response was empty)");
524
- }
594
+ finalResponse = emptyFallback ?? (hadReasoningChunk ? "" : "(agent responded but response was empty)");
525
595
  }
526
596
  }
527
597
  else {
@@ -561,10 +631,11 @@ async function runSenseTurn(options) {
561
631
  ponderDeferred,
562
632
  deliveries,
563
633
  deliveryFailures,
634
+ ...(responseDeliveryFailure ? { responseDeliveryFailure } : {}),
564
635
  providerInvocationCount,
565
636
  toolInvocationCount,
566
637
  sessionPath: sessPath,
567
- ...(deliveries.length > 0 ? { causalSessionEventIds: causalSessionEventIds(persistedEvents, existingEventIds, deliveryAttempts, ingressEventId) } : {}),
638
+ ...(deliveries.length > 0 ? { causalSessionEventIds: causalSessionEventIds(eventView, deliveryAttempts, finalDeliveryAttemptIndex, finalCausalCoordinate) } : {}),
568
639
  ...(responseCausalSessionEventId ? { responseCausalSessionEventId } : {}),
569
640
  };
570
641
  });
@@ -95,6 +95,11 @@ function hasUserVisibleTurnResponse(response) {
95
95
  const text = response.trim();
96
96
  return text.length > 0 && text !== EMPTY_SHARED_TURN_DIAGNOSTIC;
97
97
  }
98
+ function responseFallbackAfterDeliveries(result, successfulDeliveryCount) {
99
+ const failedTerminal = result.responseDeliveryFailure?.text ?? (result.responseCausalSessionEventId ? result.deliveryFailures.at(-1)?.text : undefined);
100
+ const response = failedTerminal ?? (successfulDeliveryCount === 0 && result.deliveryFailures.length === 0 ? result.response : "");
101
+ return hasUserVisibleTurnResponse(response) ? response : null;
102
+ }
98
103
  function createFullVisibilityProgress() {
99
104
  const progress = {};
100
105
  return { progress, emptyResponseFallback: () => progress.fallback?.() };
@@ -1186,10 +1191,15 @@ function createTelegramSenseApp(options) {
1186
1191
  emptyResponseFallback: fullVisibility.emptyResponseFallback,
1187
1192
  deliverySink: { onDelivery: (delivery) => deliver(delivery.text, delivery.kind === "settle") },
1188
1193
  });
1189
- if (effects.length === 0 && result.response.trim())
1190
- await deliver(result.response, true);
1194
+ let responseFallbackArtifactId;
1195
+ const responseFallback = responseFallbackAfterDeliveries(result, effects.length);
1196
+ if (responseFallback) {
1197
+ await deliver(responseFallback, true);
1198
+ responseFallbackArtifactId = effects.at(-1)?.id;
1199
+ }
1191
1200
  const causalEventIds = Object.fromEntries(effects.flatMap((artifact, index) => {
1192
- const eventId = result.causalSessionEventIds?.[index] ?? (effects.length === 1 ? result.responseCausalSessionEventId : undefined);
1201
+ const eventId = result.causalSessionEventIds?.[index]
1202
+ ?? (artifact.id === responseFallbackArtifactId ? result.responseCausalSessionEventId : undefined);
1193
1203
  return eventId ? [[artifact.id, eventId]] : [];
1194
1204
  }));
1195
1205
  await recordAcceptedEffects(sessionPath, effects, undefined, causalEventIds);
@@ -1322,7 +1332,6 @@ function createTelegramSenseApp(options) {
1322
1332
  meta: lifecycleMeta("senses.telegram_turn_start", { agentName: options.agentName, subject, ...acceptanceMeta, ...lifecycleCoordinates }, lifecycleStartedAt),
1323
1333
  });
1324
1334
  acceptanceAuditBarrier();
1325
- let deliveryCount = 0;
1326
1335
  const deliveredMessageIds = [];
1327
1336
  const deliveredChunks = [];
1328
1337
  let receiptStatus = "success";
@@ -1386,7 +1395,6 @@ function createTelegramSenseApp(options) {
1386
1395
  }
1387
1396
  else {
1388
1397
  turnEffects.push(await deliverButlerEffect(delivery.text, `turn:${subject}:${message.updateId}:delivery:${deliveryOrdinal++}`, undefined, (messageId, chunk) => { deliveredMessageIds.push(messageId); deliveredChunks.push(chunk); }, exactDownloadCreditQuestion));
1389
- deliveryCount += 1;
1390
1398
  }
1391
1399
  },
1392
1400
  },
@@ -1418,12 +1426,14 @@ function createTelegramSenseApp(options) {
1418
1426
  throw new Error("Canonical Sanctuary query did not produce exactly one matching grounded settle");
1419
1427
  const canonical = (0, sanctuary_grounding_1.renderSanctuaryGroundedResponse)(grounding.toolName, grounding.facts);
1420
1428
  turnEffects.push(await deliverButlerEffect(canonical, `turn:${subject}:${message.updateId}:delivery:${deliveryOrdinal++}`, undefined, (messageId, chunk) => { deliveredMessageIds.push(messageId); deliveredChunks.push(chunk); }));
1421
- deliveryCount = 1;
1422
1429
  }
1423
- else if (deliveryCount === 0 && hasUserVisibleTurnResponse(result.response)) {
1424
- const artifact = await deliverButlerEffect(result.response, `turn:${subject}:${message.updateId}:delivery:${deliveryOrdinal++}`, undefined, (messageId, chunk) => { deliveredMessageIds.push(messageId); deliveredChunks.push(chunk); }, exactDownloadCreditQuestion);
1425
- turnEffects.push(artifact);
1426
- responseFallbackArtifactId = artifact.id;
1430
+ else {
1431
+ const responseFallback = responseFallbackAfterDeliveries(result, turnEffects.length);
1432
+ if (responseFallback) {
1433
+ const artifact = await deliverButlerEffect(responseFallback, `turn:${subject}:${message.updateId}:delivery:${deliveryOrdinal++}`, undefined, (messageId, chunk) => { deliveredMessageIds.push(messageId); deliveredChunks.push(chunk); }, exactDownloadCreditQuestion);
1434
+ turnEffects.push(artifact);
1435
+ responseFallbackArtifactId = artifact.id;
1436
+ }
1427
1437
  }
1428
1438
  if (result.sessionPath) {
1429
1439
  const causalEventIds = Object.fromEntries((groundingIntentTool ? [] : turnEffects).flatMap((artifact, index) => {
@@ -1440,7 +1450,7 @@ function createTelegramSenseApp(options) {
1440
1450
  component: "senses",
1441
1451
  event: "senses.telegram_turn_end",
1442
1452
  message: "Telegram authorized turn completed",
1443
- meta: lifecycleMeta("senses.telegram_turn_end", { agentName: options.agentName, subject, deliveryCount: Math.max(deliveryCount, hasUserVisibleTurnResponse(result.response) ? 1 : 0), ...acceptanceMeta, ...lifecycleCoordinates, ...(acceptanceMarker ? { outcome: "success", errorDigest: null } : {}) }, Math.max(Date.now(), lifecycleStartedAt + 1)),
1453
+ meta: lifecycleMeta("senses.telegram_turn_end", { agentName: options.agentName, subject, deliveryCount: turnEffects.length, ...acceptanceMeta, ...lifecycleCoordinates, ...(acceptanceMarker ? { outcome: "success", errorDigest: null } : {}) }, Math.max(Date.now(), lifecycleStartedAt + 1)),
1444
1454
  });
1445
1455
  }
1446
1456
  catch (error) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.801",
3
+ "version": "0.1.0-alpha.802",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.801",
9
+ "version": "0.1.0-alpha.802",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.801",
3
+ "version": "0.1.0-alpha.802",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },