@ouro.bot/cli 0.1.0-alpha.484 → 0.1.0-alpha.485
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,14 @@
|
|
|
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.485",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Session JSON storage no longer accumulates duplicate event ids when two writers race for the same session — `parseSessionEnvelope` now dedupes on read (last-occurrence-wins) so existing corrupted sessions self-heal on the next save, `buildCanonicalSessionEnvelope` assigns the next sequence as `max(existing) + 1` instead of `events.length + 1` so pruning gaps cannot collide, and `deferPostTurnPersist` serializes per-`sessPath` through an in-process queue so concurrent BlueBubbles webhooks for the same chat (or CLI postTurn racing the inner-dialog turn for the same MCP session) cannot interleave their writes.",
|
|
8
|
+
"Auto-created BlueBubbles group friends are now marked with a `notes.autoCreatedGroup` flag at resolver time, and the trust gate's family-member bypass surfaces a one-time inner-pending notice the first time messages route through an unacknowledged stranger-trust group so the agent can label, rename, or dismiss the relationship before activity accumulates invisibly.",
|
|
9
|
+
"Inner-dialog worker now caps consecutive `instinct` follow-on turns at `MAX_CONSECUTIVE_INSTINCT_TURNS = 3` to break self-sustaining loops where a tool that writes to the inner-dialog pending dir during a turn would otherwise re-fire the worker indefinitely; externally-queued messages reset the counter so legitimate cascading follow-ups still run, and a new `senses.inner_dialog_worker_instinct_loop_capped` event surfaces when the cap fires."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
4
12
|
{
|
|
5
13
|
"version": "0.1.0-alpha.484",
|
|
6
14
|
"changes": [
|
|
@@ -254,6 +254,35 @@ function messageFingerprint(message) {
|
|
|
254
254
|
function makeEventId(sequence) {
|
|
255
255
|
return `evt-${String(sequence).padStart(6, "0")}`;
|
|
256
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Collapse duplicate event ids to a single entry, last-occurrence-wins.
|
|
259
|
+
*
|
|
260
|
+
* Concurrent writers in older versions of postTurnPersist could each load the
|
|
261
|
+
* envelope, compute `events.length + 1` for the next sequence, and both write
|
|
262
|
+
* an event with the same id. The duplicates would persist in the saved JSON
|
|
263
|
+
* and confuse downstream replay (the same outbound message could appear to
|
|
264
|
+
* have been sent twice from the agent's perspective without the agent knowing
|
|
265
|
+
* it sent it). We dedupe defensively on every load so corrupted sessions
|
|
266
|
+
* self-heal on the next save and so any future race produces a consistent
|
|
267
|
+
* view.
|
|
268
|
+
*/
|
|
269
|
+
function dedupeEventsByIdLastWins(events) {
|
|
270
|
+
// Index id → last position so we can preserve original order while
|
|
271
|
+
// collapsing duplicates to their final occurrence.
|
|
272
|
+
const lastIndexById = new Map();
|
|
273
|
+
for (let i = 0; i < events.length; i++) {
|
|
274
|
+
lastIndexById.set(events[i].id, i);
|
|
275
|
+
}
|
|
276
|
+
return events.filter((event, index) => lastIndexById.get(event.id) === index);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The next sequence to assign for a freshly-built event. Uses max(existing
|
|
280
|
+
* sequences) + 1 rather than `events.length + 1` so that gaps from earlier
|
|
281
|
+
* pruning, archive replay, or self-heal dedup never produce a colliding id.
|
|
282
|
+
*/
|
|
283
|
+
function nextEventSequence(existing) {
|
|
284
|
+
return existing.reduce((max, event) => Math.max(max, event.sequence), 0) + 1;
|
|
285
|
+
}
|
|
257
286
|
function validateSessionMessages(messages) {
|
|
258
287
|
const violations = [];
|
|
259
288
|
let prevNonToolRole = null;
|
|
@@ -665,7 +694,7 @@ function parseSessionEnvelope(raw, options = {}) {
|
|
|
665
694
|
if (record.version !== 2 || !Array.isArray(record.events) || !record.projection || typeof record.projection !== "object") {
|
|
666
695
|
return null;
|
|
667
696
|
}
|
|
668
|
-
const
|
|
697
|
+
const rawEvents = record.events
|
|
669
698
|
.filter((event) => event != null && typeof event === "object")
|
|
670
699
|
.map((event, index) => {
|
|
671
700
|
const role = normalizeRole(event.role);
|
|
@@ -705,6 +734,12 @@ function parseSessionEnvelope(raw, options = {}) {
|
|
|
705
734
|
},
|
|
706
735
|
};
|
|
707
736
|
});
|
|
737
|
+
// Self-heal duplicate event ids that may have been written by concurrent
|
|
738
|
+
// writers in older harness versions. Last-occurrence-wins by id (later
|
|
739
|
+
// entries in the persisted file are the more recent state for that id).
|
|
740
|
+
// We preserve the original document order otherwise, so projection.eventIds
|
|
741
|
+
// still resolves predictably.
|
|
742
|
+
const events = dedupeEventsByIdLastWins(rawEvents);
|
|
708
743
|
const projection = record.projection;
|
|
709
744
|
return {
|
|
710
745
|
version: 2,
|
|
@@ -821,8 +856,10 @@ function buildCanonicalSessionEnvelope(options) {
|
|
|
821
856
|
else {
|
|
822
857
|
if (!isSystem)
|
|
823
858
|
nonSystemSeen++;
|
|
824
|
-
// Create a new event
|
|
825
|
-
|
|
859
|
+
// Create a new event. Use nextEventSequence(events) instead of
|
|
860
|
+
// `events.length + 1` so that any gap (from pruning, archive replay,
|
|
861
|
+
// or self-heal dedup) cannot collide with an existing id.
|
|
862
|
+
const event = buildEventFromMessage(currentMessages[i], nextEventSequence(events), options.recordedAt, "live", null, null, currentIngressTimes[i]);
|
|
826
863
|
events.push(event);
|
|
827
864
|
currentEventIds.push(event.id);
|
|
828
865
|
}
|
package/dist/mind/context.js
CHANGED
|
@@ -315,11 +315,43 @@ function postTurnPersist(sessPath, prepared, usage, state) {
|
|
|
315
315
|
return envelope.events;
|
|
316
316
|
}
|
|
317
317
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
318
|
+
* Per-sessPath serialization queue. Without this, two concurrent
|
|
319
|
+
* `deferPostTurnPersist` calls (e.g. two BlueBubbles webhooks for the same
|
|
320
|
+
* chat firing back-to-back, or a CLI postTurn racing the inner-dialog turn
|
|
321
|
+
* for the same MCP session) would each load the envelope, both compute the
|
|
322
|
+
* same "next sequence", and write events with colliding ids. The session
|
|
323
|
+
* file would silently accumulate duplicates and replay would diverge from
|
|
324
|
+
* what was actually sent on the wire.
|
|
325
|
+
*
|
|
326
|
+
* The queue is in-process only — it does not protect against multiple Node
|
|
327
|
+
* processes writing to the same file. The dedup-on-load behaviour in
|
|
328
|
+
* parseSessionEnvelope keeps cross-process races from leaving permanent
|
|
329
|
+
* corruption; this serializer just keeps the common (single-process) case
|
|
330
|
+
* race-free in the first place.
|
|
331
|
+
*/
|
|
332
|
+
const sessionPersistQueues = new Map();
|
|
333
|
+
function enqueueSessionPersist(sessPath, fn) {
|
|
334
|
+
const previous = sessionPersistQueues.get(sessPath) ?? Promise.resolve();
|
|
335
|
+
// Chain on the previous tail. fn runs whether previous resolved or rejected,
|
|
336
|
+
// so one failed turn cannot block subsequent turns on the same session.
|
|
337
|
+
const next = previous.then(fn, fn);
|
|
338
|
+
// Save a swallowed-rejection sentinel as the new tail so the next caller's
|
|
339
|
+
// `previous.then(fn, fn)` sees a clean resolution; the original `next` still
|
|
340
|
+
// propagates rejection to its own caller as expected.
|
|
341
|
+
/* v8 ignore start -- the swallow only matters when fn rejects, which is the failure path covered separately */
|
|
342
|
+
const sentinel = next.then(() => undefined, () => undefined);
|
|
343
|
+
/* v8 ignore stop */
|
|
344
|
+
sessionPersistQueues.set(sessPath, sentinel);
|
|
345
|
+
return next;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Deferred persist: same as postTurnPersist but runs on the next event loop
|
|
349
|
+
* tick AND serializes against any other deferred persist for the same
|
|
350
|
+
* sessPath, so concurrent turns cannot race and produce duplicate event ids
|
|
351
|
+
* in the saved session.
|
|
320
352
|
*/
|
|
321
353
|
function deferPostTurnPersist(sessPath, prepared, usage, state) {
|
|
322
|
-
return new Promise((resolve) => {
|
|
354
|
+
return enqueueSessionPersist(sessPath, () => new Promise((resolve) => {
|
|
323
355
|
setImmediate(() => {
|
|
324
356
|
try {
|
|
325
357
|
const events = postTurnPersist(sessPath, prepared, usage, state);
|
|
@@ -336,7 +368,7 @@ function deferPostTurnPersist(sessPath, prepared, usage, state) {
|
|
|
336
368
|
resolve([]);
|
|
337
369
|
}
|
|
338
370
|
});
|
|
339
|
-
});
|
|
371
|
+
}));
|
|
340
372
|
}
|
|
341
373
|
function deleteSession(filePath) {
|
|
342
374
|
try {
|
|
@@ -87,6 +87,21 @@ class FriendResolver {
|
|
|
87
87
|
hasAnyFriends = false;
|
|
88
88
|
}
|
|
89
89
|
const isFirstImprint = !hasAnyFriends;
|
|
90
|
+
// BlueBubbles group chats route through here as `imessage-handle` with an
|
|
91
|
+
// externalId of the form `group:any;+;<chatHash>`. When the harness auto-
|
|
92
|
+
// creates the group friend at stranger trust, we mark the record so that
|
|
93
|
+
// the trust gate can surface the relationship for explicit acknowledgment
|
|
94
|
+
// later instead of letting messages accumulate silently.
|
|
95
|
+
const isImessageGroup = this.params.provider === "imessage-handle" &&
|
|
96
|
+
typeof this.params.externalId === "string" &&
|
|
97
|
+
this.params.externalId.startsWith("group:");
|
|
98
|
+
const notes = {};
|
|
99
|
+
if (this.params.displayName !== "Unknown") {
|
|
100
|
+
notes.name = { value: this.params.displayName, savedAt: now };
|
|
101
|
+
}
|
|
102
|
+
if (isImessageGroup && !isFirstImprint) {
|
|
103
|
+
notes.autoCreatedGroup = { value: "true", savedAt: now };
|
|
104
|
+
}
|
|
90
105
|
const friend = {
|
|
91
106
|
id: (0, crypto_1.randomUUID)(),
|
|
92
107
|
name: this.params.displayName,
|
|
@@ -96,7 +111,7 @@ class FriendResolver {
|
|
|
96
111
|
externalIds: [externalId],
|
|
97
112
|
tenantMemberships,
|
|
98
113
|
toolPreferences: {},
|
|
99
|
-
notes
|
|
114
|
+
notes,
|
|
100
115
|
totalTokens: 0,
|
|
101
116
|
createdAt: now,
|
|
102
117
|
updatedAt: now,
|
|
@@ -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;
|
|
@@ -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";
|