@ouro.bot/cli 0.1.0-alpha.967 → 0.1.0-alpha.969

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,18 @@
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.969",
6
+ "changes": [
7
+ "When someone replies to an earlier message in Telegram, iMessage or Teams, the agent now sees what they replied to and whose words it was, even when that message isn't in its own history."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.968",
12
+ "changes": [
13
+ "The context-loss Sentinel takes over its write lock when the process that left it is gone. A lock left by a crash or a reboot used to block every Sentinel refresh until someone deleted it, which turned daemon health critical, so `ouro up` failed and self-update rolled new releases back."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.967",
6
18
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.967",
2
+ "runtimeVersion": "0.1.0-alpha.969",
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.967</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.969</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -92,6 +92,49 @@ function atomicWriteJson(filePath, value) {
92
92
  function sleep(ms) {
93
93
  return new Promise((resolve) => setTimeout(resolve, ms));
94
94
  }
95
+ function processGone(pid) {
96
+ try {
97
+ process.kill(pid, 0);
98
+ return false;
99
+ }
100
+ catch (error) {
101
+ return error.code === "ESRCH";
102
+ }
103
+ }
104
+ /**
105
+ * A lock whose writer died (a crash, a reboot) would otherwise block every refresh forever. It is taken over only when it
106
+ * names a process that no longer exists; any other content waits as before. The lock is renamed aside first, so of two
107
+ * waiters only one takes it, and a lock that changed owner in the meantime is put back.
108
+ */
109
+ function breakDeadOwnerLock(lockPath) {
110
+ let owner;
111
+ try {
112
+ owner = fs.readFileSync(lockPath, "utf-8").trim();
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ if (!/^\d+$/.test(owner) || !processGone(Number(owner)))
118
+ return false;
119
+ const aside = `${lockPath}.${process.pid}.${Date.now()}.stale`;
120
+ try {
121
+ fs.renameSync(lockPath, aside);
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ if (fs.readFileSync(aside, "utf-8").trim() !== owner) {
127
+ try {
128
+ fs.linkSync(aside, lockPath);
129
+ }
130
+ catch { /* a newer lock exists; it owns the path */ }
131
+ fs.rmSync(aside, { force: true });
132
+ return false;
133
+ }
134
+ fs.rmSync(aside, { force: true });
135
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "heart", event: "heart.context_loss_sentinel_stale_lock", message: "took over a context-loss Sentinel lock whose owner process is gone", meta: { owner: Number(owner) } });
136
+ return true;
137
+ }
95
138
  async function withFileLock(lockPath, timeoutMs, fn) {
96
139
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
97
140
  const startedAt = Date.now();
@@ -112,6 +155,8 @@ async function withFileLock(lockPath, timeoutMs, fn) {
112
155
  if (code !== "EEXIST") {
113
156
  throw error;
114
157
  }
158
+ if (breakDeadOwnerLock(lockPath))
159
+ continue;
115
160
  if (Date.now() - startedAt > timeoutMs) {
116
161
  throw new Error(`context-loss Sentinel lock timed out: ${lockPath}`);
117
162
  }
@@ -191,14 +191,26 @@ function deriveActionPolicy(signals, combinedSpeech, priorAssistantReferents, la
191
191
  }
192
192
  // A reply names the message it answers. The sense stamps that message's text on the
193
193
  // user message, so "yes" binds to what was replied to, not to the latest thing said.
194
- function stampReplyingToText(message, text) {
195
- ;
196
- message._replyingToText = text;
194
+ // The author says whose words they were; a person can quote their own earlier message.
195
+ function stampReplyingToText(message, text, author) {
196
+ const record = message;
197
+ record._replyingToText = text;
198
+ if (author)
199
+ record._replyingToAuthor = author;
197
200
  }
198
- function replyingToTextOf(message) {
199
- const text = message._replyingToText;
200
- return typeof text === "string" && text.trim() ? text.trim() : undefined;
201
+ function replyingToOf(message) {
202
+ const record = message;
203
+ const text = record._replyingToText;
204
+ if (typeof text !== "string" || !text.trim())
205
+ return undefined;
206
+ const author = record._replyingToAuthor;
207
+ return author === "agent" || author === "person" || author === "other" ? { text: text.trim(), author } : { text: text.trim() };
201
208
  }
209
+ const REPLYING_TO_LABEL = {
210
+ agent: " (your message)",
211
+ person: " (their own earlier message)",
212
+ other: " (someone else's message)",
213
+ };
202
214
  function buildOrientationFrame(input) {
203
215
  const derivedCurrent = input.currentUserMessages
204
216
  ? { messages: input.currentUserMessages, firstIndex: input.messages.length }
@@ -206,10 +218,14 @@ function buildOrientationFrame(input) {
206
218
  const currentUserSpeech = derivedCurrent.messages
207
219
  .map((message) => extractMessageText(message))
208
220
  .filter((text) => text.length > 0);
209
- const replyingToText = derivedCurrent.messages.map(replyingToTextOf).find((text) => text !== undefined);
221
+ const replyingTo = derivedCurrent.messages.map(replyingToOf).find((reply) => reply !== undefined);
210
222
  const previousAssistant = latestAssistantBefore(input.messages, derivedCurrent.firstIndex);
211
- const priorAssistantReferents = extractOrderedListReferents(replyingToText ?? extractMessageText(previousAssistant));
212
- const source = replyingToText && !input.source?.replyingToText ? { kind: input.channel, ...input.source, replyingToText } : input.source;
223
+ // Only the agent's own message holds the list a "the second one" points into.
224
+ const replyListText = replyingTo && (replyingTo.author ?? "agent") === "agent" ? replyingTo.text : undefined;
225
+ const priorAssistantReferents = extractOrderedListReferents(replyListText ?? extractMessageText(previousAssistant));
226
+ const source = replyingTo && !input.source?.replyingToText
227
+ ? { kind: input.channel, ...input.source, replyingToText: replyingTo.text, ...(replyingTo.author ? { replyingToAuthor: replyingTo.author } : {}) }
228
+ : input.source;
213
229
  const candidateStructuredOutput = latestStructuredOutputFrom(input.structuredOutputs);
214
230
  const speechKind = input.speechKind ?? "utterance";
215
231
  const signals = deriveSignals(currentUserSpeech, priorAssistantReferents, candidateStructuredOutput, speechKind);
@@ -316,8 +332,10 @@ function renderOrientationFrame(frame) {
316
332
  lines.push(`- default reply target: ${frame.source.defaultReplyTarget}`);
317
333
  if (frame.source.threadId)
318
334
  lines.push(`- thread id: ${frame.source.threadId}`);
319
- if (frame.source.replyingToText)
320
- lines.push(`- replying to: ${frame.source.replyingToText}`);
335
+ if (frame.source.replyingToText) {
336
+ const label = frame.source.replyingToAuthor ? REPLYING_TO_LABEL[frame.source.replyingToAuthor] : "";
337
+ lines.push(`- replying to${label}: ${frame.source.replyingToText}`);
338
+ }
321
339
  if (frame.source.repairNotice)
322
340
  lines.push(`- repair notice: ${frame.source.repairNotice}`);
323
341
  if (frame.source.routingHint)
@@ -774,6 +774,9 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
774
774
  }
775
775
  },
776
776
  async getMessageText(messageGuid) {
777
+ return (await this.getRepliedMessage(messageGuid))?.text ?? null;
778
+ },
779
+ async getRepliedMessage(messageGuid) {
777
780
  await assertHostAccount();
778
781
  const url = buildRepairUrl(config.serverUrl, messageGuid, config.password);
779
782
  try {
@@ -809,7 +812,12 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
809
812
  message: "fetched message text by guid",
810
813
  meta: { messageGuid },
811
814
  });
812
- return data.text.trim() || null;
815
+ const text = data.text.trim();
816
+ if (!text)
817
+ return null;
818
+ const handle = asRecord(data.handle);
819
+ const senderAddress = handle ? readString(handle, "address")?.trim() : undefined;
820
+ return { text, fromMe: data.isFromMe === true, ...(senderAddress ? { senderAddress } : {}) };
813
821
  }
814
822
  catch (error) {
815
823
  (0, runtime_1.emitNervesEvent)({
@@ -531,7 +531,13 @@ function extractHistoricalLaneSummary(messages) {
531
531
  }
532
532
  return summaries;
533
533
  }
534
- function buildBlueBubblesOrientationSource(event, existingMessages, evidence, conversationKind, repliedToText) {
534
+ // Whose words the replied-to message was: the agent's own account, the person replying, or someone else.
535
+ function repliedMessageAuthor(replied, actorExternalId) {
536
+ if (replied.fromMe)
537
+ return "agent";
538
+ return replied.senderAddress && replied.senderAddress === actorExternalId ? "person" : "other";
539
+ }
540
+ function buildBlueBubblesOrientationSource(event, existingMessages, evidence, conversationKind, repliedToText, repliedToAuthor) {
535
541
  const repairNotice = event.repairNotice?.trim();
536
542
  const actor = evidence.actor;
537
543
  const participants = evidence.participants
@@ -569,7 +575,7 @@ function buildBlueBubblesOrientationSource(event, existingMessages, evidence, co
569
575
  lane: threadId ? "thread" : "top_level",
570
576
  defaultReplyTarget: threadId ? "current_lane" : "top_level",
571
577
  ...(threadId ? { threadId } : {}),
572
- ...(repliedToText ? { replyingToText: repliedToText } : {}),
578
+ ...(repliedToText ? { replyingToText: repliedToText, ...(repliedToAuthor ? { replyingToAuthor: repliedToAuthor } : {}) } : {}),
573
579
  ...(repairNotice ? { repairNotice } : {}),
574
580
  ...(summaries.length > 0 ? { recentLanes: summaries } : {}),
575
581
  ...(hasThreadRoute
@@ -1602,8 +1608,16 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1602
1608
  // Fetch the text of the message being replied to (if this is a threaded reply)
1603
1609
  const threadGuid = event.threadOriginatorGuid?.trim();
1604
1610
  let repliedToText = null;
1611
+ let repliedToAuthor;
1605
1612
  if (threadGuid) {
1606
- repliedToText = await client.getMessageText(threadGuid).catch(() => null);
1613
+ if (client.getRepliedMessage) {
1614
+ const replied = await client.getRepliedMessage(threadGuid).catch(() => null);
1615
+ repliedToText = replied?.text ?? null;
1616
+ repliedToAuthor = replied ? repliedMessageAuthor(replied, options.orientationEvidence.actor.externalId) : undefined;
1617
+ }
1618
+ else {
1619
+ repliedToText = await client.getMessageText(threadGuid).catch(() => null);
1620
+ }
1607
1621
  (0, runtime_1.emitNervesEvent)({
1608
1622
  component: "senses",
1609
1623
  event: "senses.bluebubbles_reply_context",
@@ -1619,7 +1633,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1619
1633
  messages: [...priorMessages, userMessage],
1620
1634
  currentUserMessages: [userMessage],
1621
1635
  structuredOutputs: existing?.structuredOutputs ?? [],
1622
- source: buildBlueBubblesOrientationSource(event, priorMessages, options.orientationEvidence, options.orientationConversationKind, repliedToText),
1636
+ source: buildBlueBubblesOrientationSource(event, priorMessages, options.orientationEvidence, options.orientationConversationKind, repliedToText, repliedToAuthor),
1623
1637
  });
1624
1638
  if (!(0, latest_turn_1.isCurrent)(options.latestTurnCapability)) {
1625
1639
  return { handled: true, notifiedAgent: false, kind: event.kind, reason: "superseded" };
@@ -481,7 +481,11 @@ async function runSenseTurnExclusive(options, owner) {
481
481
  }
482
482
  }
483
483
  const replyToEventId = precommittedIngressEvent?.relations.replyToEventId ?? options.ingressRelations?.replyToEventId;
484
- const repliedToText = spokenText(existing?.events?.find((event) => event.id === replyToEventId));
484
+ const matchedReplyText = spokenText(existing?.events?.find((event) => event.id === replyToEventId));
485
+ const fallbackReplyText = options.replyingTo?.text.trim().slice(0, 1200) ?? "";
486
+ const repliedTo = matchedReplyText
487
+ ? { text: matchedReplyText, author: "agent" }
488
+ : fallbackReplyText ? { text: fallbackReplyText, author: options.replyingTo.author } : undefined;
485
489
  if (precommittedIngressEvent) {
486
490
  const projectedIngress = exactProjectedIngressMessage(existing, sessionMessages, precommittedIngressEvent.id, baseSession.value);
487
491
  if (!projectedIngress || projectedIngress.role !== "user" || projectedIngress.content !== userMessage)
@@ -491,8 +495,8 @@ async function runSenseTurnExclusive(options, owner) {
491
495
  threadRootEventId: precommittedIngressEvent.relations.threadRootEventId,
492
496
  references: precommittedIngressEvent.relations.references,
493
497
  });
494
- if (repliedToText)
495
- (0, orientation_frame_1.stampReplyingToText)(projectedIngress, repliedToText);
498
+ if (repliedTo)
499
+ (0, orientation_frame_1.stampReplyingToText)(projectedIngress, repliedTo.text, repliedTo.author);
496
500
  }
497
501
  // Pending dir
498
502
  const pendingDir = (0, pending_1.getPendingDir)(agentName, friendId, channel, sessionKey);
@@ -602,8 +606,8 @@ async function runSenseTurnExclusive(options, owner) {
602
606
  (0, session_events_1.stampIngressTime)(userMsg);
603
607
  if (options.ingressRelations)
604
608
  (0, session_events_1.stampIngressRelations)(userMsg, options.ingressRelations);
605
- if (repliedToText)
606
- (0, orientation_frame_1.stampReplyingToText)(userMsg, repliedToText);
609
+ if (repliedTo)
610
+ (0, orientation_frame_1.stampReplyingToText)(userMsg, repliedTo.text, repliedTo.author);
607
611
  inboundMessages.push(userMsg);
608
612
  }
609
613
  const turnResult = await (0, pipeline_1.handleInboundTurn)({
@@ -43,6 +43,7 @@ exports.buildWelcomeCard = buildWelcomeCard;
43
43
  exports.createTeamsCallbacks = createTeamsCallbacks;
44
44
  exports.reasoningStatus = reasoningStatus;
45
45
  exports.withConversationLock = withConversationLock;
46
+ exports.parseTeamsQuotedReply = parseTeamsQuotedReply;
46
47
  exports.handleTeamsMessage = handleTeamsMessage;
47
48
  exports.createTeamsSdkLogger = createTeamsSdkLogger;
48
49
  exports.sendProactiveTeamsMessageToSession = sendProactiveTeamsMessageToSession;
@@ -76,6 +77,7 @@ const node_crypto_1 = require("node:crypto");
76
77
  const outbound_record_1 = require("../mind/outbound-record");
77
78
  const trust_gate_1 = require("./trust-gate");
78
79
  const pipeline_1 = require("./pipeline");
80
+ const orientation_frame_1 = require("../heart/orientation-frame");
79
81
  const session_transaction_1 = require("../mind/session-transaction");
80
82
  const teamsFailoverStates = new Map();
81
83
  const pending_1 = require("../mind/pending");
@@ -737,6 +739,52 @@ function getFriendStore() {
737
739
  const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
738
740
  return new friends_1.FileFriendStore(friendsPath);
739
741
  }
742
+ function decodeHtmlText(html) {
743
+ return html
744
+ .replace(/<br\s*\/?>/giu, "\n")
745
+ .replace(/<[^>]*>/gu, "")
746
+ .replace(/&nbsp;/gu, " ")
747
+ .replace(/&lt;/gu, "<")
748
+ .replace(/&gt;/gu, ">")
749
+ .replace(/&quot;/gu, '"')
750
+ .replace(/&#39;/gu, "'")
751
+ .replace(/&amp;/gu, "&")
752
+ .replace(/\s+/gu, " ")
753
+ .trim();
754
+ }
755
+ /**
756
+ * Teams carries a quoted reply as a text/html attachment holding a
757
+ * schema.skype.com/Reply blockquote: the quoted author's id (itemprop="mri")
758
+ * and a preview of their words (itemprop="preview"). Ids compare without the
759
+ * "28:"/"29:" channel prefixes, so the bot's own words read as the agent's.
760
+ */
761
+ // The person's message, with the quote they replied to stamped on it for the orientation frame.
762
+ function teamsUserMessage(content, quotedReply) {
763
+ const message = { role: "user", content };
764
+ if (quotedReply)
765
+ (0, orientation_frame_1.stampReplyingToText)(message, quotedReply.text, quotedReply.author);
766
+ return message;
767
+ }
768
+ function parseTeamsQuotedReply(attachments, ids) {
769
+ for (const attachment of attachments ?? []) {
770
+ if (attachment.contentType !== "text/html" || typeof attachment.content !== "string")
771
+ continue;
772
+ const quote = /<blockquote[^>]*itemtype="http:\/\/schema\.skype\.com\/Reply"[^>]*>([\s\S]*?)<\/blockquote>/iu.exec(attachment.content);
773
+ if (!quote)
774
+ continue;
775
+ const preview = /<p[^>]*itemprop="preview"[^>]*>([\s\S]*?)<\/p>/iu.exec(quote[1]);
776
+ const text = decodeHtmlText(preview ? preview[1] : quote[1]).slice(0, 1200);
777
+ if (!text)
778
+ continue;
779
+ const mri = /itemprop="mri"[^>]*itemid="([^"]+)"/iu.exec(quote[1])?.[1];
780
+ const bare = (id) => id?.replace(/^\d+:/u, "");
781
+ const author = !mri ? undefined
782
+ : bare(mri) === bare(ids.botId) ? "agent"
783
+ : bare(mri) === bare(ids.userId) ? "person" : "other";
784
+ return { text, ...(author ? { author } : {}) };
785
+ }
786
+ return undefined;
787
+ }
740
788
  function reportTeamsRequest(stage, conversationId, context, details = {}) {
741
789
  if (!context?.inboundActivityId)
742
790
  return;
@@ -894,7 +942,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
894
942
  channel: "teams",
895
943
  sessionKey: conversationId,
896
944
  capabilities: teamsCapabilities,
897
- messages: [{ role: "user", content: currentText }],
945
+ messages: [teamsUserMessage(currentText, currentText === text ? teamsContext?.quotedReply : undefined)],
898
946
  continuityIngressTexts: [currentText],
899
947
  callbacks: failoverAwareCallbacks,
900
948
  sessionTurnLease,
@@ -1375,6 +1423,7 @@ function registerBotHandlers(app, label) {
1375
1423
  const teamsContext = {
1376
1424
  inboundActivityId: activity.id,
1377
1425
  replyToId: activity.replyToId,
1426
+ quotedReply: parseTeamsQuotedReply(activity.attachments, { botId: activity.recipient?.id ?? app.id, userId }),
1378
1427
  graphToken,
1379
1428
  adoToken,
1380
1429
  githubToken,
@@ -525,6 +525,12 @@ function createTelegramLongPoll(options) {
525
525
  const rawAttachmentCount = (message) => [
526
526
  message.document, message.audio, message.video, message.voice, message.animation, message.sticker,
527
527
  ].filter(Boolean).length + (message.photo?.length ? 1 : 0);
528
+ const repliedTo = (message, userId) => {
529
+ const reply = message.reply_to_message;
530
+ const quoted = [message.quote?.text, reply.text, reply.caption].find((value) => typeof value === "string" && value.trim());
531
+ const author = reply.from?.is_bot ? "agent" : reply.from && String(reply.from.id) === userId ? "person" : reply.from ? "other" : undefined;
532
+ return { ...(quoted ? { replyToText: quoted.trim() } : {}), ...(author ? { replyToAuthor: author } : {}) };
533
+ };
528
534
  const authorizedMessage = (update, authority) => {
529
535
  const message = update.message;
530
536
  const userId = message?.from ? String(message.from.id) : "";
@@ -548,7 +554,7 @@ function createTelegramLongPoll(options) {
548
554
  attachments,
549
555
  ...(attachmentNotices.length ? { attachmentNotices } : {}),
550
556
  ...(Number.isSafeInteger(message.reply_to_message?.message_id) && message.reply_to_message.message_id > 0
551
- ? { replyToMessageId: String(message.reply_to_message.message_id) }
557
+ ? { replyToMessageId: String(message.reply_to_message.message_id), ...repliedTo(message, userId) }
552
558
  : {}),
553
559
  ...(authority ? { authority } : {}),
554
560
  };
@@ -1605,6 +1605,7 @@ function createTelegramSenseApp(options) {
1605
1605
  },
1606
1606
  userMessage: message.text,
1607
1607
  ingressRelations,
1608
+ ...(message.replyToText ? { replyingTo: { text: message.replyToText, ...(message.replyToAuthor ? { author: message.replyToAuthor } : {}) } } : {}),
1608
1609
  ...(ingressReceipt ? { precommittedIngress: ingressReceipt } : {}),
1609
1610
  turnMetricsObserver,
1610
1611
  emptyResponseFallback: fullVisibility.emptyResponseFallback,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.967",
3
+ "version": "0.1.0-alpha.969",
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.967",
9
+ "version": "0.1.0-alpha.969",
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.967",
3
+ "version": "0.1.0-alpha.969",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },