@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 events = record.events
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
- const event = buildEventFromMessage(currentMessages[i], events.length + 1, options.recordedAt, "live", null, null, currentIngressTimes[i]);
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
  }
@@ -315,11 +315,43 @@ function postTurnPersist(sessPath, prepared, usage, state) {
315
315
  return envelope.events;
316
316
  }
317
317
  /**
318
- * Deferred persist: same as postTurnPersist but runs on the next event loop tick.
319
- * Returns a promise that resolves when the persist completes.
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: this.params.displayName !== "Unknown" ? { name: { value: this.params.displayName, savedAt: now } } : {},
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.484",
3
+ "version": "0.1.0-alpha.485",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",