@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 +7 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/reach-channel.js +85 -0
- package/dist/heart/session-activity.js +2 -0
- package/dist/heart/session-events.js +19 -2
- package/dist/mind/context.js +25 -4
- package/dist/mind/outbound-record.js +165 -0
- package/dist/repertoire/tools-notes.js +12 -7
- package/dist/repertoire/tools-session.js +8 -0
- package/dist/senses/bluebubbles/client.js +6 -2
- package/dist/senses/bluebubbles/index.js +63 -1
- package/dist/senses/shepherd/feedback-worker.js +4 -3
- package/dist/senses/shepherd/owner-notice.js +109 -23
- package/dist/senses/teams.js +17 -0
- package/dist/senses/voice/local-audio-notify.js +3 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
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,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.
|
|
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,
|
package/dist/mind/context.js
CHANGED
|
@@ -318,15 +318,24 @@ function saveSession(filePath, messages, lastUsage, state, lease) {
|
|
|
318
318
|
return envelope.events;
|
|
319
319
|
});
|
|
320
320
|
}
|
|
321
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
141
|
-
|
|
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.
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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:
|
|
166
|
-
*
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
203
|
-
* guard
|
|
204
|
-
*
|
|
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
|
|
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
|
|
228
|
-
const
|
|
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
|
|
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";
|
package/dist/senses/teams.js
CHANGED
|
@@ -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) => {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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",
|