@ouro.bot/cli 0.1.0-alpha.951 → 0.1.0-alpha.953

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,20 @@
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.953",
6
+ "changes": [
7
+ "Shepherd works both ways on any channel: every notice ends with \"Reply here and I'll pass your answer on.\", and when you answer one in iMessage, Teams or any other conversation, your agent recognizes the notice as its own, reads that coding session in Herdr or cmux, passes your answer on, and tells you what it sent.",
8
+ "iMessage no longer drops a slow reply: a turn now has ten minutes instead of two before it is treated as hung, and a newer message from you still replaces an older turn. A turn that truly hangs can now mean up to ten minutes of silence, so send another message to move on. The health check calls a turn stalled only after five minutes."
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.1.0-alpha.952",
13
+ "changes": [
14
+ "An iMessage held back by the recovery budget or storm breaker is no longer lost: it stays pending, and a later recovery pass handles it once the block lifts. Budget and breaker windows now run on the real clock instead of the message's own timestamp, so the block can actually lift.",
15
+ "`ouro friend dedupe` now finds records that share the same person identity, such as a stale stranger record holding an iMessage handle that a family record also owns, and proposes merging them into the higher-trust record. Group-chat handles are not treated as a shared identity."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.951",
6
20
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.951",
2
+ "runtimeVersion": "0.1.0-alpha.953",
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.951</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.953</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -41,7 +41,7 @@ exports.dedupeFriends = dedupeFriends;
41
41
  // Merging friend records into one person, and retiring the merged-away records.
42
42
  //
43
43
  // Two product paths use this: redeeming an identity link code (a new channel identity
44
- // joins its owner's record) and deduplicating records that name the same local person.
44
+ // joins its owner's record) and deduplicating records that name the same person.
45
45
  // The target record stays authoritative: a merge never raises trust, admission,
46
46
  // initiative or capability, so merging cannot be used to escalate anyone.
47
47
  //
@@ -246,7 +246,11 @@ async function mergeFriends(input) {
246
246
  function isLocalOnly(record) {
247
247
  return record.externalIds.length > 0 && record.externalIds.every((externalId) => externalId.provider === "local");
248
248
  }
249
+ const TRUST_RANK = { stranger: 0, acquaintance: 1, friend: 2, family: 3 };
249
250
  function canonicalOrder(a, b) {
251
+ const trust = TRUST_RANK[b.trustLevel ?? "friend"] - TRUST_RANK[a.trustLevel ?? "friend"];
252
+ if (trust !== 0)
253
+ return trust;
250
254
  const notes = Object.keys(b.notes).length - Object.keys(a.notes).length;
251
255
  if (notes !== 0)
252
256
  return notes;
@@ -254,46 +258,48 @@ function canonicalOrder(a, b) {
254
258
  return a.createdAt < b.createdAt ? -1 : 1;
255
259
  return a.id < b.id ? -1 : 1;
256
260
  }
261
+ /** A group-chat handle names every member of the chat, so it never identifies one person. */
262
+ function isPersonIdentity(externalId) {
263
+ return externalId.provider !== "local"
264
+ && !(externalId.provider === "imessage-handle" && externalId.externalId.startsWith("group:"));
265
+ }
257
266
  /**
258
- * Records are duplicates when they are human, carry only local identities, share the
259
- * same name and trust level, and are joined through at least one shared local identity.
260
- * The canonical record is the one with the most notes, then the oldest.
267
+ * Human records are duplicates when either
268
+ * - they carry only local identities, share the same name and trust level, and are joined
269
+ * through at least one shared local identity, or
270
+ * - they share an identical person identity (provider, id and tenant). The friends store
271
+ * refuses to resolve such an identity because it is bound to more than one record.
272
+ * The canonical record is the one with the highest trust, then the most notes, then the oldest.
261
273
  */
262
274
  function planFriendDedupe(records) {
263
- const buckets = new Map();
264
- for (const record of records) {
265
- if ((record.kind ?? "human") !== "human" || !isLocalOnly(record))
266
- continue;
267
- const key = JSON.stringify([record.name.trim().toLowerCase(), record.trustLevel ?? "friend"]);
268
- buckets.set(key, [...(buckets.get(key) ?? []), record]);
269
- }
270
- const groups = [];
271
- for (const bucket of buckets.values()) {
272
- // Union records that share any local identity.
273
- const parent = bucket.map((_, index) => index);
274
- const find = (index) => (parent[index] === index ? index : (parent[index] = find(parent[index])));
275
- const owner = new Map();
276
- bucket.forEach((record, index) => {
277
- for (const externalId of record.externalIds) {
278
- const key = externalIdKey(externalId);
279
- const seen = owner.get(key);
280
- if (seen === undefined)
281
- owner.set(key, index);
282
- else
283
- parent[find(index)] = find(seen);
284
- }
285
- });
286
- const components = new Map();
287
- bucket.forEach((record, index) => {
288
- const root = find(index);
289
- components.set(root, [...(components.get(root) ?? []), record]);
290
- });
291
- for (const members of components.values()) {
292
- if (members.length < 2)
275
+ const humans = records.filter((record) => (record.kind ?? "human") === "human");
276
+ const parent = humans.map((_, index) => index);
277
+ const find = (index) => (parent[index] === index ? index : (parent[index] = find(parent[index])));
278
+ const owner = new Map();
279
+ humans.forEach((record, index) => {
280
+ const localBucket = isLocalOnly(record) ? [record.name.trim().toLowerCase(), record.trustLevel ?? "friend"] : null;
281
+ for (const externalId of record.externalIds) {
282
+ if (!localBucket && !isPersonIdentity(externalId))
293
283
  continue;
294
- const [canonical, ...duplicates] = [...members].sort(canonicalOrder);
295
- groups.push({ canonical: canonical, duplicates });
284
+ const key = JSON.stringify([...(localBucket ?? []), externalIdKey(externalId)]);
285
+ const seen = owner.get(key);
286
+ if (seen === undefined)
287
+ owner.set(key, index);
288
+ else
289
+ parent[find(index)] = find(seen);
296
290
  }
291
+ });
292
+ const components = new Map();
293
+ humans.forEach((record, index) => {
294
+ const root = find(index);
295
+ components.set(root, [...(components.get(root) ?? []), record]);
296
+ });
297
+ const groups = [];
298
+ for (const members of components.values()) {
299
+ if (members.length < 2)
300
+ continue;
301
+ const [canonical, ...duplicates] = [...members].sort(canonicalOrder);
302
+ groups.push({ canonical: canonical, duplicates });
297
303
  }
298
304
  return groups.sort((a, b) => canonicalOrder(a.canonical, b.canonical));
299
305
  }
@@ -311,7 +317,7 @@ async function dedupeFriends(input) {
311
317
  targetId: group.canonical.id,
312
318
  sourceIds: duplicateIds,
313
319
  actor: input.actor,
314
- reason: "duplicate local records of the same person",
320
+ reason: "duplicate records of the same person",
315
321
  dryRun,
316
322
  ...(input.now ? { now: input.now } : {}),
317
323
  });
@@ -56,7 +56,7 @@ function deferredFileName(deliveryId) {
56
56
  return `${(0, crypto_1.createHash)("sha256").update(deliveryId).digest("hex").slice(0, 32)}.json`;
57
57
  }
58
58
  function recordedText(record) {
59
- return `[sent by ${record.originLabel}] ${record.content}`;
59
+ return `[you sent this via ${record.originLabel}] ${record.content}`;
60
60
  }
61
61
  function recordError(message, meta) {
62
62
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "mind", event: "mind.outbound_record_error", message, meta });
@@ -68,7 +68,10 @@ function parseDeferred(raw) {
68
68
  return null;
69
69
  return value;
70
70
  }
71
- /** How a recorded message names who sent it: `[sent by <label>]`. */
71
+ /**
72
+ * How a recorded message names the way it was sent: `[you sent this via <label>]`. Every record is the agent's own
73
+ * message, so a reply to it on any channel is a reply to the agent, which owns and can act on it.
74
+ */
72
75
  exports.OUTBOUND_ORIGIN_LABELS = {
73
76
  shepherd: "Shepherd",
74
77
  send_message: "send_message",
@@ -229,7 +229,7 @@ exports.shepherdSendToolDefinition = {
229
229
  type: "function",
230
230
  function: {
231
231
  name: "shepherd_send",
232
- description: `Type into one coding agent's terminal on this machine for the human you are talking with, because they asked you to. Use it only to submit that human's own instruction or keypress ("tell it to run the tests", "approve that", "stop it"): never on your own initiative, and never for anyone else. Give exactly one of text or key. text is one line (at most ${exports.SEND_TEXT_MAX_CHARS} characters, no control characters), typed as "[<your configured name> for <their name>] <text>" and submitted with Enter. key is pressed alone: one of ${exports.SEND_KEYS.join(", ")} (a menu or permission prompt, escape to interrupt, ctrl-c to cancel). enter, tab, a digit, y and n can approve or choose on a prompt, so they need screen from a shepherd_read made just before, and nothing is sent if the terminal changed since; tell the human what a prompt asks before approving it unless they have seen it. Pass screen with text too when the text answers what is on screen. To interrupt and then instruct, send escape, read again, then send the text. It also types into the terminal the human has focused on this machine. submission.state accepted means only that the terminal host accepted input; it may still be queued behind credential approval or a busy agent. It does not prove receiver receipt, resumed execution, restored capability or useful progress. Report those as unverified, never "unblocked" or "all unblocked" from this result or an unchanged screen. Each stronger claim needs fresh evidence from the exact target instance after this attempt, tied to this instruction; receipt alone is not progress or restored credentials. unknown may already have arrived: preserve the attempt and do not blindly resend.`,
232
+ description: `Type into one coding agent's terminal on this machine for the human you are talking with, because they asked you to. Use it only to submit that human's own instruction or keypress ("tell it to run the tests", "approve that", "stop it"): never on your own initiative, and never for anyone else. When they answer a Shepherd notice you sent them, on any channel, with something meant for that coding session, that is such a request: shepherd_read the session the notice names, check it still shows the agent and folder the notice named, send their answer, then tell them what you sent. A thank-you or a question for you is not; if it is unclear which session or what to send, ask. Give exactly one of text or key. text is one line (at most ${exports.SEND_TEXT_MAX_CHARS} characters, no control characters), typed as "[<your configured name> for <their name>] <text>" and submitted with Enter. key is pressed alone: one of ${exports.SEND_KEYS.join(", ")} (a menu or permission prompt, escape to interrupt, ctrl-c to cancel). enter, tab, a digit, y and n can approve or choose on a prompt, so they need screen from a shepherd_read made just before, and nothing is sent if the terminal changed since; tell the human what a prompt asks before approving it unless they have seen it. Pass screen with text too when the text answers what is on screen. To interrupt and then instruct, send escape, read again, then send the text. It also types into the terminal the human has focused on this machine. submission.state accepted means only that the terminal host accepted input; it may still be queued behind credential approval or a busy agent. It does not prove receiver receipt, resumed execution, restored capability or useful progress. Report those as unverified, never "unblocked" or "all unblocked" from this result or an unchanged screen. Each stronger claim needs fresh evidence from the exact target instance after this attempt, tied to this instruction; receipt alone is not progress or restored credentials. unknown may already have arrived: preserve the attempt and do not blindly resend.`,
233
233
  parameters: {
234
234
  type: "object",
235
235
  properties: {
@@ -325,9 +325,10 @@ const defaultDeps = {
325
325
  const BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS = 30_000;
326
326
  const BLUEBUBBLES_RECOVERY_PASS_DELAY_MS = 1_000;
327
327
  const BLUEBUBBLES_RECOVERY_PASS_INTERVAL_MS = 30_000;
328
- const BLUEBUBBLES_LIVE_TURN_TIMEOUT_MS = 2 * 60_000;
329
- const BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS = 10 * 60_000;
330
- const BLUEBUBBLES_LIVE_TURN_STALLED_MS = 90_000;
328
+ // A turn that is still thinking after this long is treated as hung. It is generous on purpose: a slow reply that
329
+ // arrives is worth far more than a dropped one, and a newer message from the person still supersedes an older turn.
330
+ const BLUEBUBBLES_TURN_TIMEOUT_MS = 10 * 60_000;
331
+ const BLUEBUBBLES_LIVE_TURN_STALLED_MS = 5 * 60_000;
331
332
  const BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS = 2_000;
332
333
  const BLUEBUBBLES_ACTIVITY_OPERATION_TIMEOUT_MS = 20_000;
333
334
  const BLUEBUBBLES_CATCHUP_PAGE_SIZE = 50;
@@ -616,11 +617,6 @@ function getBlueBubblesContinuityIngressTexts(event) {
616
617
  function sha256Hex(input) {
617
618
  return (0, node_crypto_1.createHash)("sha256").update(input).digest("hex");
618
619
  }
619
- function timestampIsoForAutonomy(event) {
620
- return Number.isFinite(event.timestamp)
621
- ? new Date(event.timestamp).toISOString()
622
- : new Date().toISOString();
623
- }
624
620
  function blueBubblesAutonomyTarget(event) {
625
621
  return {
626
622
  messageGuid: event.messageGuid,
@@ -659,7 +655,6 @@ function recordBlueBubblesRecoveryFailureForBudget(agentName, event, source) {
659
655
  try {
660
656
  (0, autonomy_budget_1.recordAutonomyFailure)((0, identity_1.getAgentRoot)(agentName), {
661
657
  ...blueBubblesRecoveryStormInput(agentName, event, source),
662
- occurredAt: timestampIsoForAutonomy(event),
663
658
  });
664
659
  }
665
660
  catch {
@@ -1488,7 +1483,6 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1488
1483
  senseOrHabit: "bluebubbles",
1489
1484
  target: blueBubblesAutonomyTarget(event),
1490
1485
  idempotencyKey: `bluebubbles-recovery:${source}:${event.messageGuid}`,
1491
- now: timestampIsoForAutonomy(event),
1492
1486
  storm: blueBubblesRecoveryStormInput(agentName, event, source),
1493
1487
  });
1494
1488
  if (!decision.allowed) {
@@ -1735,7 +1729,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1735
1729
  const [groupHasFamilyMember, hasExistingGroupWithFamily] = trustContext;
1736
1730
  lifecycleSignal?.throwIfAborted();
1737
1731
  // ── Call shared pipeline ────────────────────────────────────────
1738
- const timeoutMs = options.timeoutMs ?? BLUEBUBBLES_LIVE_TURN_TIMEOUT_MS;
1732
+ const timeoutMs = options.timeoutMs ?? BLUEBUBBLES_TURN_TIMEOUT_MS;
1739
1733
  timeoutPromise = new Promise((resolve) => {
1740
1734
  resolveTimeout = resolve;
1741
1735
  });
@@ -2634,6 +2628,10 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2634
2628
  publishAcceptedReceipt,
2635
2629
  };
2636
2630
  result = await handleBlueBubblesNormalizedEvent(canonicalEvent, resolvedDeps, source, capturedOptions);
2631
+ // A budget or storm-breaker block defers the message; it was never seen.
2632
+ // Leave it pending so a later recovery pass retries it once the block lifts.
2633
+ if (result.reason === "autonomy_budget_blocked")
2634
+ return result;
2637
2635
  handledOutcome = result.notifiedAgent ? "message_completed" : "message_observed";
2638
2636
  }
2639
2637
  if (result.notifiedAgent) {
@@ -3022,7 +3020,7 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
3022
3020
  }
3023
3021
  semanticGenerationOwned = true;
3024
3022
  const handled = await handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "upstream-catchup", {
3025
- timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
3023
+ timeoutMs: BLUEBUBBLES_TURN_TIMEOUT_MS,
3026
3024
  autonomyBudgetTrigger: "recovery",
3027
3025
  observationReservation,
3028
3026
  lifecycleSignal: options.lifecycleSignal,
@@ -3138,7 +3136,7 @@ async function recoverCapturedBlueBubblesInboundMessages(deps = {}, options = {}
3138
3136
  normalized,
3139
3137
  writeResult: "semantic_capture_duplicate",
3140
3138
  }, resolvedDeps, "webhook", {
3141
- timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
3139
+ timeoutMs: BLUEBUBBLES_TURN_TIMEOUT_MS,
3142
3140
  autonomyBudgetTrigger: "recovery",
3143
3141
  semanticRecovery: true,
3144
3142
  observationReservation,
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.NOTICE_RETRY_MS = exports.NOTICE_LIST_MAX = exports.NOTICE_COALESCE_MS = void 0;
36
+ exports.NOTICE_REPLY_LINE = exports.NOTICE_RETRY_MS = exports.NOTICE_LIST_MAX = exports.NOTICE_COALESCE_MS = void 0;
37
37
  exports.noticeWanted = noticeWanted;
38
38
  exports.plain = plain;
39
39
  exports.noticeLine = noticeLine;
@@ -132,11 +132,13 @@ function noticeLine(record, location) {
132
132
  case "working":
133
133
  return `${subject} is still working in the background and has news. ${why}`;
134
134
  case "unclear":
135
- return `Shepherd could not determine the current state of ${subject}. ${why}`;
135
+ return `I could not determine the current state of ${subject}. ${why}`;
136
136
  default:
137
- return `Shepherd could not complete its observation of ${subject}; the agent's state was not determined. ${why}`;
137
+ return `I could not complete my observation of ${subject}; the agent's state was not determined. ${why}`;
138
138
  }
139
139
  }
140
+ /** Closes every notice: the person answers in the same conversation, and the agent relays it with `shepherd_send`. */
141
+ exports.NOTICE_REPLY_LINE = "Reply here and I'll pass your answer on.";
140
142
  /** A bounded display of latest observations; all input records retain delivery custody. */
141
143
  function noticeMessage(records, refs = new Map(), context = {}) {
142
144
  const latest = new Map();
@@ -160,7 +162,7 @@ function noticeMessage(records, refs = new Map(), context = {}) {
160
162
  const more = sessions.length - lines.length;
161
163
  const body = sessions.length === 1 ? lines[0]
162
164
  : [`Updates from ${sessions.length} coding sessions:`, ...lines.map(line => `- ${line}`), ...(more > 0 ? [`- and ${more} more.`] : [])].join("\n");
163
- return context.recovered ? `Includes recovered historical observations, not live status:\n${body}` : body;
165
+ return `${context.recovered ? `Includes recovered historical observations, not live status:\n${body}` : body}\n\n${exports.NOTICE_REPLY_LINE}`;
164
166
  }
165
167
  /**
166
168
  * The channels this agent can reach people on from this machine: its enabled Teams sense, and its enabled BlueBubbles
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.951",
3
+ "version": "0.1.0-alpha.953",
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.951",
9
+ "version": "0.1.0-alpha.953",
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.951",
3
+ "version": "0.1.0-alpha.953",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },