@ouro.bot/cli 0.1.0-alpha.945 → 0.1.0-alpha.946

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.946",
6
+ "changes": [
7
+ "Shepherd's notices about your coding agents now reach you on the channel you use, such as iMessage, and every message the agent sends on its own is saved into that conversation, so your reply arrives with context.",
8
+ "On a machine linked to its owner, Shepherd's notices go only to that owner and never to other family members."
9
+ ]
10
+ },
4
11
  {
5
12
  "version": "0.1.0-alpha.945",
6
13
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.945",
2
+ "runtimeVersion": "0.1.0-alpha.946",
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.945</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.946</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REACH_CHANNELS = void 0;
4
+ exports.resolveReachTarget = resolveReachTarget;
5
+ const runtime_1 = require("../nerves/runtime");
6
+ const config_1 = require("./config");
7
+ const session_activity_1 = require("./session-activity");
8
+ /** Channels an agent can reach a person on outside a live conversation. */
9
+ exports.REACH_CHANNELS = ["bluebubbles", "teams"];
10
+ function isReachChannel(value) {
11
+ return typeof value === "string" && exports.REACH_CHANNELS.includes(value);
12
+ }
13
+ /** The friend's id on a channel, when that channel is enabled and the friend has one. */
14
+ function reachableOn(friend, channel, enabled) {
15
+ if (!enabled.has(channel))
16
+ return null;
17
+ const provider = channel === "bluebubbles" ? "imessage-handle" : "aad";
18
+ const ids = Array.isArray(friend.externalIds) ? friend.externalIds : [];
19
+ // A `group:` id is a group chat the friend is in, never a way to reach them alone.
20
+ const id = ids.find(entry => entry?.provider === provider && typeof entry.externalId === "string" && entry.externalId.trim() !== "" &&
21
+ !entry.externalId.startsWith("group:"));
22
+ if (!id)
23
+ return null;
24
+ return { channel, handle: id.externalId.toLowerCase(), ...(channel === "teams" && id.tenantId ? { tenantId: id.tenantId } : {}) };
25
+ }
26
+ /**
27
+ * A 1:1 session key, read back from its sanitized filename. A BlueBubbles key is 1:1 when it is a DM chat GUID
28
+ * (`chat_<service>;-;<handle>`) or the chat-identifier fallback naming this friend's own handle; a group never is.
29
+ * A Teams key is 1:1 when it is a personal conversation id (`a:` sanitized to `a_`).
30
+ */
31
+ function isOneToOne(channel, key, friend) {
32
+ if (channel === "teams")
33
+ return key.startsWith("a_");
34
+ if (key.includes(";+;"))
35
+ return false;
36
+ if (key.startsWith("chat_") && key.includes(";-;"))
37
+ return true;
38
+ const handle = reachableOn(friend, "bluebubbles", new Set(["bluebubbles"]))?.handle;
39
+ return handle !== undefined && key.toLowerCase() === `chat_identifier_${(0, config_1.sanitizeKey)(handle)}`;
40
+ }
41
+ /** The friend's own 1:1 sessions, newest inbound first. Messages the agent recorded never rank a session. */
42
+ function friendSessions(input) {
43
+ return (0, session_activity_1.listSessionActivity)({ sessionsDir: input.sessionsDir, friendsDir: input.friendsDir, agentName: input.agentName,
44
+ activeThresholdMs: Number.POSITIVE_INFINITY, nowMs: input.nowMs, friendId: input.friend.id })
45
+ .filter((record) => record.friendId === input.friend.id && record.activitySource === "friend-facing" && record.lastInboundAt !== null &&
46
+ isReachChannel(record.channel) && isOneToOne(record.channel, record.key, input.friend))
47
+ .sort((a, b) => Date.parse(b.lastInboundAt) - Date.parse(a.lastInboundAt));
48
+ }
49
+ /**
50
+ * Chooses the channel that reaches a friend outside a live conversation: their stated preference when it is current and
51
+ * reachable, else the channel of their newest 1:1 session, else any reachable channel (BlueBubbles, then Teams).
52
+ */
53
+ function resolveReachTarget(input) {
54
+ const { friend, enabledChannels } = input;
55
+ const nowMs = (input.now ?? Date.now)();
56
+ const sessions = friendSessions({ ...input, nowMs });
57
+ const target = (reach) => ({
58
+ channel: reach.channel, key: sessions.find(record => record.channel === reach.channel)?.key ?? null,
59
+ friendId: friend.id, handle: reach.handle, ...(reach.tenantId ? { tenantId: reach.tenantId } : {}),
60
+ });
61
+ const stated = friend.relationshipPolicy?.preferences.reachChannel;
62
+ const current = stated && (stated.provenance === "stated" || stated.provenance === "default") && isReachChannel(stated.value) &&
63
+ (!stated.expiresAt || Date.parse(stated.expiresAt) > nowMs);
64
+ const preferred = current ? stated.value : null;
65
+ const preferredReach = preferred ? reachableOn(friend, preferred, enabledChannels) : null;
66
+ if (preferredReach)
67
+ return target(preferredReach);
68
+ let chosen = null;
69
+ for (const record of sessions) {
70
+ chosen = reachableOn(friend, record.channel, enabledChannels);
71
+ if (chosen)
72
+ break;
73
+ }
74
+ for (const channel of exports.REACH_CHANNELS) {
75
+ if (chosen)
76
+ break;
77
+ chosen = reachableOn(friend, channel, enabledChannels);
78
+ }
79
+ if (preferred) {
80
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.reach_channel_fallback",
81
+ message: `${preferred} cannot reach this friend, so the notice goes ${chosen ? `on ${chosen.channel}` : "nowhere"}`,
82
+ meta: { friendId: friend.id, preferred, chosen: chosen?.channel ?? null } });
83
+ }
84
+ return chosen ? target(chosen) : { unreachable: `no reachable channel: enable BlueBubbles or Teams and link this friend's id`, friendId: friend.id };
85
+ }
@@ -141,6 +141,8 @@ function listSessionActivity(query) {
141
141
  return [];
142
142
  }
143
143
  for (const friendId of friendDirs) {
144
+ if (query.friendId !== undefined && friendId !== query.friendId)
145
+ continue;
144
146
  const friendPath = path.join(sessionsDir, friendId);
145
147
  let channels;
146
148
  try {
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.EVENT_CONTENT_MAX_CHARS = void 0;
36
+ exports.EVENT_CONTENT_MAX_CHARS = exports.OUTBOUND_ORIGINS = void 0;
37
37
  exports.materializeApprovalTerminal = materializeApprovalTerminal;
38
38
  exports.attachApprovalSuspensionCheckpoint = attachApprovalSuspensionCheckpoint;
39
39
  exports.listApprovalSuspensionCheckpoints = listApprovalSuspensionCheckpoints;
@@ -72,6 +72,18 @@ const node_crypto_1 = require("node:crypto");
72
72
  const node_util_1 = require("node:util");
73
73
  const runtime_1 = require("../nerves/runtime");
74
74
  const structured_output_1 = require("./structured-output");
75
+ exports.OUTBOUND_ORIGINS = ["shepherd", "send_message", "voice", "reminder", "proactive"];
76
+ /**
77
+ * A readable outbound marker. Any non-empty origin is kept, so a marker written by a newer harness with an origin this
78
+ * one does not know still dedupes by its deliveryId; extra fields are dropped on normalization.
79
+ */
80
+ function isSessionEventOutbound(value) {
81
+ if (value === null || typeof value !== "object" || Array.isArray(value))
82
+ return false;
83
+ const record = value;
84
+ return typeof record.origin === "string" && record.origin.length > 0
85
+ && typeof record.deliveryId === "string" && record.deliveryId.length > 0;
86
+ }
75
87
  const APPROVAL_TERMINAL_TEXT = {
76
88
  denied: "approval denied by requester; the protected action was not executed",
77
89
  expired: "approval expired before execution; the protected action was not executed",
@@ -1148,6 +1160,9 @@ function normalizeSessionEvent(event, index, recordedAt) {
1148
1160
  captureKind: typeof provenance?.captureKind === "string" ? provenance.captureKind : "live",
1149
1161
  legacyVersion: typeof provenance?.legacyVersion === "number" ? provenance.legacyVersion : null,
1150
1162
  sourceMessageIndex: typeof provenance?.sourceMessageIndex === "number" ? provenance.sourceMessageIndex : null,
1163
+ ...(isSessionEventOutbound(provenance?.outbound)
1164
+ ? { outbound: { origin: provenance.outbound.origin, deliveryId: provenance.outbound.deliveryId } }
1165
+ : {}),
1151
1166
  },
1152
1167
  };
1153
1168
  }
@@ -1482,12 +1497,14 @@ function buildCanonicalSessionEnvelope(options) {
1482
1497
  envelope.structuredOutputs = (0, structured_output_1.extractStructuredOutputsFromEvents)(providerProjectionEvents(envelope));
1483
1498
  return { envelope, evictedEvents };
1484
1499
  }
1485
- function appendSyntheticAssistantEvent(envelope, content, recordedAt) {
1500
+ function appendSyntheticAssistantEvent(envelope, content, recordedAt, outbound) {
1486
1501
  // Use nextEventSequence(events) instead of `events.length + 1` so any gap
1487
1502
  // (from pruning, archive replay, or self-heal dedup) cannot collide with
1488
1503
  // an existing event id. Same fix pattern as line 1046.
1489
1504
  const sequence = nextEventSequence(envelope.events);
1490
1505
  const event = buildEventFromMessage({ role: "assistant", content }, sequence, recordedAt, "synthetic", null, null);
1506
+ if (outbound)
1507
+ event.provenance = { ...event.provenance, outbound: { origin: outbound.origin, deliveryId: outbound.deliveryId } };
1491
1508
  const priorIds = projectedSessionEventIds(envelope);
1492
1509
  const updated = {
1493
1510
  ...envelope,
@@ -318,15 +318,24 @@ function saveSession(filePath, messages, lastUsage, state, lease) {
318
318
  return envelope.events;
319
319
  });
320
320
  }
321
- function appendSyntheticAssistantMessage(filePath, content, lease) {
321
+ /**
322
+ * Appends an assistant message the agent did not produce in this session's own turn. With `createIfMissing`, a missing
323
+ * session starts as an empty envelope. With `outbound`, a delivery already recorded here is not recorded again.
324
+ */
325
+ function appendSyntheticAssistantMessage(filePath, content, lease, options = {}) {
322
326
  try {
323
327
  return withMutationLease(filePath, lease, (owned) => {
324
- if (!fs.existsSync(filePath))
328
+ const recordedAt = new Date().toISOString();
329
+ const exists = fs.existsSync(filePath);
330
+ if (!exists && !options.createIfMissing)
325
331
  return false;
326
- const envelope = (0, session_events_1.loadSessionEnvelopeFile)(filePath);
332
+ const envelope = exists ? (0, session_events_1.loadSessionEnvelopeFile)(filePath) : emptySessionEnvelope(recordedAt);
327
333
  if (!envelope)
328
334
  return false;
329
- const updated = (0, session_events_1.appendSyntheticAssistantEvent)(envelope, content, new Date().toISOString());
335
+ const deliveryId = options.outbound?.deliveryId;
336
+ if (deliveryId && envelope.events.some((event) => event.provenance.outbound?.deliveryId === deliveryId))
337
+ return true;
338
+ const updated = (0, session_events_1.appendSyntheticAssistantEvent)(envelope, content, recordedAt, options.outbound);
330
339
  writeSessionEnvelopeUnderLease(filePath, updated, owned);
331
340
  (0, runtime_1.emitNervesEvent)({
332
341
  component: "mind",
@@ -341,6 +350,18 @@ function appendSyntheticAssistantMessage(filePath, content, lease) {
341
350
  return false;
342
351
  }
343
352
  }
353
+ function emptySessionEnvelope(recordedAt) {
354
+ return (0, session_events_1.buildCanonicalSessionEnvelope)({
355
+ existing: null,
356
+ previousMessages: [],
357
+ currentMessages: [],
358
+ trimmedMessages: [],
359
+ recordedAt,
360
+ lastUsage: null,
361
+ state: null,
362
+ projectionBasis: { maxTokens: null, contextMargin: null, inputTokens: null },
363
+ }).envelope;
364
+ }
344
365
  function loadSession(filePath) {
345
366
  try {
346
367
  const envelope = (0, session_events_1.loadSessionEnvelopeFile)(filePath);
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.OUTBOUND_ORIGIN_LABELS = void 0;
37
+ exports.drainOutboundRecords = drainOutboundRecords;
38
+ exports.recordOutboundInSession = recordOutboundInSession;
39
+ const crypto_1 = require("crypto");
40
+ const fs = __importStar(require("fs"));
41
+ const path = __importStar(require("path"));
42
+ const config_1 = require("../heart/config");
43
+ const runtime_1 = require("../nerves/runtime");
44
+ const context_1 = require("./context");
45
+ const session_transaction_1 = require("./session-transaction");
46
+ /** The folder deferred records for one session wait in; coordinates that would leave or alias it are refused. */
47
+ function deferredDir(agentRoot, friendId, channel, key) {
48
+ const folder = (0, config_1.sanitizeKey)(key);
49
+ if (!(0, config_1.isValidSessionCoordinate)(friendId) || !(0, config_1.isValidSessionCoordinate)(channel) || !(0, config_1.isValidSessionCoordinate)(folder)) {
50
+ throw new Error("invalid outbound record coordinates");
51
+ }
52
+ return path.join(agentRoot, "state", "outbound-records", friendId, channel, folder);
53
+ }
54
+ /** A fixed-length file name per delivery, so long or similar ids neither fail nor collide. */
55
+ function deferredFileName(deliveryId) {
56
+ return `${(0, crypto_1.createHash)("sha256").update(deliveryId).digest("hex").slice(0, 32)}.json`;
57
+ }
58
+ function recordedText(record) {
59
+ return `[sent by ${record.originLabel}] ${record.content}`;
60
+ }
61
+ function recordError(message, meta) {
62
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "mind", event: "mind.outbound_record_error", message, meta });
63
+ }
64
+ function parseDeferred(raw) {
65
+ const value = JSON.parse(raw);
66
+ if (!value || typeof value.content !== "string" || typeof value.origin !== "string" || typeof value.deliveryId !== "string"
67
+ || typeof value.originLabel !== "string" || typeof value.recordedAt !== "string")
68
+ return null;
69
+ return value;
70
+ }
71
+ /** How a recorded message names who sent it: `[sent by <label>]`. */
72
+ exports.OUTBOUND_ORIGIN_LABELS = {
73
+ shepherd: "Shepherd",
74
+ send_message: "send_message",
75
+ voice: "voice",
76
+ reminder: "a reminder",
77
+ proactive: "a proactive message",
78
+ };
79
+ /**
80
+ * Appends every deferred record for this session in `recordedAt` order under the caller's lease, moving each file to
81
+ * `applied/` beside it. Unreadable files are moved too, with an error event. Returns how many records were appended.
82
+ * It never throws, so a turn that drains first is never stopped by it.
83
+ */
84
+ function drainOutboundRecords(agentRoot, friendId, channel, key, lease) {
85
+ try {
86
+ return drainUnderLease(agentRoot, friendId, channel, key, lease);
87
+ }
88
+ catch (error) {
89
+ recordError("could not drain deferred outbound records", { friendId, channel, error: error instanceof Error ? error.message : String(error) });
90
+ return 0;
91
+ }
92
+ }
93
+ function drainUnderLease(agentRoot, friendId, channel, key, lease) {
94
+ const dir = deferredDir(agentRoot, friendId, channel, key);
95
+ let names;
96
+ try {
97
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
98
+ }
99
+ catch {
100
+ return 0;
101
+ }
102
+ if (names.length === 0)
103
+ return 0;
104
+ const applied = path.join(dir, "applied");
105
+ fs.mkdirSync(applied, { recursive: true });
106
+ const entries = names.map((name) => {
107
+ try {
108
+ return { name, record: parseDeferred(fs.readFileSync(path.join(dir, name), "utf8")) };
109
+ }
110
+ catch {
111
+ return { name, record: null };
112
+ }
113
+ });
114
+ entries.sort((a, b) => (a.record?.recordedAt ?? "").localeCompare(b.record?.recordedAt ?? ""));
115
+ let count = 0;
116
+ for (const { name, record } of entries) {
117
+ if (!record) {
118
+ recordError("skipped an unreadable deferred outbound record", { friendId, channel, file: name });
119
+ }
120
+ else if ((0, context_1.appendSyntheticAssistantMessage)(lease.sessionPath, recordedText(record), lease, { outbound: { origin: record.origin, deliveryId: record.deliveryId }, createIfMissing: true })) {
121
+ count += 1;
122
+ }
123
+ else {
124
+ recordError("could not append a deferred outbound record", { friendId, channel, file: name });
125
+ }
126
+ fs.renameSync(path.join(dir, name), path.join(applied, name));
127
+ }
128
+ return count;
129
+ }
130
+ /** Records a delivered message in the friend's session on that channel. Never throws. */
131
+ function recordOutboundInSession(input) {
132
+ const { agentRoot, friendId, channel, key, origin, deliveryId } = input;
133
+ try {
134
+ const dir = deferredDir(agentRoot, friendId, channel, key);
135
+ const sessionPath = (0, config_1.resolveSessionPath)(friendId, channel, key, { agentRoot, confined: true });
136
+ if ((0, session_transaction_1.currentSessionTurnLease)(sessionPath))
137
+ return "skipped_current_session";
138
+ try {
139
+ return (0, session_transaction_1.withImmediateSessionTurnLease)(sessionPath, (lease) => {
140
+ drainOutboundRecords(agentRoot, friendId, channel, key, lease);
141
+ const appended = (0, context_1.appendSyntheticAssistantMessage)(sessionPath, recordedText(input), lease, { outbound: { origin, deliveryId }, createIfMissing: true });
142
+ if (appended)
143
+ return "recorded";
144
+ recordError("could not append an outbound record to its session", { friendId, channel, deliveryId });
145
+ return "failed";
146
+ });
147
+ }
148
+ catch (error) {
149
+ if (!(error instanceof session_transaction_1.SessionTurnBusyError))
150
+ throw error;
151
+ fs.mkdirSync(dir, { recursive: true });
152
+ const file = path.join(dir, deferredFileName(deliveryId));
153
+ const record = { content: input.content, origin, deliveryId, originLabel: input.originLabel, recordedAt: new Date().toISOString() };
154
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(record), { mode: 0o600 });
155
+ fs.renameSync(`${file}.tmp`, file);
156
+ return "deferred";
157
+ }
158
+ }
159
+ catch (error) {
160
+ recordError("could not record an outbound message in its session", {
161
+ friendId, channel, deliveryId, error: error instanceof Error ? error.message : String(error),
162
+ });
163
+ return "failed";
164
+ }
165
+ }
@@ -46,6 +46,7 @@ const diary_1 = require("../mind/diary");
46
46
  const record_paths_1 = require("../mind/record-paths");
47
47
  const provenance_trust_1 = require("../mind/provenance-trust");
48
48
  const relationship_authorization_1 = require("./relationship-authorization");
49
+ const reach_channel_1 = require("../heart/reach-channel");
49
50
  const CLAUDE_READ_ONLY_TOOLS = "Read,Grep,Glob,LS";
50
51
  function bundleRelativeLocator(filePath, ctx) {
51
52
  const agentRoot = ctx?.agentRoot ?? (0, identity_1.getAgentRoot)();
@@ -396,13 +397,13 @@ exports.notesToolDefinitions = [
396
397
  type: "function",
397
398
  function: {
398
399
  name: "save_friend_note",
399
- description: "save something i learned about my friend. use type 'name' to update their display name, 'tool_preference' for presentation preferences, or 'note' for general context. For Sanctuary relationships, preference source is stated (the person said or confirmed it), observed (a revisable pattern i inferred), or default (a fallback until better evidence), and only communication or timing are accepted. Other agents retain their existing integration-preference behavior and do not persist this provenance field. desired state and action authority belong in steward policy. notes and preferences improve orientation but never grant authority. set override=true when replacing an existing value.",
400
+ description: "save something i learned about my friend. use type 'name' to update their display name, 'tool_preference' for presentation preferences, or 'note' for general context. For Sanctuary relationships, preference source is stated (the person said or confirmed it), observed (a revisable pattern i inferred), or default (a fallback until better evidence), and only communication or timing are accepted. Any friend may save key reachChannel (with source) to say which channel to reach them on outside a conversation: bluebubbles or teams; iMessage means bluebubbles. Other agents retain their existing integration-preference behavior and do not persist this provenance field. desired state and action authority belong in steward policy. notes and preferences improve orientation but never grant authority. set override=true when replacing an existing value.",
400
401
  parameters: {
401
402
  type: "object",
402
403
  properties: {
403
404
  type: { type: "string", enum: ["name", "tool_preference", "note"], description: "what kind of information to save" },
404
- key: { type: "string", description: "category key (required for tool_preference and note; Sanctuary relationship preferences use communication or timing)" },
405
- source: { type: "string", enum: ["stated", "observed", "default"], description: "preference provenance; required for Sanctuary relationship preferences" },
405
+ key: { type: "string", description: "category key (required for tool_preference and note; Sanctuary relationship preferences use communication or timing; reachChannel names the channel to reach this person on: bluebubbles or teams)" },
406
+ source: { type: "string", enum: ["stated", "observed", "default"], description: "preference provenance; required for Sanctuary relationship preferences and for reachChannel" },
406
407
  content: { type: "string", description: "the value to save" },
407
408
  override: { type: "string", enum: ["true", "false"], description: "set to 'true' to overwrite an existing value" },
408
409
  },
@@ -431,13 +432,17 @@ exports.notesToolDefinitions = [
431
432
  const actor = ctx.relationshipAuthorization?.actor;
432
433
  const isSanctuaryRelationship = ctx.context.friend?.capabilityProfileId?.startsWith("sanctuary-") === true;
433
434
  const preferenceSource = a.source;
434
- if (isSanctuaryRelationship && a.type === "tool_preference" && !["stated", "observed", "default"].includes(String(preferenceSource))) {
435
+ const isReachChannel = a.type === "tool_preference" && a.key === "reachChannel";
436
+ if ((isSanctuaryRelationship || isReachChannel) && a.type === "tool_preference" && !["stated", "observed", "default"].includes(String(preferenceSource))) {
435
437
  return "i need exact preference provenance: source must be stated, observed, or default";
436
438
  }
437
- if (isSanctuaryRelationship && a.type === "tool_preference" && !["communication", "timing"].includes(a.key ?? "")) {
439
+ if (isReachChannel && !reach_channel_1.REACH_CHANNELS.includes(a.content ?? "")) {
440
+ return "i can only reach people on bluebubbles or teams";
441
+ }
442
+ if (isSanctuaryRelationship && a.type === "tool_preference" && !isReachChannel && !["communication", "timing"].includes(a.key ?? "")) {
438
443
  return "Sanctuary relationship preferences are limited to communication or timing; desired state and action authority belong in steward policy";
439
444
  }
440
- if (actor?.trustLevel === "friend" && (actor.friendId !== friendId || a.type !== "tool_preference" || !["communication", "timing"].includes(a.key ?? ""))) {
445
+ if (actor?.trustLevel === "friend" && (actor.friendId !== friendId || a.type !== "tool_preference" || !["communication", "timing", "reachChannel"].includes(a.key ?? ""))) {
441
446
  return "household members may only save their own communication or timing preferences; preferences never grant authority";
442
447
  }
443
448
  // Validate parameters (input-shape guards stay harness-side so we keep the
@@ -451,7 +456,7 @@ exports.notesToolDefinitions = [
451
456
  return "i need a key for tool_preference or note type";
452
457
  // Record-domain logic lives in @ouro.bot/friends; this wrapper maps the
453
458
  // structured FriendOpResult back to the tool's first-person strings.
454
- if (isSanctuaryRelationship && a.type === "tool_preference" && preferenceSource) {
459
+ if ((isSanctuaryRelationship || isReachChannel) && a.type === "tool_preference" && preferenceSource) {
455
460
  const current = await ctx.friendStore.get(friendId);
456
461
  if (!current)
457
462
  return "i can't find the friend record on disk";
@@ -1006,6 +1006,7 @@ exports.sessionToolDefinitions = [
1006
1006
  text: request.content,
1007
1007
  intent: request.intent,
1008
1008
  authorizingSession: request.authorizingSession,
1009
+ origin: "send_message",
1009
1010
  });
1010
1011
  if (result.delivered) {
1011
1012
  return {
@@ -1031,6 +1032,12 @@ exports.sessionToolDefinitions = [
1031
1032
  detail: "bluebubbles send failed",
1032
1033
  };
1033
1034
  }
1035
+ if (result.reason === "host_account_refused") {
1036
+ return {
1037
+ status: "failed",
1038
+ detail: "the BlueBubbles server is not signed in to this agent's account, so nothing was sent",
1039
+ };
1040
+ }
1034
1041
  return {
1035
1042
  status: "unavailable",
1036
1043
  detail: "live delivery unavailable right now; queued for the next active turn",
@@ -1050,6 +1057,7 @@ exports.sessionToolDefinitions = [
1050
1057
  text: request.content,
1051
1058
  intent: request.intent,
1052
1059
  authorizingSession: request.authorizingSession,
1060
+ origin: "send_message",
1053
1061
  }, {
1054
1062
  botApi: ctx.botApi,
1055
1063
  });
@@ -327,7 +327,11 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
327
327
  await assertHostAccount({ fresh: true });
328
328
  const resolvedChatGuid = await resolveChatGuid(params.chat, config, channelConfig);
329
329
  if (!resolvedChatGuid) {
330
- throw new Error("BlueBubbles send currently requires chat.chatGuid from the inbound event.");
330
+ // Nothing reached the server, so a caller may safely send again once the chat exists.
331
+ throw new BlueBubblesSendError({
332
+ message: "BlueBubbles send currently requires chat.chatGuid from the inbound event.",
333
+ httpStatus: null, errorCode: "chat_unresolved", transportInvoked: false,
334
+ });
331
335
  }
332
336
  const url = buildBlueBubblesApiUrl(config.serverUrl, "/api/v1/message/text", config.password);
333
337
  const body = {
@@ -423,7 +427,7 @@ function createBlueBubblesClient(config = (0, config_1.getBlueBubblesConfig)(),
423
427
  messageGuid: messageGuid ?? null,
424
428
  },
425
429
  });
426
- return { messageGuid };
430
+ return { messageGuid, chatGuid: resolvedChatGuid };
427
431
  },
428
432
  async editMessage(params) {
429
433
  await assertHostAccount();
@@ -73,6 +73,8 @@ const prompt_1 = require("../../mind/prompt");
73
73
  const mcp_manager_1 = require("../../repertoire/mcp-manager");
74
74
  const runtime_1 = require("../../nerves/runtime");
75
75
  const session_transaction_1 = require("../../mind/session-transaction");
76
+ const outbound_record_1 = require("../../mind/outbound-record");
77
+ const await_alert_1 = require("../../heart/awaiting/await-alert");
76
78
  const orientation_frame_1 = require("../../heart/orientation-frame");
77
79
  const proactive_content_guard_1 = require("../proactive-content-guard");
78
80
  const bluebubbles_meta_guard_1 = require("../bluebubbles-meta-guard");
@@ -1527,6 +1529,8 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1527
1529
  const friendId = context.friend.id;
1528
1530
  const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
1529
1531
  return await resolvedDeps.withSessionTurnLease(sessPath, async (sessionTurnLease) => {
1532
+ // Messages sent into this chat while another turn held it wait outside the session; they go in first.
1533
+ (0, outbound_record_1.drainOutboundRecords)((0, identity_1.getAgentRoot)(), friendId, "bluebubbles", event.chat.sessionKey, sessionTurnLease);
1530
1534
  try {
1531
1535
  (0, session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
1532
1536
  }
@@ -3409,6 +3413,43 @@ function buildChatRefForSessionKey(friend, sessionKey) {
3409
3413
  participantHandles: [],
3410
3414
  };
3411
3415
  }
3416
+ /**
3417
+ * The session a proactive send is saved into: the chat key it was sent on, else the friend's existing DM session, else
3418
+ * the DM key an inbound message from their handle would use (`chat:any;-;<handle>`), so their reply loads it.
3419
+ */
3420
+ function proactiveRecordKey(friend, sessionKey, chat, sentChatGuid) {
3421
+ if (normalizeBlueBubblesSessionKey(sessionKey).startsWith("chat:"))
3422
+ return sessionKey;
3423
+ // The DM the server actually sent into is where the friend's reply will be keyed.
3424
+ if (sentChatGuid?.includes(";-;"))
3425
+ return `chat:${sentChatGuid}`;
3426
+ const existing = (0, await_alert_1.resolveAlertKey)((0, identity_1.getAgentRoot)(), friend.id, "bluebubbles");
3427
+ if (existing?.includes(";-;"))
3428
+ return existing;
3429
+ /* v8 ignore start -- a non-chat key is routed by the friend's handle, so a delivered send always had one @preserve */
3430
+ const handle = findImessageHandle(friend) ?? chat.chatIdentifier;
3431
+ return handle ? `chat:any;-;${handle}` : null;
3432
+ /* v8 ignore stop */
3433
+ }
3434
+ /** Saves a delivered message into its chat. Never throws: recording never changes the result of a send that went out. */
3435
+ function recordBlueBubblesProactiveSend(friend, params, chat, sentChatGuid) {
3436
+ try {
3437
+ const key = proactiveRecordKey(friend, params.sessionKey, chat, sentChatGuid);
3438
+ /* v8 ignore next -- a delivered send always had a chat GUID or identifier to send to @preserve */
3439
+ if (!key)
3440
+ return;
3441
+ const origin = params.origin ?? "proactive";
3442
+ (0, outbound_record_1.recordOutboundInSession)({
3443
+ agentRoot: (0, identity_1.getAgentRoot)(), friendId: friend.id, channel: "bluebubbles", key, content: params.text, origin,
3444
+ deliveryId: params.deliveryId ?? `proactive:${(0, node_crypto_1.randomUUID)()}`, originLabel: outbound_record_1.OUTBOUND_ORIGIN_LABELS[origin],
3445
+ });
3446
+ }
3447
+ catch (error) {
3448
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.bluebubbles_proactive_record_error",
3449
+ message: "the BlueBubbles message was sent, but could not be saved into its chat",
3450
+ meta: { friendId: friend.id, error: error instanceof Error ? error.message : String(error) } });
3451
+ }
3452
+ }
3412
3453
  async function sendProactiveBlueBubblesMessageToSession(params, deps = {}) {
3413
3454
  if ((0, bluebubbles_meta_guard_1.containsInternalMetaMarkers)(params.text)) {
3414
3455
  (0, bluebubbles_meta_guard_1.emitBluebubblesMetaBlocked)({
@@ -3528,7 +3569,7 @@ async function sendProactiveBlueBubblesMessageToSession(params, deps = {}) {
3528
3569
  return { delivered: false, reason: "internal_content_blocked" };
3529
3570
  }
3530
3571
  try {
3531
- await client.sendText({ chat, text: params.text });
3572
+ const sent = await client.sendText({ chat, text: params.text });
3532
3573
  (0, runtime_1.emitNervesEvent)({
3533
3574
  component: "senses",
3534
3575
  event: "senses.bluebubbles_proactive_sent",
@@ -3540,9 +3581,30 @@ async function sendProactiveBlueBubblesMessageToSession(params, deps = {}) {
3540
3581
  chatIdentifier: chat.chatIdentifier ?? null,
3541
3582
  },
3542
3583
  });
3584
+ recordBlueBubblesProactiveSend(friend, params, chat, sent?.chatGuid);
3543
3585
  return { delivered: true };
3544
3586
  }
3545
3587
  catch (error) {
3588
+ if (error instanceof client_1.BlueBubblesSendError && error.errorCode === "chat_unresolved") {
3589
+ (0, runtime_1.emitNervesEvent)({
3590
+ level: "warn",
3591
+ component: "senses",
3592
+ event: "senses.bluebubbles_proactive_no_chat",
3593
+ message: "no BlueBubbles chat matched this friend, so nothing was sent",
3594
+ meta: { friendId: params.friendId, sessionKey: params.sessionKey },
3595
+ });
3596
+ return { delivered: false, reason: "missing_target" };
3597
+ }
3598
+ if (error instanceof host_account_verifier_1.BlueBubblesHostAccountError) {
3599
+ (0, runtime_1.emitNervesEvent)({
3600
+ level: "warn",
3601
+ component: "senses",
3602
+ event: "senses.bluebubbles_proactive_host_account_refused",
3603
+ message: "proactive bluebubbles send refused: the server is signed in to another account",
3604
+ meta: { friendId: params.friendId, sessionKey: params.sessionKey, reason: error.message },
3605
+ });
3606
+ return { delivered: false, reason: "host_account_refused" };
3607
+ }
3546
3608
  (0, runtime_1.emitNervesEvent)({
3547
3609
  level: "error",
3548
3610
  component: "senses",
@@ -82,7 +82,7 @@ function feedbackReviewOptions(record, signal) {
82
82
  function startFeedbackWorker(options) {
83
83
  const file = (0, feedback_store_1.feedbackStorePath)(options.agentRoot);
84
84
  const friendStore = options.friendStore ?? new friends_1.FileFriendStore(path.join(options.agentRoot, "friends"));
85
- const send = options.send ?? (0, owner_notice_1.createTeamsNoticeSender)(options.agentRoot);
85
+ const send = options.send ?? (0, owner_notice_1.createNoticeSender)(options.agentRoot);
86
86
  const run = options.run ?? (async (input) => (await Promise.resolve().then(() => __importStar(require("../shared-turn")))).runSenseTurn(input));
87
87
  const owner = (0, node_crypto_1.randomUUID)();
88
88
  const controller = new AbortController();
@@ -137,8 +137,9 @@ function startFeedbackWorker(options) {
137
137
  const name = (0, agent_presentation_1.readAgentPresentation)(options.agent, options.agentRoot).name;
138
138
  const text = `${name} — session feedback\n${record.source.label}\n${record.id}\n\n${record.review.response}`;
139
139
  attempted = true;
140
- const result = await send({ friendId: recipient.friendId, name: record.actorId, key: record.id,
141
- ...(recipient.tenantId ? { tenantId: recipient.tenantId } : {}) }, text);
140
+ // Feedback answers the Teams chat it came from, so it stays on Teams.
141
+ const result = await send({ friendId: recipient.friendId, name: record.actorId, channel: "teams", key: record.id,
142
+ ...(recipient.tenantId ? { tenantId: recipient.tenantId } : {}) }, text, [record.id]);
142
143
  if (result.delivered && result.delivery?.state === "accepted" && result.delivery.conversationId && result.delivery.messageId) {
143
144
  store.completeDelivery(record.id, record.actorId, owner, { state: "sent",
144
145
  conversationId: result.delivery.conversationId, messageId: result.delivery.messageId });
@@ -38,14 +38,19 @@ exports.noticeWanted = noticeWanted;
38
38
  exports.plain = plain;
39
39
  exports.noticeLine = noticeLine;
40
40
  exports.noticeMessage = noticeMessage;
41
+ exports.readEnabledReachChannels = readEnabledReachChannels;
41
42
  exports.resolveNoticeTargets = resolveNoticeTargets;
43
+ exports.noticeDeliveryId = noticeDeliveryId;
42
44
  exports.chooseTeamsBot = chooseTeamsBot;
43
- exports.createTeamsNoticeSender = createTeamsNoticeSender;
45
+ exports.createNoticeSender = createNoticeSender;
44
46
  exports.createOwnerNotifier = createOwnerNotifier;
47
+ const node_crypto_1 = require("node:crypto");
45
48
  const path = __importStar(require("node:path"));
46
49
  const friends_1 = require("@ouro.bot/friends");
47
50
  const await_alert_1 = require("../../heart/awaiting/await-alert");
48
51
  const config_1 = require("../../heart/config");
52
+ const identity_1 = require("../../heart/identity");
53
+ const reach_channel_1 = require("../../heart/reach-channel");
49
54
  const runtime_1 = require("../../nerves/runtime");
50
55
  const notice_outbox_1 = require("./notice-outbox");
51
56
  /**
@@ -157,29 +162,89 @@ function noticeMessage(records, refs = new Map(), context = {}) {
157
162
  : [`Updates from ${sessions.length} coding sessions:`, ...lines.map(line => `- ${line}`), ...(more > 0 ? [`- and ${more} more.`] : [])].join("\n");
158
163
  return context.recovered ? `Includes recovered historical observations, not live status:\n${body}` : body;
159
164
  }
160
- function teamsAadId(friend) {
161
- const ext = friend.externalIds.find((entry) => entry.provider === "aad" && !entry.externalId.startsWith("group:"));
162
- return ext ? { id: ext.externalId.toLowerCase(), tenantId: ext.tenantId } : null;
165
+ /**
166
+ * The channels this agent can reach people on from this machine: its enabled Teams sense, and its enabled BlueBubbles
167
+ * sense when this machine has a BlueBubbles connection (credentials live per machine, so another machine may own it).
168
+ */
169
+ function readEnabledReachChannels(agent, agentRoot, blueBubblesConfig = config_1.getBlueBubblesConfig) {
170
+ const enabled = new Set();
171
+ try {
172
+ const senses = (0, identity_1.loadAgentConfig)({ agentName: agent, agentRoot }).senses;
173
+ if (senses?.bluebubbles?.enabled) {
174
+ try {
175
+ blueBubblesConfig();
176
+ enabled.add("bluebubbles");
177
+ }
178
+ catch (error) {
179
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_notice_bluebubbles_not_attached", message: "BlueBubbles is enabled but not connected on this machine, so owner notices use another channel", meta: { agent, error: error.message } });
180
+ }
181
+ }
182
+ if (senses?.teams?.enabled)
183
+ enabled.add("teams");
184
+ }
185
+ catch (error) {
186
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_notice_channels_unreadable", message: "could not read which channels are enabled, so no owner notice channel is reachable", meta: { agent, error: error.message } });
187
+ }
188
+ return enabled;
189
+ }
190
+ /** Every channel id a friend record holds, in the same `channel:handle` form `resolveReachTarget` returns. */
191
+ function personIdentities(friend) {
192
+ const identities = new Set();
193
+ // Only called for a reachable friend, whose externalIds is therefore a list.
194
+ for (const entry of friend.externalIds) {
195
+ // A `group:` id names a group chat the friend is in, which other people share; it never identifies the person.
196
+ if (typeof entry?.externalId !== "string" || entry.externalId.trim() === "" || entry.externalId.startsWith("group:"))
197
+ continue;
198
+ if (entry.provider === "imessage-handle")
199
+ identities.add(`bluebubbles:${entry.externalId.toLowerCase()}`);
200
+ if (entry.provider === "aad")
201
+ identities.add(`teams:${entry.externalId.toLowerCase()}`);
202
+ }
203
+ return identities;
163
204
  }
164
205
  /**
165
- * Who hears the notice: every family friend reachable on Teams (a Teams user id the bot can open a chat with), once per
166
- * Teams user even when several friend records share one. Teams is the only proactive channel wired today.
206
+ * Who hears the notice, and where: the family friend who is this machine's own user (`local` id), else every family
207
+ * friend, on the channel `resolveReachTarget` chooses for them, once per
208
+ * person on that channel even when several friend records share one id. With no 1:1 session yet, the send opens one:
209
+ * BlueBubbles by the friend's handle, Teams by a new 1:1 conversation.
167
210
  */
168
- async function resolveNoticeTargets(agentRoot, store = new friends_1.FileFriendStore(path.join(agentRoot, "friends"))) {
211
+ async function resolveNoticeTargets(agentRoot, store = new friends_1.FileFriendStore(path.join(agentRoot, "friends")), enabledChannels = new Set(), username = (0, friends_1.machineOwnerUsername)()) {
169
212
  const seen = new Set();
170
213
  const targets = [];
171
- for (const friend of await store.listAll()) {
172
- if (friend.trustLevel !== "family")
214
+ const family = (await store.listAll()).filter(friend => friend.trustLevel === "family");
215
+ // The notice is the owner's: when family friends are linked to this machine's own user, only they hear it, never
216
+ // their relatives, even when none of them is reachable yet.
217
+ const owners = family.filter(friend => (Array.isArray(friend.externalIds) ? friend.externalIds : [])
218
+ .some(entry => typeof entry?.provider === "string" && typeof entry.externalId === "string" && (0, friends_1.isLocalMachineOwnerIdentity)(entry.provider, entry.externalId, username)));
219
+ for (const friend of owners.length > 0 ? owners : family) {
220
+ const reach = (0, reach_channel_1.resolveReachTarget)({
221
+ friend, enabledChannels, sessionsDir: path.join(agentRoot, "state", "sessions"), friendsDir: path.join(agentRoot, "friends"),
222
+ agentName: path.basename(agentRoot, ".ouro"),
223
+ });
224
+ if ("unreachable" in reach)
173
225
  continue;
174
- const aad = teamsAadId(friend);
175
- if (!aad || seen.has(aad.id))
226
+ // One notice per person: a friend whose iMessage handle or Teams id another target already has is that person.
227
+ const identities = personIdentities(friend);
228
+ identities.add(`${reach.channel}:${reach.handle}`);
229
+ if ([...identities].some(identity => seen.has(identity)))
176
230
  continue;
177
- seen.add(aad.id);
178
- targets.push({ friendId: friend.id, name: friend.name, key: (0, await_alert_1.resolveAlertKey)(agentRoot, friend.id, "teams") ?? "shepherd", ...(aad.tenantId ? { tenantId: aad.tenantId } : {}) });
231
+ for (const identity of identities)
232
+ seen.add(identity);
233
+ const alertKey = (0, await_alert_1.resolveAlertKey)(agentRoot, friend.id, reach.channel);
234
+ // A BlueBubbles fallback key must be a DM: a group key would be refused, and "shepherd" sends to the handle.
235
+ const fallback = reach.channel === "bluebubbles" ? (alertKey?.includes(";-;") ? alertKey : null) : alertKey;
236
+ targets.push({ friendId: friend.id, name: friend.name, channel: reach.channel, key: reach.key ?? fallback ?? "shepherd", ...(reach.tenantId ? { tenantId: reach.tenantId } : {}) });
237
+ }
238
+ if (owners.length > 0 && targets.length === 0) {
239
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_notice_owner_unreachable", message: "this machine's owner has no reachable channel, so the Shepherd owner notice waits; link their iMessage handle or Teams id", meta: { owners: owners.length } });
179
240
  }
180
241
  (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.shepherd_notice_targets", message: "resolved Shepherd owner notice targets", meta: { count: targets.length } });
181
242
  return targets;
182
243
  }
244
+ /** One id per batch and person, so a retried batch is saved into the conversation once. */
245
+ function noticeDeliveryId(friendId, noticeIds) {
246
+ return `shepherd:${(0, node_crypto_1.createHash)("sha256").update(`${friendId}\0${[...noticeIds].sort().join(",")}`).digest("hex")}`;
247
+ }
183
248
  /** The bot whose tenant is the friend's: the secondary bot when it serves that tenant, else the primary. */
184
249
  function chooseTeamsBot(tenantId, primary, secondary) {
185
250
  if (tenantId && secondary.clientId && secondary.tenantId === tenantId)
@@ -199,13 +264,33 @@ async function liveBotApi(config) {
199
264
  }
200
265
  /* v8 ignore stop */
201
266
  /**
202
- * Sends through the Teams sense's own proactive path (`sendProactiveTeamsMessageToSession`: trust check, internal-content
203
- * guard, 1:1 chat with the friend's Teams id) with a bot client of this process's own, built from the agent's Teams
204
- * credentials. Nothing listens on a port: the client only acquires a bot token and calls the Bot Framework.
267
+ * Sends on the target's channel through that sense's own proactive path, which applies its trust check, internal-content
268
+ * guard and 1:1 rule, and saves the notice into the friend's conversation.
269
+ *
270
+ * - Teams: `sendProactiveTeamsMessageToSession` with a bot client of this process's own, built from the agent's Teams
271
+ * credentials. Nothing listens on a port: the client only acquires a bot token and calls the Bot Framework.
272
+ * - BlueBubbles: `sendProactiveBlueBubblesMessageToSession` over the BlueBubbles REST API, which checks the server's
273
+ * account before sending. When the client cannot be set up (no credentials, say), the notice stays pending.
205
274
  */
206
- function createTeamsNoticeSender(agentRoot, deps = {}) {
275
+ function createNoticeSender(agentRoot, deps = {}) {
207
276
  const bots = new Map();
208
- return async (target, text) => {
277
+ return async (target, text, noticeIds) => {
278
+ const deliveryId = noticeDeliveryId(target.friendId, noticeIds);
279
+ if (target.channel === "bluebubbles") {
280
+ let client;
281
+ let send;
282
+ try {
283
+ /* v8 ignore next 2 -- tests inject the client and sender; the live ones need BlueBubbles credentials @preserve */
284
+ client = deps.createBlueBubblesClient ? deps.createBlueBubblesClient() : (await Promise.resolve().then(() => __importStar(require("../bluebubbles/client")))).createBlueBubblesClient();
285
+ send = deps.sendBlueBubbles ?? (await Promise.resolve().then(() => __importStar(require("../bluebubbles")))).sendProactiveBlueBubblesMessageToSession;
286
+ }
287
+ catch (error) {
288
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_notice_bluebubbles_unavailable", message: "BlueBubbles could not be set up for the Shepherd owner notice; it stays waiting", meta: { friendId: target.friendId, error: error.message } });
289
+ return { delivered: false, reason: "bluebubbles_unavailable" };
290
+ }
291
+ const store = new friends_1.FileFriendStore(path.join(agentRoot, "friends"));
292
+ return send({ friendId: target.friendId, sessionKey: target.key, text, intent: "generic_outreach", deliveryId, origin: "shepherd" }, { createClient: () => client, createFriendStore: () => store });
293
+ }
209
294
  const config = chooseTeamsBot(target.tenantId, (deps.primary ?? config_1.getTeamsConfig)(), (deps.secondary ?? config_1.getTeamsSecondaryConfig)());
210
295
  if (!config)
211
296
  return { delivered: false, reason: "no_teams_bot" };
@@ -218,14 +303,15 @@ function createTeamsNoticeSender(agentRoot, deps = {}) {
218
303
  bot.catch(() => bots.delete(config.clientId));
219
304
  }
220
305
  const { sendProactiveTeamsMessageToSession } = await Promise.resolve().then(() => __importStar(require("../teams")));
221
- return sendProactiveTeamsMessageToSession({ friendId: target.friendId, sessionKey: target.key, text, textFormat: "plain", intent: "generic_outreach", captureReceipt: true }, { botApi: await bot, store: new friends_1.FileFriendStore(path.join(agentRoot, "friends")) });
306
+ return sendProactiveTeamsMessageToSession({ friendId: target.friendId, sessionKey: target.key, text, textFormat: "plain", intent: "generic_outreach", captureReceipt: true, deliveryId, origin: "shepherd" }, { botApi: await bot, store: new friends_1.FileFriendStore(path.join(agentRoot, "friends")) });
222
307
  };
223
308
  }
224
309
  /** Collects durable notice intents; acceptance is distinct from transport delivery. */
225
310
  function createOwnerNotifier(options) {
226
311
  const { agent, policy } = options;
227
- const resolveTargets = options.resolveTargets ?? (() => resolveNoticeTargets(options.agentRoot));
228
- const send = options.send ?? createTeamsNoticeSender(options.agentRoot);
312
+ const enabledChannels = options.enabledChannels ?? (() => readEnabledReachChannels(agent, options.agentRoot));
313
+ const resolveTargets = options.resolveTargets ?? (() => resolveNoticeTargets(options.agentRoot, undefined, enabledChannels()));
314
+ const send = options.send ?? createNoticeSender(options.agentRoot);
229
315
  const retryWrites = new Map();
230
316
  const recoveredNoticeIds = new Set();
231
317
  const writes = new Set();
@@ -285,7 +371,7 @@ function createOwnerNotifier(options) {
285
371
  if (stopped)
286
372
  return;
287
373
  if (targets.length === 0) {
288
- (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_notice_no_target", message: "no family friend is reachable on Teams, so the Shepherd owner notice was not sent", meta: { agent, returns: entries.length } });
374
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.shepherd_notice_no_target", message: "no family friend is reachable on any enabled channel, so the Shepherd owner notice was not sent", meta: { agent, returns: entries.length } });
289
375
  schedule(exports.NOTICE_RETRY_MS);
290
376
  return;
291
377
  }
@@ -307,7 +393,7 @@ function createOwnerNotifier(options) {
307
393
  break;
308
394
  }
309
395
  try {
310
- const result = await send(target, noticeMessage(batch.map(entry => entry.record), refs, { recovered: ids.some(id => recoveredNoticeIds.has(id)) }));
396
+ const result = await send(target, noticeMessage(batch.map(entry => entry.record), refs, { recovered: ids.some(id => recoveredNoticeIds.has(id)) }), ids);
311
397
  const state = result.delivered ? "sent"
312
398
  : result.delivery ? result.delivery.state === "not_sent" ? "pending" : "unknown"
313
399
  : result.reason && result.reason !== "send_error" ? "pending" : "unknown";
@@ -73,6 +73,7 @@ const commands_2 = require("./commands");
73
73
  const http = __importStar(require("http"));
74
74
  const path = __importStar(require("path"));
75
75
  const node_crypto_1 = require("node:crypto");
76
+ const outbound_record_1 = require("../mind/outbound-record");
76
77
  const trust_gate_1 = require("./trust-gate");
77
78
  const pipeline_1 = require("./pipeline");
78
79
  const session_transaction_1 = require("../mind/session-transaction");
@@ -818,6 +819,8 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
818
819
  try {
819
820
  await runWithLease(sessPath, async (sessionTurnLease) => {
820
821
  reportTeamsRequest("running", conversationId, teamsContext, { traceId, friendId });
822
+ // Messages sent into this conversation while another turn held it wait outside the session; they go in first.
823
+ (0, outbound_record_1.drainOutboundRecords)(owner.agentRoot, friendId, "teams", conversationId, sessionTurnLease);
821
824
  // Proactive feedback acknowledgements are not magically in this model session's
822
825
  // history. Recover their frozen packet only after identity and lease ownership.
823
826
  let feedbackReply;
@@ -1511,6 +1514,20 @@ async function sendProactiveTeamsMessageToSession(params, deps) {
1511
1514
  meta: { friendId: params.friendId, aadObjectId: aadInfo.aadObjectId, sessionKey: params.sessionKey },
1512
1515
  });
1513
1516
  const messageId = activity && typeof activity === "object" && "id" in activity && typeof activity.id === "string" ? activity.id : undefined;
1517
+ // Saved into the conversation it was sent into, so the friend's reply there carries it.
1518
+ const origin = params.origin ?? "proactive";
1519
+ // Recording never changes the result of a send that already went out.
1520
+ try {
1521
+ (0, outbound_record_1.recordOutboundInSession)({
1522
+ agentRoot: (0, identity_1.getAgentRoot)(), friendId: friend.id, channel: "teams", key: conversation.id, content: params.text, origin,
1523
+ deliveryId: params.deliveryId ?? `proactive:${(0, node_crypto_1.randomUUID)()}`, originLabel: outbound_record_1.OUTBOUND_ORIGIN_LABELS[origin],
1524
+ });
1525
+ }
1526
+ catch (error) {
1527
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.teams_proactive_record_error",
1528
+ message: "the Teams message was sent, but could not be saved into its conversation",
1529
+ meta: { friendId: friend.id, error: error instanceof Error ? error.message : String(error) } });
1530
+ }
1514
1531
  return {
1515
1532
  delivered: true,
1516
1533
  ...(params.captureReceipt ? { delivery: { state: "accepted", conversationId, ...(messageId ? { messageId } : {}) } } : {}),
@@ -55,6 +55,7 @@ function defaultOwnerNoticeDeliverers(agentName) {
55
55
  text: request.content,
56
56
  intent: request.intent,
57
57
  authorizingSession: request.authorizingSession,
58
+ origin: "voice",
58
59
  });
59
60
  if (result.delivered)
60
61
  return { status: "delivered_now", detail: "sent to the bluebubbles chat now" };
@@ -64,6 +65,8 @@ function defaultOwnerNoticeDeliverers(agentName) {
64
65
  return { status: "blocked", detail: "blocked: contains internal meta markers" };
65
66
  if (result.reason === "send_error")
66
67
  return { status: "failed", detail: "bluebubbles send failed" };
68
+ if (result.reason === "host_account_refused")
69
+ return { status: "failed", detail: "the BlueBubbles server is not signed in to this agent's account, so nothing was sent" };
67
70
  return { status: "unavailable", detail: "live delivery unavailable right now; queued for the next active turn" };
68
71
  },
69
72
  telegram: async (request) => {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.945",
3
+ "version": "0.1.0-alpha.946",
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.945",
9
+ "version": "0.1.0-alpha.946",
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.945",
3
+ "version": "0.1.0-alpha.946",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },