@ouro.bot/cli 0.1.0-alpha.484 → 0.1.0-alpha.486
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 +17 -0
- package/dist/heart/active-work.js +89 -3
- package/dist/heart/background-operations.js +26 -3
- package/dist/heart/daemon/cli-exec.js +171 -9
- package/dist/heart/mail-import-discovery.js +37 -2
- package/dist/heart/providers/azure.js +1 -1
- package/dist/heart/providers/github-copilot.js +1 -1
- package/dist/heart/providers/openai-codex.js +1 -1
- package/dist/heart/session-events.js +40 -3
- package/dist/heart/streaming.js +13 -2
- package/dist/mailroom/blob-store.js +16 -10
- package/dist/mailroom/core.js +1 -1
- package/dist/mailroom/file-store.js +35 -9
- package/dist/mailroom/mbox-import.js +41 -0
- package/dist/mailroom/reader.js +22 -0
- package/dist/mailroom/search-cache.js +182 -0
- package/dist/mailroom/search-relevance.js +319 -0
- package/dist/mind/context.js +36 -4
- package/dist/mind/friends/resolver.js +16 -1
- package/dist/nerves/coverage/file-completeness.js +4 -0
- package/dist/repertoire/tools-mail.js +453 -68
- package/dist/senses/bluebubbles/inbound-log.js +13 -0
- package/dist/senses/bluebubbles/index.js +394 -236
- package/dist/senses/bluebubbles/processed-log.js +111 -0
- package/dist/senses/inner-dialog-worker.js +38 -2
- package/dist/senses/mail.js +19 -3
- package/dist/senses/trust-gate.js +96 -1
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
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.getBlueBubblesProcessedLogPath = getBlueBubblesProcessedLogPath;
|
|
37
|
+
exports.hasProcessedBlueBubblesMessage = hasProcessedBlueBubblesMessage;
|
|
38
|
+
exports.recordProcessedBlueBubblesMessage = recordProcessedBlueBubblesMessage;
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const config_1 = require("../../heart/config");
|
|
42
|
+
const identity_1 = require("../../heart/identity");
|
|
43
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
44
|
+
function getBlueBubblesProcessedLogPath(agentName, sessionKey) {
|
|
45
|
+
return path.join((0, identity_1.getAgentRoot)(agentName), "state", "senses", "bluebubbles", "processed", `${(0, config_1.sanitizeKey)(sessionKey)}.ndjson`);
|
|
46
|
+
}
|
|
47
|
+
function readEntries(filePath) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
50
|
+
return raw
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => line.trim())
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.map((line) => JSON.parse(line))
|
|
55
|
+
.filter((entry) => typeof entry.messageGuid === "string" && typeof entry.sessionKey === "string");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function hasProcessedBlueBubblesMessage(agentName, sessionKey, messageGuid) {
|
|
62
|
+
if (!messageGuid.trim())
|
|
63
|
+
return false;
|
|
64
|
+
const filePath = getBlueBubblesProcessedLogPath(agentName, sessionKey);
|
|
65
|
+
return readEntries(filePath).some((entry) => entry.messageGuid === messageGuid);
|
|
66
|
+
}
|
|
67
|
+
function recordProcessedBlueBubblesMessage(agentName, event, source, outcome) {
|
|
68
|
+
const filePath = getBlueBubblesProcessedLogPath(agentName, event.chat.sessionKey);
|
|
69
|
+
try {
|
|
70
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
71
|
+
if (event.messageGuid.trim() && readEntries(filePath).some((entry) => entry.messageGuid === event.messageGuid)) {
|
|
72
|
+
return filePath;
|
|
73
|
+
}
|
|
74
|
+
fs.appendFileSync(filePath, JSON.stringify({
|
|
75
|
+
recordedAt: new Date().toISOString(),
|
|
76
|
+
messageGuid: event.messageGuid,
|
|
77
|
+
sessionKey: event.chat.sessionKey,
|
|
78
|
+
source,
|
|
79
|
+
outcome,
|
|
80
|
+
}) + "\n", "utf-8");
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
(0, runtime_1.emitNervesEvent)({
|
|
84
|
+
level: "warn",
|
|
85
|
+
component: "senses",
|
|
86
|
+
event: "senses.bluebubbles_processed_log_error",
|
|
87
|
+
message: "failed to record bluebubbles processed sidecar log",
|
|
88
|
+
meta: {
|
|
89
|
+
agentName,
|
|
90
|
+
messageGuid: event.messageGuid,
|
|
91
|
+
sessionKey: event.chat.sessionKey,
|
|
92
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
return filePath;
|
|
96
|
+
}
|
|
97
|
+
(0, runtime_1.emitNervesEvent)({
|
|
98
|
+
component: "senses",
|
|
99
|
+
event: "senses.bluebubbles_processed_logged",
|
|
100
|
+
message: "recorded handled bluebubbles message to processed sidecar log",
|
|
101
|
+
meta: {
|
|
102
|
+
agentName,
|
|
103
|
+
messageGuid: event.messageGuid,
|
|
104
|
+
sessionKey: event.chat.sessionKey,
|
|
105
|
+
source,
|
|
106
|
+
outcome,
|
|
107
|
+
path: filePath,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
return filePath;
|
|
111
|
+
}
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MAX_CONSECUTIVE_INSTINCT_TURNS = void 0;
|
|
36
37
|
exports.createInnerDialogWorker = createInnerDialogWorker;
|
|
37
38
|
exports.startInnerDialogWorker = startInnerDialogWorker;
|
|
38
39
|
const path = __importStar(require("path"));
|
|
@@ -41,6 +42,20 @@ const runtime_1 = require("../nerves/runtime");
|
|
|
41
42
|
const identity_1 = require("../heart/identity");
|
|
42
43
|
const pending_1 = require("../mind/pending");
|
|
43
44
|
const habit_runtime_state_1 = require("../heart/habits/habit-runtime-state");
|
|
45
|
+
/**
|
|
46
|
+
* Cap on consecutive `instinct` follow-on turns triggered by `hasPendingWork()`
|
|
47
|
+
* with no externally-queued work in between. Without this cap, a turn that
|
|
48
|
+
* writes anything back into the inner-dialog pending dir as a side effect of
|
|
49
|
+
* processing (e.g. a surface tool routing a response) puts the worker into
|
|
50
|
+
* a self-sustaining loop where the next turn's drain produces another write,
|
|
51
|
+
* and so on. Real workflows rarely chain more than 2–3 instinct turns; an
|
|
52
|
+
* external trigger (habit, poke, chat) resets the counter so legitimate
|
|
53
|
+
* follow-on work is unaffected.
|
|
54
|
+
*
|
|
55
|
+
* Three feels right: legitimate cascading follow-ups (e.g. processing a
|
|
56
|
+
* batch of delegated returns) get through; a true self-loop caps fast.
|
|
57
|
+
*/
|
|
58
|
+
exports.MAX_CONSECUTIVE_INSTINCT_TURNS = 3;
|
|
44
59
|
function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runInnerDialogTurn)(options), hasPendingWork = () => (0, pending_1.hasPendingMessages)((0, pending_1.getInnerDialogPendingDir)((0, identity_1.getAgentName)()))) {
|
|
45
60
|
let running = false;
|
|
46
61
|
const queue = [];
|
|
@@ -54,6 +69,7 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
|
|
|
54
69
|
let nextReason = reason;
|
|
55
70
|
let nextTaskId = taskId;
|
|
56
71
|
let nextHabitName = habitName;
|
|
72
|
+
let consecutiveInstinctTurns = reason === "instinct" ? 1 : 0;
|
|
57
73
|
do {
|
|
58
74
|
try {
|
|
59
75
|
await runTurn({ reason: nextReason, taskId: nextTaskId, habitName: nextHabitName });
|
|
@@ -82,16 +98,36 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
|
|
|
82
98
|
// Habit file/state may be unavailable during the turn — skip gracefully
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
|
-
// Drain queue first
|
|
101
|
+
// Drain queue first. Externally-queued work resets the instinct cap
|
|
102
|
+
// because a real outside trigger arrived between turns.
|
|
86
103
|
if (queue.length > 0) {
|
|
87
104
|
const next = queue.shift();
|
|
88
105
|
nextReason = next.reason;
|
|
89
106
|
nextTaskId = next.taskId;
|
|
90
107
|
nextHabitName = next.habitName;
|
|
108
|
+
consecutiveInstinctTurns = nextReason === "instinct" ? consecutiveInstinctTurns + 1 : 0;
|
|
91
109
|
continue;
|
|
92
110
|
}
|
|
93
|
-
// Then check hasPendingWork fallback
|
|
111
|
+
// Then check hasPendingWork fallback. This is the loop site: any
|
|
112
|
+
// tool that writes to the inner-dialog pending dir during a turn
|
|
113
|
+
// would cause hasPendingWork() to be true here, producing a
|
|
114
|
+
// self-sustaining "instinct" loop with no external input. Cap it.
|
|
94
115
|
if (hasPendingWork()) {
|
|
116
|
+
if (consecutiveInstinctTurns >= exports.MAX_CONSECUTIVE_INSTINCT_TURNS) {
|
|
117
|
+
(0, runtime_1.emitNervesEvent)({
|
|
118
|
+
level: "warn",
|
|
119
|
+
component: "senses",
|
|
120
|
+
event: "senses.inner_dialog_worker_instinct_loop_capped",
|
|
121
|
+
message: "inner dialog worker stopped chaining instinct turns; pending work remains for next external trigger",
|
|
122
|
+
meta: {
|
|
123
|
+
consecutiveInstinctTurns,
|
|
124
|
+
cap: exports.MAX_CONSECUTIVE_INSTINCT_TURNS,
|
|
125
|
+
lastReason: nextReason,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
consecutiveInstinctTurns += 1;
|
|
95
131
|
nextReason = "instinct";
|
|
96
132
|
nextTaskId = undefined;
|
|
97
133
|
nextHabitName = undefined;
|
package/dist/senses/mail.js
CHANGED
|
@@ -135,14 +135,29 @@ function stringArray(value) {
|
|
|
135
135
|
return [];
|
|
136
136
|
return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
137
137
|
}
|
|
138
|
-
function
|
|
138
|
+
function candidateDescriptors(value) {
|
|
139
|
+
if (!Array.isArray(value))
|
|
140
|
+
return [];
|
|
141
|
+
return value
|
|
142
|
+
.filter((entry) => !!entry && typeof entry === "object" && !Array.isArray(entry))
|
|
143
|
+
.map((entry) => ({
|
|
144
|
+
path: typeof entry.path === "string" ? entry.path : "",
|
|
145
|
+
originKind: typeof entry.originKind === "string" ? entry.originKind : undefined,
|
|
146
|
+
originLabel: typeof entry.originLabel === "string" ? entry.originLabel : undefined,
|
|
147
|
+
}))
|
|
148
|
+
.filter((entry) => entry.path.trim().length > 0);
|
|
149
|
+
}
|
|
150
|
+
function renderImportDiscoveryContent(candidatePaths, descriptors) {
|
|
151
|
+
const renderedCandidates = descriptors.length > 0
|
|
152
|
+
? descriptors.map((descriptor) => `- [${descriptor.originLabel ?? descriptor.originKind ?? "filesystem"}] ${descriptor.path}`)
|
|
153
|
+
: candidatePaths.map((candidatePath) => `- ${candidatePath}`);
|
|
139
154
|
return [
|
|
140
155
|
"[Mail Import Ready]",
|
|
141
156
|
"A local MBOX archive is ready for delegated-mail backfill.",
|
|
142
157
|
"This may live in a worktree-local Playwright sandbox rather than ~/Downloads.",
|
|
143
158
|
"",
|
|
144
159
|
"recent candidates:",
|
|
145
|
-
...
|
|
160
|
+
...renderedCandidates,
|
|
146
161
|
"",
|
|
147
162
|
"If this matches an expected mailbox backfill, run `ouro mail import-mbox --discover --owner-email <email> --source hey --agent <agent>` first so Ouro can pick the matching archive or report ambiguity.",
|
|
148
163
|
].join("\n");
|
|
@@ -170,6 +185,7 @@ async function scanMailImportDiscoveryAttention(input) {
|
|
|
170
185
|
? discovered.spec.fingerprint
|
|
171
186
|
: null;
|
|
172
187
|
const candidatePaths = stringArray(discovered?.spec?.candidatePaths);
|
|
188
|
+
const descriptors = candidateDescriptors(discovered?.spec?.candidateDescriptors);
|
|
173
189
|
const shouldQueue = Boolean(discovered && fingerprint && fingerprint !== state.lastNotifiedFingerprint);
|
|
174
190
|
if (shouldQueue) {
|
|
175
191
|
(0, pending_1.queuePendingMessage)(pendingDir, {
|
|
@@ -177,7 +193,7 @@ async function scanMailImportDiscoveryAttention(input) {
|
|
|
177
193
|
friendId: "self",
|
|
178
194
|
channel: "mail",
|
|
179
195
|
key: "import-ready",
|
|
180
|
-
content: renderImportDiscoveryContent(candidatePaths),
|
|
196
|
+
content: renderImportDiscoveryContent(candidatePaths, descriptors),
|
|
181
197
|
timestamp: nowMs,
|
|
182
198
|
mode: "reflect",
|
|
183
199
|
});
|
|
@@ -93,6 +93,91 @@ function writeInnerPendingNotice(bundleRoot, noticeContent, nowIso) {
|
|
|
93
93
|
fs.mkdirSync(innerPendingDir, { recursive: true });
|
|
94
94
|
fs.writeFileSync(filePath, JSON.stringify(payload), "utf-8");
|
|
95
95
|
}
|
|
96
|
+
const ACKNOWLEDGED_GROUPS_FILENAME = "acknowledged-auto-groups.json";
|
|
97
|
+
function acknowledgedGroupsPath(bundleRoot) {
|
|
98
|
+
return path.join(bundleRoot, "state", ACKNOWLEDGED_GROUPS_FILENAME);
|
|
99
|
+
}
|
|
100
|
+
function loadAcknowledgedGroupsState(bundleRoot) {
|
|
101
|
+
try {
|
|
102
|
+
const raw = fs.readFileSync(acknowledgedGroupsPath(bundleRoot), "utf-8");
|
|
103
|
+
if (!raw.trim())
|
|
104
|
+
return {};
|
|
105
|
+
const parsed = JSON.parse(raw);
|
|
106
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
107
|
+
return {};
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function persistAcknowledgedGroupsState(bundleRoot, state) {
|
|
115
|
+
const target = acknowledgedGroupsPath(bundleRoot);
|
|
116
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
117
|
+
fs.writeFileSync(target, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* For BlueBubbles group chats that were auto-created at stranger trust (no
|
|
121
|
+
* explicit operator/agent action ever bound the harness to this group), the
|
|
122
|
+
* gate's family-member bypass would otherwise let messages flow through
|
|
123
|
+
* silently and the agent would accumulate a session it has no mental model
|
|
124
|
+
* for. Surface the relationship as an inner-pending notice exactly once so
|
|
125
|
+
* the agent can categorize / rename / dismiss the group on its next turn.
|
|
126
|
+
*
|
|
127
|
+
* Returns true if a notice was written so callers can emit a telemetry event.
|
|
128
|
+
*/
|
|
129
|
+
function maybeSurfaceAutoCreatedGroup(input, bundleRoot, nowIso) {
|
|
130
|
+
// Caller guarantees isGroupChat = true (only invoked from the family-member
|
|
131
|
+
// bypass branch); skip a redundant guard here.
|
|
132
|
+
if (input.friend.trustLevel !== "stranger")
|
|
133
|
+
return false;
|
|
134
|
+
if (!input.friend.notes?.["autoCreatedGroup"])
|
|
135
|
+
return false;
|
|
136
|
+
// loadAcknowledgedGroupsState is defensive (its own try/catch returns {})
|
|
137
|
+
// so we don't wrap it in another try here.
|
|
138
|
+
const state = loadAcknowledgedGroupsState(bundleRoot);
|
|
139
|
+
if (state[input.friend.id])
|
|
140
|
+
return false;
|
|
141
|
+
const noticeContent = `New BlueBubbles group "${input.friend.name}" became active without explicit acknowledgment. ` +
|
|
142
|
+
`It was auto-created at stranger trust the first time a message routed through it. ` +
|
|
143
|
+
`If you recognize the group, label or rename it (and consider promoting trust); if not, you can leave it as a stranger group or rename it for clarity. ` +
|
|
144
|
+
`external id: ${input.externalId}; friend id: ${input.friend.id}.`;
|
|
145
|
+
try {
|
|
146
|
+
writeInnerPendingNotice(bundleRoot, noticeContent, nowIso);
|
|
147
|
+
persistAcknowledgedGroupsState(bundleRoot, {
|
|
148
|
+
...state,
|
|
149
|
+
[input.friend.id]: { surfacedAt: nowIso },
|
|
150
|
+
});
|
|
151
|
+
(0, runtime_1.emitNervesEvent)({
|
|
152
|
+
level: "info",
|
|
153
|
+
component: "senses",
|
|
154
|
+
event: "senses.trust_gate_group_acknowledgment_surfaced",
|
|
155
|
+
message: "auto-created group surfaced for agent acknowledgment",
|
|
156
|
+
meta: {
|
|
157
|
+
friendId: input.friend.id,
|
|
158
|
+
friendName: input.friend.name,
|
|
159
|
+
externalId: input.externalId,
|
|
160
|
+
provider: input.provider,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return true;
|
|
164
|
+
/* v8 ignore start -- defensive: surfacing failure must not block the gate decision @preserve */
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
(0, runtime_1.emitNervesEvent)({
|
|
168
|
+
level: "error",
|
|
169
|
+
component: "senses",
|
|
170
|
+
event: "senses.trust_gate_error",
|
|
171
|
+
message: "failed to surface auto-created group for acknowledgment",
|
|
172
|
+
meta: {
|
|
173
|
+
friendId: input.friend.id,
|
|
174
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
/* v8 ignore stop */
|
|
180
|
+
}
|
|
96
181
|
function enforceTrustGate(input) {
|
|
97
182
|
const { senseType } = input;
|
|
98
183
|
// Local (CLI) and internal (inner dialog) — always allow
|
|
@@ -104,8 +189,18 @@ function enforceTrustGate(input) {
|
|
|
104
189
|
return { allowed: true };
|
|
105
190
|
}
|
|
106
191
|
// Open senses (BlueBubbles/iMessage) — enforce trust rules
|
|
107
|
-
// Group chat with a family member present — allow regardless of trust level
|
|
192
|
+
// Group chat with a family member present — allow regardless of trust level.
|
|
193
|
+
// BUT if this is an auto-created stranger group (the harness picked it up
|
|
194
|
+
// silently via the family-member shortcut and the agent never explicitly
|
|
195
|
+
// acknowledged it), surface a one-time inner-pending notice so the agent
|
|
196
|
+
// gets a chance to categorize / rename / dismiss the relationship instead
|
|
197
|
+
// of accumulating activity invisibly.
|
|
108
198
|
if (input.isGroupChat && input.groupHasFamilyMember) {
|
|
199
|
+
/* v8 ignore start -- defaults shared with the rest of the gate; tested via the stranger-trust path */
|
|
200
|
+
const bundleRoot = input.bundleRoot ?? (0, identity_1.getAgentRoot)();
|
|
201
|
+
const nowIso = (input.now ?? (() => new Date()))().toISOString();
|
|
202
|
+
/* v8 ignore stop */
|
|
203
|
+
maybeSurfaceAutoCreatedGroup(input, bundleRoot, nowIso);
|
|
109
204
|
return { allowed: true };
|
|
110
205
|
}
|
|
111
206
|
const trustLevel = input.friend.trustLevel ?? "friend";
|