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

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,13 @@
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.952",
6
+ "changes": [
7
+ "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.",
8
+ "`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."
9
+ ]
10
+ },
4
11
  {
5
12
  "version": "0.1.0-alpha.951",
6
13
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.951",
2
+ "runtimeVersion": "0.1.0-alpha.952",
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.952</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
  });
@@ -616,11 +616,6 @@ function getBlueBubblesContinuityIngressTexts(event) {
616
616
  function sha256Hex(input) {
617
617
  return (0, node_crypto_1.createHash)("sha256").update(input).digest("hex");
618
618
  }
619
- function timestampIsoForAutonomy(event) {
620
- return Number.isFinite(event.timestamp)
621
- ? new Date(event.timestamp).toISOString()
622
- : new Date().toISOString();
623
- }
624
619
  function blueBubblesAutonomyTarget(event) {
625
620
  return {
626
621
  messageGuid: event.messageGuid,
@@ -659,7 +654,6 @@ function recordBlueBubblesRecoveryFailureForBudget(agentName, event, source) {
659
654
  try {
660
655
  (0, autonomy_budget_1.recordAutonomyFailure)((0, identity_1.getAgentRoot)(agentName), {
661
656
  ...blueBubblesRecoveryStormInput(agentName, event, source),
662
- occurredAt: timestampIsoForAutonomy(event),
663
657
  });
664
658
  }
665
659
  catch {
@@ -1488,7 +1482,6 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1488
1482
  senseOrHabit: "bluebubbles",
1489
1483
  target: blueBubblesAutonomyTarget(event),
1490
1484
  idempotencyKey: `bluebubbles-recovery:${source}:${event.messageGuid}`,
1491
- now: timestampIsoForAutonomy(event),
1492
1485
  storm: blueBubblesRecoveryStormInput(agentName, event, source),
1493
1486
  });
1494
1487
  if (!decision.allowed) {
@@ -2634,6 +2627,10 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2634
2627
  publishAcceptedReceipt,
2635
2628
  };
2636
2629
  result = await handleBlueBubblesNormalizedEvent(canonicalEvent, resolvedDeps, source, capturedOptions);
2630
+ // A budget or storm-breaker block defers the message; it was never seen.
2631
+ // Leave it pending so a later recovery pass retries it once the block lifts.
2632
+ if (result.reason === "autonomy_budget_blocked")
2633
+ return result;
2637
2634
  handledOutcome = result.notifiedAgent ? "message_completed" : "message_observed";
2638
2635
  }
2639
2636
  if (result.notifiedAgent) {
@@ -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.952",
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.952",
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.952",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },