@securecoms/agent-bridge 0.8.0 → 0.10.0

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/dist/index.js CHANGED
@@ -1284,6 +1284,18 @@ var LeafRosterMismatchError = class extends Error {
1284
1284
  this.name = "LeafRosterMismatchError";
1285
1285
  }
1286
1286
  };
1287
+ var AmbiguousOwnLeafError = class extends Error {
1288
+ deviceId;
1289
+ totalLeaves;
1290
+ candidateLeaves;
1291
+ constructor(deviceId, totalLeaves, candidateLeaves) {
1292
+ super(`removeDeviceFromLiveGroup: cannot identify device ${deviceId}'s leaf \u2014 the identity owns ${totalLeaves} leaves and ${candidateLeaves} remain after excluding this committer's own. Leaf credentials carry the identity, not the device, so the target is genuinely ambiguous. Refusing rather than evicting a device that was never revoked.`);
1293
+ this.deviceId = deviceId;
1294
+ this.totalLeaves = totalLeaves;
1295
+ this.candidateLeaves = candidateLeaves;
1296
+ this.name = "AmbiguousOwnLeafError";
1297
+ }
1298
+ };
1287
1299
  var HealAddAfterRemoveError = class extends Error {
1288
1300
  constructor(cause) {
1289
1301
  super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
@@ -1303,7 +1315,7 @@ var ForgedCredentialError = class extends Error {
1303
1315
  this.name = "ForgedCredentialError";
1304
1316
  }
1305
1317
  };
1306
- var MlsGroupKeyProvider = class {
1318
+ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
1307
1319
  o;
1308
1320
  suite;
1309
1321
  managers = /* @__PURE__ */ new Map();
@@ -1453,9 +1465,25 @@ var MlsGroupKeyProvider = class {
1453
1465
  * Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
1454
1466
  * Surfaces a wedged channel that would otherwise "look idle": the daemon serves
1455
1467
  * it on GET /v1/metrics and the web shell reads it via a selector. A channel is
1456
- * `stuck` once it has had `stuckThreshold` consecutive decrypt failures with no
1457
- * intervening successful decrypt. Returns a plain object keyed by channelId;
1458
- * absence of a key means zero errors observed for that channel.
1468
+ * `stuck` once it has had `stuckThreshold` GENUINELY-WEDGED decrypt failures
1469
+ * with no intervening successful decrypt. Returns a plain object keyed by
1470
+ * channelId; absence of a key means zero errors observed for that channel.
1471
+ *
1472
+ * `stuck` gates on `wedgedRun`, NOT `consecutiveErrors` — the same correction
1473
+ * the quarantine gate already got (P0a). `consecutiveErrors` is bumped at the
1474
+ * top of the catch for EVERY failure, INCLUDING the benign past-epoch
1475
+ * duplicate-commit churn that the `permanentlyDead` path immediately and
1476
+ * safely auto-drains. Deriving the user-visible health signal from it meant
1477
+ * ordinary at-least-once / committer-race traffic painted a perfectly healthy
1478
+ * channel red.
1479
+ *
1480
+ * That is the prod symptom of 2026-07-31: a freshly-welcomed federated partner
1481
+ * was queued the very Add commit its Welcome already contained. It joined the
1482
+ * correct group at the correct epoch, correctly identified the replayed commit
1483
+ * as dead and drained it — and still showed "This channel is stuck", because
1484
+ * the drain happened AFTER the error had been counted. `wedgedRun` counts only
1485
+ * rows left UN-acked (actually stuck), and a clean decrypt clears it, so a
1486
+ * genuine wedge still reports exactly as before.
1459
1487
  */
1460
1488
  errorStats() {
1461
1489
  const out = {};
@@ -1464,7 +1492,7 @@ var MlsGroupKeyProvider = class {
1464
1492
  decryptErrors: e.decryptErrors,
1465
1493
  consecutiveErrors: e.consecutiveErrors,
1466
1494
  lastErrorAt: e.lastErrorAt,
1467
- stuck: e.consecutiveErrors >= this.stuckThreshold,
1495
+ stuck: e.wedgedRun >= this.stuckThreshold,
1468
1496
  quarantined: e.quarantined
1469
1497
  };
1470
1498
  }
@@ -2129,6 +2157,25 @@ var MlsGroupKeyProvider = class {
2129
2157
  * `removed_device_id` (instead of removed_identity_id); the server resolves the
2130
2158
  * device's identity, asserts it matches, and purges that ONE device.
2131
2159
  */
2160
+ /**
2161
+ * This committer's OWN leaf index in the given group's ratchet tree, or null
2162
+ * when it cannot be read.
2163
+ *
2164
+ * `MLSGroupManager.state` is marked `@internal` but is a declared (non-private)
2165
+ * field, and `state.privatePath.leafIndex` is the same value ts-mls itself uses
2166
+ * as the sender leaf when protecting application data. Reading it here avoids a
2167
+ * change to `@agentvault/crypto`; if a public accessor is added later this
2168
+ * should delegate to it.
2169
+ *
2170
+ * Returns null rather than throwing so every caller must decide explicitly what
2171
+ * an unknown own-leaf means. The one caller today FAILS CLOSED on null by
2172
+ * falling back to the positional path only for FOREIGN identities — it never
2173
+ * uses null to justify guessing among an own identity's leaves.
2174
+ */
2175
+ ownLeafIndex(mgr) {
2176
+ const idx = mgr.state?.privatePath?.leafIndex;
2177
+ return typeof idx === "number" ? idx : null;
2178
+ }
2132
2179
  async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
2133
2180
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
2134
2181
  }
@@ -2141,7 +2188,17 @@ var MlsGroupKeyProvider = class {
2141
2188
  if (leaves.length !== opts.orderedDeviceIds.length) {
2142
2189
  throw new LeafRosterMismatchError();
2143
2190
  }
2144
- const leafIndex = leaves[pos];
2191
+ let leafIndex;
2192
+ const ownLeaf = this.ownLeafIndex(mgr);
2193
+ if (ownLeaf !== null && leaves.includes(ownLeaf)) {
2194
+ const others = leaves.filter((i) => i !== ownLeaf);
2195
+ if (others.length !== 1) {
2196
+ throw new AmbiguousOwnLeafError(deviceId, leaves.length, others.length);
2197
+ }
2198
+ leafIndex = others[0];
2199
+ } else {
2200
+ leafIndex = leaves[pos];
2201
+ }
2145
2202
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
2146
2203
  const priorEpoch = Number(mgr.epoch);
2147
2204
  const { commit } = await mgr.removeMembers([leafIndex]);
@@ -2394,7 +2451,7 @@ var MlsGroupKeyProvider = class {
2394
2451
  });
2395
2452
  await this.o.store.save(channelId, mgr.exportState());
2396
2453
  this.managers.set(channelId, mgr);
2397
- await this.o.store.deletePendingBundle(this.o.deviceId);
2454
+ return { staleMemberDeviceIds: reinit.stale_member_device_ids ?? [] };
2398
2455
  });
2399
2456
  }
2400
2457
  /** Returns true when an in-memory MLS manager is live for the channel.
@@ -2470,7 +2527,60 @@ var MlsGroupKeyProvider = class {
2470
2527
  * ⇒ refuse, never call verify), then structural signer check, then attestation
2471
2528
  * verify against the NON-EMPTY pinned key, then post-join group-id assertion.
2472
2529
  */
2530
+ /** C4 (split-brain Task 4): back-off for REFUSED supersedes, keyed
2531
+ * `${channelId}:${groupId}`. A refused supersede leaves its row un-acked by
2532
+ * design (it is not a stray), but pre-back-off that meant a full re-verify —
2533
+ * including a fetchResetAttestation HTTP round-trip — on EVERY receive poll,
2534
+ * forever, for a request that can never succeed (the nine production split
2535
+ * channels all carry NULL-attestation legacy groups).
2536
+ *
2537
+ * ⚠️ ONLY POST-FETCH refusals enter the back-off. The step-0 pin guard and
2538
+ * the retired-group check run BEFORE fetchResetAttestation, so they
2539
+ * contribute zero HTTP to the storm — and the pin guard's precondition is
2540
+ * TRANSIENT (cold owner cache / momentarily anomalous roster; the bridge
2541
+ * warms the cache before receiveOnce for exactly this reason). Backing a
2542
+ * pre-fetch refusal off buys nothing and converts a one-cycle stall into a
2543
+ * ten-minute outage on a valid recovery.
2544
+ *
2545
+ * Of the backed-off refusals: no-attestation, unparseable content,
2546
+ * channel/group mismatch and post-join mismatch are DETERMINISTIC per
2547
+ * (channel, group) — the attestation columns are written at group creation
2548
+ * and never change. Signer-mismatch, signature-verify and no-bundle are
2549
+ * ASSUMED stable for the window; they are pin- or bundle-dependent, but by
2550
+ * the time they run the fetch has already been paid, so suppressing the
2551
+ * window bounds the storm at the cost of a ≤10-minute delay if the pin or
2552
+ * bundle changes. Note the suppression means the reason is not RE-OBSERVED
2553
+ * during the window — the underlying precondition can change freely; the
2554
+ * window is a cost bound, not a claim of immutability.
2555
+ *
2556
+ * The reason lives in the VALUE for observability; the clearing rule lives
2557
+ * in the KEY: the owner minting a FIXED replacement group arrives with a
2558
+ * NEW group id — a different key, tried immediately. The un-openable path
2559
+ * (verified Welcome, KP mismatch) is EXEMPT: onSupersedeUnopenable's own
2560
+ * cooldown governs it. In-memory only: a reload/restart costs one extra
2561
+ * fetch per stranded row — reload-bounded, not poll-bounded. */
2562
+ supersedeRefusals = /* @__PURE__ */ new Map();
2563
+ static SUPERSEDE_REFUSAL_BACKOFF_MS = 10 * 6e4;
2564
+ /** True while (channelId, groupId) is inside its refusal back-off window.
2565
+ * @internal exposed for diagnostics only — not part of the stable surface. */
2566
+ supersedeBackoffActive(channelId, groupId) {
2567
+ const rec = this.supersedeRefusals.get(`${channelId}:${groupId}`);
2568
+ return rec !== void 0 && Date.now() < rec.until;
2569
+ }
2473
2570
  async trySupersede(channelId, row, heldGroupId) {
2571
+ const refuse = (msg) => {
2572
+ this.recordDecryptError(row, new Error(msg));
2573
+ const now = Date.now();
2574
+ for (const [k, v] of this.supersedeRefusals) {
2575
+ if (now >= v.until)
2576
+ this.supersedeRefusals.delete(k);
2577
+ }
2578
+ this.supersedeRefusals.set(`${channelId}:${row.group_id}`, {
2579
+ reason: msg,
2580
+ until: now + _MlsGroupKeyProvider.SUPERSEDE_REFUSAL_BACKOFF_MS
2581
+ });
2582
+ return false;
2583
+ };
2474
2584
  const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
2475
2585
  if (!owner || !owner.publicKeyHex) {
2476
2586
  this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
@@ -2483,33 +2593,27 @@ var MlsGroupKeyProvider = class {
2483
2593
  }
2484
2594
  const att = await this.o.backend.fetchResetAttestation(row.group_id);
2485
2595
  if (!att) {
2486
- this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
2487
- return false;
2596
+ return refuse(`supersede refused: no attestation for group ${row.group_id}`);
2488
2597
  }
2489
2598
  if (att.signerIdentityId !== owner.identityId) {
2490
- this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
2491
- return false;
2599
+ return refuse("supersede refused: signer is not the pinned owner");
2492
2600
  }
2493
2601
  let content;
2494
2602
  try {
2495
2603
  content = JSON.parse(att.signedContent);
2496
2604
  } catch {
2497
- this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
2498
- return false;
2605
+ return refuse("supersede refused: unparseable signed_content");
2499
2606
  }
2500
2607
  if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
2501
- this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
2502
- return false;
2608
+ return refuse("supersede refused: attestation channel/group mismatch");
2503
2609
  }
2504
2610
  const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
2505
2611
  if (!ok) {
2506
- this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
2507
- return false;
2612
+ return refuse("supersede refused: signature does not verify against pinned owner key");
2508
2613
  }
2509
2614
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2510
2615
  if (!bundle) {
2511
- this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
2512
- return false;
2616
+ return refuse("supersede refused: no pending KeyPackage bundle to open the new Welcome");
2513
2617
  }
2514
2618
  return await this.locks.run(channelId, async () => {
2515
2619
  const mgr = new MLSGroupManager();
@@ -2534,14 +2638,14 @@ var MlsGroupKeyProvider = class {
2534
2638
  return false;
2535
2639
  }
2536
2640
  if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
2537
- this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
2538
- return false;
2641
+ return refuse("supersede refused: post-join group id != attested new_group_id");
2539
2642
  }
2540
2643
  this.evictChannelState(channelId);
2541
2644
  await this.o.store.delete(channelId);
2542
2645
  await this.o.store.discardStaging(channelId);
2543
2646
  await this.o.store.addRetiredGroupId(channelId, heldGroupId);
2544
2647
  this.errors.delete(channelId);
2648
+ this.supersedeRefusals.delete(`${channelId}:${row.group_id}`);
2545
2649
  this.managers.set(channelId, mgr);
2546
2650
  await this.o.store.save(channelId, mgr.exportState());
2547
2651
  await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
@@ -2578,9 +2682,15 @@ var MlsGroupKeyProvider = class {
2578
2682
  if (heldGroupId !== null && heldGroupId === row.group_id) {
2579
2683
  handshakeAcked.push(row.queue_id);
2580
2684
  } else if (heldGroupId !== null && heldGroupId !== row.group_id) {
2581
- const ok = await this.trySupersede(wcid, row, heldGroupId);
2582
- if (ok)
2583
- supersededChannelIds.push(wcid);
2685
+ if (this.supersedeBackoffActive(wcid, row.group_id)) {
2686
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2687
+ console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} g=${row.group_id.slice(0, 8)} -> SKIP(refusal back-off)`);
2688
+ }
2689
+ } else {
2690
+ const ok = await this.trySupersede(wcid, row, heldGroupId);
2691
+ if (ok)
2692
+ supersededChannelIds.push(wcid);
2693
+ }
2584
2694
  }
2585
2695
  } else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2586
2696
  console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} -> NO-LOCAL-STATE (leave un-acked for join path)`);
@@ -3283,19 +3393,8 @@ var RestMlsBackend = class {
3283
3393
  created: j.created === void 0 ? true : Boolean(j.created)
3284
3394
  };
3285
3395
  }
3286
- async reinitConnectChannelGroup(channelId, memberDeviceIds) {
3287
- const committer = memberDeviceIds[0];
3288
- if (!committer)
3289
- throw new Error("reinitConnectChannelGroup: empty member set");
3290
- const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
3291
- member_device_ids: memberDeviceIds,
3292
- committer_device_id: committer
3293
- });
3294
- return {
3295
- group_id: String(j.group_id),
3296
- current_epoch: Number(j.current_epoch),
3297
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
3298
- };
3396
+ async reinitConnectChannelGroup() {
3397
+ throw new Error("reinitConnectChannelGroup was removed (it minted unattested MLS groups that orphaned channel members permanently). Use resetReinitConnectChannelGroup with an owner attestation.");
3299
3398
  }
3300
3399
  async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
3301
3400
  const committer = memberDeviceIds[0];
@@ -3313,7 +3412,11 @@ var RestMlsBackend = class {
3313
3412
  return {
3314
3413
  group_id: String(j.group_id),
3315
3414
  current_epoch: Number(j.current_epoch),
3316
- member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
3415
+ member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
3416
+ // Absent on a backend that predates the dropped-member report; an empty
3417
+ // list is the correct read of "the server told us nothing", since the old
3418
+ // server also never dropped anyone for an expired KeyPackage.
3419
+ stale_member_device_ids: Array.isArray(j.stale_member_device_ids) ? j.stale_member_device_ids.map(String) : []
3317
3420
  };
3318
3421
  }
3319
3422
  async fetchKeyPackages(deviceIds) {
@@ -3811,7 +3914,7 @@ function loadPackageMeta(importMetaUrl) {
3811
3914
 
3812
3915
  // src/bridge-agent.ts
3813
3916
  import { promises as fsp } from "node:fs";
3814
- import { join as join5, dirname } from "node:path";
3917
+ import { join as join6, dirname } from "node:path";
3815
3918
 
3816
3919
  // src/latency.ts
3817
3920
  var DEFAULT_CAPACITY = 1024;
@@ -4324,29 +4427,153 @@ function readRecentTurns(baseDir, agent, sessionId, k) {
4324
4427
  return k > 0 ? turns.slice(-k) : [];
4325
4428
  }
4326
4429
 
4430
+ // src/memory/openclaw-workspace-facts.ts
4431
+ import { readdirSync, readFileSync as readFileSync4, statSync } from "node:fs";
4432
+ import { join as join5 } from "node:path";
4433
+ var DEFAULT_FACTS_BUDGET = 8e3;
4434
+ var MEMORY_MD_CAP = 6e3;
4435
+ var MIN_FRAGMENT = 200;
4436
+ var MAX_SOURCE_BYTES = 1024 * 1024;
4437
+ var DAILY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
4438
+ function resolveWorkspaceDir(baseDir, agent, override) {
4439
+ if (override) return expandHome(override);
4440
+ const base = expandHome(baseDir);
4441
+ const isMain = agent === "" || agent === "main";
4442
+ try {
4443
+ const cfg = JSON.parse(readFileSync4(join5(base, "openclaw.json"), "utf8"));
4444
+ const agents = cfg?.agents;
4445
+ if (agents && typeof agents === "object") {
4446
+ const list = Array.isArray(agents.list) ? agents.list : [];
4447
+ const wanted = isMain ? "main" : agent;
4448
+ const entry = list.find((e) => e && typeof e === "object" && e.id === wanted);
4449
+ const ws = entry?.workspace;
4450
+ if (typeof ws === "string" && ws) return expandHome(ws);
4451
+ const def = agents.defaults?.workspace;
4452
+ if (typeof def === "string" && def) return expandHome(def);
4453
+ }
4454
+ } catch {
4455
+ }
4456
+ return isMain ? join5(base, "workspace") : join5(base, `workspace-${agent}`);
4457
+ }
4458
+ var TRUNC = "\u2026[truncated]";
4459
+ function headSlice(s, n) {
4460
+ if (s.length <= n) return { text: s, clipped: false };
4461
+ let out = s.slice(0, n);
4462
+ const last = out.charCodeAt(out.length - 1);
4463
+ if (last >= 55296 && last <= 56319) out = out.slice(0, -1);
4464
+ return { text: out, clipped: true };
4465
+ }
4466
+ function tailSlice(s, n) {
4467
+ if (s.length <= n) return { text: s, clipped: false };
4468
+ let out = s.slice(s.length - n);
4469
+ const first = out.charCodeAt(0);
4470
+ if (first >= 56320 && first <= 57343) out = out.slice(1);
4471
+ return { text: out, clipped: true };
4472
+ }
4473
+ function readBounded(path) {
4474
+ if (statSync(path).size > MAX_SOURCE_BYTES) return { text: null, oversize: true };
4475
+ return { text: readFileSync4(path, "utf8"), oversize: false };
4476
+ }
4477
+ function readWorkspaceFacts(baseDir, agent, opts) {
4478
+ const raw = opts?.charBudget;
4479
+ const budget = typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : DEFAULT_FACTS_BUDGET;
4480
+ if (budget === 0) return { facts: [], truncated: false, missing: false };
4481
+ const dir = resolveWorkspaceDir(baseDir, agent, opts?.workspaceDir);
4482
+ const facts = [];
4483
+ let truncated = false;
4484
+ let remaining = budget;
4485
+ try {
4486
+ const r = readBounded(join5(dir, "MEMORY.md"));
4487
+ if (r.oversize) truncated = true;
4488
+ else if (r.text && r.text.trim()) {
4489
+ const { text, clipped } = headSlice(r.text, Math.min(MEMORY_MD_CAP, budget));
4490
+ truncated ||= clipped;
4491
+ facts.push(`workspace MEMORY.md:
4492
+ ${text}${clipped ? `
4493
+ ${TRUNC}` : ""}`);
4494
+ remaining -= text.length;
4495
+ }
4496
+ } catch {
4497
+ }
4498
+ try {
4499
+ const dailies = readdirSync(join5(dir, "memory")).filter((f) => DAILY_RE.test(f)).sort().reverse().slice(0, 2);
4500
+ for (const f of dailies) {
4501
+ if (remaining < MIN_FRAGMENT) {
4502
+ truncated = true;
4503
+ break;
4504
+ }
4505
+ let r;
4506
+ try {
4507
+ r = readBounded(join5(dir, "memory", f));
4508
+ } catch {
4509
+ continue;
4510
+ }
4511
+ if (r.oversize) {
4512
+ truncated = true;
4513
+ continue;
4514
+ }
4515
+ if (!r.text || !r.text.trim()) continue;
4516
+ const { text, clipped } = tailSlice(r.text, remaining);
4517
+ truncated ||= clipped;
4518
+ facts.push(`workspace memory/${f} (daily):
4519
+ ${clipped ? `${TRUNC}
4520
+ ` : ""}${text}`);
4521
+ remaining -= text.length;
4522
+ }
4523
+ } catch {
4524
+ }
4525
+ return { facts, truncated, missing: facts.length === 0 };
4526
+ }
4527
+
4327
4528
  // src/memory/openclaw-provider.ts
4328
4529
  var DEFAULT_K2 = 200;
4329
4530
  var OpenClawMemoryProvider = class {
4330
- constructor(store, baseDir, agent) {
4531
+ // gauge: last successful returned total, including prefixes/markers
4532
+ constructor(store, baseDir, agent, factsOpts) {
4331
4533
  this.store = store;
4332
4534
  this.baseDir = baseDir;
4333
4535
  this.agent = agent;
4536
+ this.factsOpts = factsOpts;
4334
4537
  }
4335
4538
  noSession = 0;
4336
4539
  readFailed = 0;
4540
+ factsMissing = 0;
4541
+ // absence-shaped: resolved path missing or zero sources (per-call)
4542
+ factsReadFailed = 0;
4543
+ // anything THROWN out of the facts computation
4544
+ factsTruncated = 0;
4545
+ // budget clipped/dropped a source (incl. the 1MB skip)
4546
+ factsChars = 0;
4337
4547
  async record(input) {
4338
4548
  if (input.modality !== "voice") return { seq: 0, deduped: true };
4339
4549
  return this.store.recordMemory({ ...input, text: `[voice] ${input.text}` });
4340
4550
  }
4341
4551
  async context(conversationId, limit) {
4342
4552
  const k = limit ?? DEFAULT_K2;
4553
+ let wsFacts = [];
4554
+ try {
4555
+ const r = readWorkspaceFacts(this.baseDir, this.agent, {
4556
+ workspaceDir: this.factsOpts?.workspaceDir,
4557
+ charBudget: this.factsOpts?.factsCharBudget
4558
+ });
4559
+ wsFacts = r.facts;
4560
+ if (r.missing) this.factsMissing++;
4561
+ if (r.truncated) this.factsTruncated++;
4562
+ this.factsChars = r.facts.reduce((n, f) => n + f.length, 0);
4563
+ } catch {
4564
+ this.factsReadFailed++;
4565
+ }
4566
+ const withFacts = (res) => ({
4567
+ recent: res.recent,
4568
+ facts: [...wsFacts, ...res.facts]
4569
+ });
4343
4570
  const voice = await this.store.memoryContext(conversationId, k);
4344
4571
  let textTurns = [];
4345
4572
  try {
4346
4573
  const sid = resolveOwnerDmSession(this.baseDir, this.agent);
4347
4574
  if (!sid) {
4348
4575
  this.noSession++;
4349
- return voice;
4576
+ return withFacts(voice);
4350
4577
  }
4351
4578
  textTurns = readRecentTurns(this.baseDir, this.agent, sid, k).map((t) => ({
4352
4579
  seq: 0,
@@ -4356,7 +4583,7 @@ var OpenClawMemoryProvider = class {
4356
4583
  }));
4357
4584
  } catch {
4358
4585
  this.readFailed++;
4359
- return voice;
4586
+ return withFacts(voice);
4360
4587
  }
4361
4588
  const tagged = [
4362
4589
  ...textTurns.map((t, i) => ({ ...t, _src: 0, _i: i })),
@@ -4375,10 +4602,17 @@ var OpenClawMemoryProvider = class {
4375
4602
  text: t.text,
4376
4603
  ts: t.ts
4377
4604
  }));
4378
- return { recent, facts: voice.facts };
4605
+ return withFacts({ recent, facts: voice.facts });
4379
4606
  }
4380
4607
  stats() {
4381
- return { openclaw_no_session: this.noSession, openclaw_read_failed: this.readFailed };
4608
+ return {
4609
+ openclaw_no_session: this.noSession,
4610
+ openclaw_read_failed: this.readFailed,
4611
+ openclaw_facts_missing: this.factsMissing,
4612
+ openclaw_facts_read_failed: this.factsReadFailed,
4613
+ openclaw_facts_truncated: this.factsTruncated,
4614
+ openclaw_facts_chars: this.factsChars
4615
+ };
4382
4616
  }
4383
4617
  };
4384
4618
 
@@ -4402,10 +4636,10 @@ var NullMemory = class {
4402
4636
  return { recent: [], facts: [] };
4403
4637
  }
4404
4638
  };
4405
- function createMemoryProvider(provider, store, baseDir, agent) {
4639
+ function createMemoryProvider(provider, store, baseDir, agent, openclawFacts) {
4406
4640
  if (provider === "none") return new NullMemory();
4407
4641
  if (provider === "hermes") return new HermesMemoryProvider(baseDir ?? "~/.hermes");
4408
- if (provider === "openclaw") return new OpenClawMemoryProvider(store, baseDir ?? "~/.openclaw", agent ?? "");
4642
+ if (provider === "openclaw") return new OpenClawMemoryProvider(store, baseDir ?? "~/.openclaw", agent ?? "", openclawFacts);
4409
4643
  return new SecureComsLocalMemory(store);
4410
4644
  }
4411
4645
 
@@ -4513,7 +4747,10 @@ var BridgeAgent = class _BridgeAgent {
4513
4747
  identityId: cfg.identityId,
4514
4748
  cipherFor: () => new KeyedInboxCipher(deriveMlsStoreKey(seed))
4515
4749
  });
4516
- this.memory = createMemoryProvider(this.cfg.memoryProvider, this.store, this.cfg.memoryBaseDir, this.cfg.memoryAgent);
4750
+ this.memory = createMemoryProvider(this.cfg.memoryProvider, this.store, this.cfg.memoryBaseDir, this.cfg.memoryAgent, {
4751
+ workspaceDir: this.cfg.memoryWorkspaceDir,
4752
+ factsCharBudget: this.cfg.memoryFactsCharBudget
4753
+ });
4517
4754
  this.provider = new MlsGroupKeyProvider({
4518
4755
  store: this.store,
4519
4756
  backend: this.backend,
@@ -4593,7 +4830,7 @@ var BridgeAgent = class _BridgeAgent {
4593
4830
  const j = await res.json();
4594
4831
  return String(j.attachment_id);
4595
4832
  };
4596
- this.attachmentsDirImpl = deps.attachmentsDir ?? join5(dirname(cfg.storePath), "attachments");
4833
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join6(dirname(cfg.storePath), "attachments");
4597
4834
  this.action = {
4598
4835
  begin: (channelId) => this.actionBegin(channelId),
4599
4836
  start: (opts) => this.actionStart(opts),
@@ -5077,11 +5314,33 @@ var BridgeAgent = class _BridgeAgent {
5077
5314
  return;
5078
5315
  }
5079
5316
  this.providerCapabilityMissing = false;
5080
- let chans;
5081
- try {
5082
- chans = await this.listChannels();
5083
- } catch (e) {
5084
- console.warn("[bridge] selfHealSweep aborted: listChannels failed:", e);
5317
+ const enclaves = await this.enclavesToCover();
5318
+ this.enclaveListEverSucceeded.add(this.cfg.enclaveId);
5319
+ const seen = /* @__PURE__ */ new Set();
5320
+ const chans = [];
5321
+ const failed = [];
5322
+ let listedAny = false;
5323
+ for (const encId of enclaves) {
5324
+ try {
5325
+ for (const c of await this.listChannels(encId)) {
5326
+ if (seen.has(c.channel_id)) continue;
5327
+ seen.add(c.channel_id);
5328
+ chans.push(c);
5329
+ }
5330
+ listedAny = true;
5331
+ this.enclaveListEverSucceeded.add(encId);
5332
+ this.enclaveListWarned.delete(encId);
5333
+ } catch (e) {
5334
+ failed.push(encId);
5335
+ if (!this.enclaveListWarned.has(encId)) {
5336
+ this.enclaveListWarned.add(encId);
5337
+ console.warn(`[bridge] selfHealSweep: listChannels failed for enclave ${encId}; skipping (warn-once per streak):`, e);
5338
+ }
5339
+ }
5340
+ }
5341
+ this.sweepEnclavesFailed = failed;
5342
+ if (!listedAny) {
5343
+ console.warn("[bridge] selfHealSweep aborted: every enclave listing failed");
5085
5344
  return;
5086
5345
  }
5087
5346
  for (const c of chans) {
@@ -5427,7 +5686,7 @@ var BridgeAgent = class _BridgeAgent {
5427
5686
  * the other enclaves in the set.
5428
5687
  */
5429
5688
  async refreshChannelMeta() {
5430
- const enclaves = /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.store.distinctEnclaveIds()]);
5689
+ const enclaves = await this.enclavesToCover();
5431
5690
  const now = Date.now();
5432
5691
  for (const encId of enclaves) {
5433
5692
  try {
@@ -5440,6 +5699,89 @@ var BridgeAgent = class _BridgeAgent {
5440
5699
  }
5441
5700
  }
5442
5701
  }
5702
+ /** Cache for the server-side enclave enumeration (see enclavesToCover). */
5703
+ enclaveEnumCache = null;
5704
+ /** Enclaves whose channel listing failed on the NEWEST sweep (audit HIGH:
5705
+ * skip-one deleted the sweep's partial-coverage signal — observations_stale
5706
+ * is a global max, so one healthy enclave masked a failed one). Surfaced on
5707
+ * health() so partial coverage is never silent. */
5708
+ sweepEnclavesFailed = [];
5709
+ /** Enclave ids whose channel listing has EVER succeeded this process, seeded
5710
+ * with HOME (home is always expected to list; without the seed, a home
5711
+ * failure right after a restart would read as "never succeeded" and slip
5712
+ * through the expected-gap branch — weaker than the old abort-on-failure).
5713
+ * A failure of a member of this set is a REGRESSION and degrades health; a
5714
+ * failure of a never-succeeded enclave (partner: tenant-scoped 404, permanent
5715
+ * pending backend authz) is an EXPECTED gap — reported, not degrading, or
5716
+ * every federated agent would peg degraded forever. */
5717
+ enclaveListEverSucceeded = /* @__PURE__ */ new Set();
5718
+ /** Warn-once-per-failure-streak, keyed by enclave id (cleared on success):
5719
+ * a permanently-404ing partner enclave must not log 1440 lines a day. */
5720
+ enclaveListWarned = /* @__PURE__ */ new Set();
5721
+ static ENCLAVE_ENUM_TTL_MS = 5 * 6e4;
5722
+ enclaveEnumWarned = false;
5723
+ /**
5724
+ * C1 (split-brain Task 3): every enclave this agent must COVER — for the
5725
+ * self-heal sweep and for channel-meta refresh alike. ONE helper on purpose:
5726
+ * these two call sites had drifted (refreshChannelMeta looped home+learned,
5727
+ * selfHealSweep looped nothing), and the drift is exactly what left both
5728
+ * production agents with zero recovery coverage in their second enclave.
5729
+ *
5730
+ * The set is SERVER enumeration ∪ home ∪ store-learned:
5731
+ *
5732
+ * - SERVER (`GET /api/v1/enclaves`, membership-scoped, federated included)
5733
+ * is the authoritative source. The store CANNOT be: channel_meta's only
5734
+ * writers are listChannels itself (circular — you learn enclave B by
5735
+ * having already listed B) and the receive pump's successfully-decrypted
5736
+ * loop, and a FORKED agent decrypts nothing, so the agent that most needs
5737
+ * sweeping in enclave B is precisely the one whose store never learned B.
5738
+ * - home + learned stay in the union as the degraded-mode floor: if the
5739
+ * enumeration endpoint is down, we sweep what we can rather than nothing,
5740
+ * and we warn (once) so the gap is diagnosable.
5741
+ *
5742
+ * The server result is cached for ENCLAVE_ENUM_TTL_MS so per-ingest-cycle
5743
+ * callers (refreshChannelMeta) do not add an HTTP round-trip per cycle;
5744
+ * membership changes are picked up within one TTL, which matches the sweep
5745
+ * cadence this exists to serve.
5746
+ *
5747
+ * ⚠️ Residual gap, stated: the enumeration RETURNS cross-tenant partner
5748
+ * enclaves, but `list_channels_in_enclave` is tenant-scoped and 404s them
5749
+ * under this agent's bearer — so a partner enclave in this set still yields
5750
+ * no channels. Callers must treat a per-enclave listing failure as skip-one,
5751
+ * never abort-all. Closing that needs backend authz work, not daemon work.
5752
+ */
5753
+ async enclavesToCover() {
5754
+ const now = Date.now();
5755
+ if (!this.enclaveEnumCache || now - this.enclaveEnumCache.at > _BridgeAgent.ENCLAVE_ENUM_TTL_MS) {
5756
+ try {
5757
+ const url = `${this.cfg.apiBase}/api/v1/enclaves?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
5758
+ const res = await this.fetchImpl(url, {
5759
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
5760
+ });
5761
+ if (!res.ok) throw new Error(`list_enclaves failed: ${res.status}`);
5762
+ const rows = await res.json();
5763
+ this.enclaveEnumCache = { ids: rows.map((r) => r.enclave_id), at: Date.now() };
5764
+ this.enclaveEnumWarned = false;
5765
+ } catch (e) {
5766
+ if (!this.enclaveEnumWarned) {
5767
+ this.enclaveEnumWarned = true;
5768
+ console.warn(
5769
+ "[bridge] enclave enumeration failed; sweep coverage degraded to home+learned enclaves:",
5770
+ e
5771
+ );
5772
+ }
5773
+ }
5774
+ }
5775
+ const fresh = this.enclaveEnumCache && Date.now() - this.enclaveEnumCache.at <= _BridgeAgent.ENCLAVE_ENUM_TTL_MS;
5776
+ if (fresh) {
5777
+ return /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.enclaveEnumCache.ids]);
5778
+ }
5779
+ return /* @__PURE__ */ new Set([
5780
+ this.cfg.enclaveId,
5781
+ ...this.store.distinctEnclaveIds(),
5782
+ ...this.enclaveEnumCache?.ids ?? []
5783
+ ]);
5784
+ }
5443
5785
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
5444
5786
  freshRoster(channelId) {
5445
5787
  const hit = this.channelRoster.get(channelId);
@@ -6036,6 +6378,9 @@ var BridgeAgent = class _BridgeAgent {
6036
6378
  const stalePeriodMs = _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS;
6037
6379
  const observationsStale = this.providerCapabilityMissing || channels.length === 0 || Date.now() - newest > stalePeriodMs;
6038
6380
  const divergedChannels = channels.filter((o) => o.diverged === true).length;
6381
+ const sweepCoverageLost = this.sweepEnclavesFailed.some(
6382
+ (id) => this.enclaveListEverSucceeded.has(id)
6383
+ );
6039
6384
  const liveChannels = channels.map((o) => ({
6040
6385
  ...o,
6041
6386
  last_decrypt_at: this.provider.lastDecryptAtFor?.(o.channel_id) ?? o.last_decrypt_at
@@ -6050,8 +6395,9 @@ var BridgeAgent = class _BridgeAgent {
6050
6395
  observations_stale: observationsStale,
6051
6396
  diverged_channels: divergedChannels,
6052
6397
  // A2: the truthful roll-up gates read instead of `status` (D-3).
6053
- degraded: divergedChannels > 0 || observationsStale,
6398
+ degraded: divergedChannels > 0 || observationsStale || sweepCoverageLost,
6054
6399
  channels: liveChannels,
6400
+ sweep_enclaves_failed: [...this.sweepEnclavesFailed],
6055
6401
  identity_id: this.cfg.identityId,
6056
6402
  joined_channels: joined,
6057
6403
  buffered: this.buffer.length,
@@ -6120,13 +6466,13 @@ var BridgeAgent = class _BridgeAgent {
6120
6466
  };
6121
6467
 
6122
6468
  // src/config.ts
6123
- import { readFileSync as readFileSync4, statSync } from "node:fs";
6124
- import { dirname as dirname2, join as join6 } from "node:path";
6469
+ import { readFileSync as readFileSync5, statSync as statSync2 } from "node:fs";
6470
+ import { dirname as dirname2, join as join7 } from "node:path";
6125
6471
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
6126
6472
  function loadConfig(opts) {
6127
- const raw = readFileSync4(opts.credentialPath, "utf8");
6473
+ const raw = readFileSync5(opts.credentialPath, "utf8");
6128
6474
  try {
6129
- const mode = statSync(opts.credentialPath).mode & 511;
6475
+ const mode = statSync2(opts.credentialPath).mode & 511;
6130
6476
  if (mode & 63) process.stderr.write(`[bridge] WARNING: credential file ${opts.credentialPath} permission ${mode.toString(8)} is not 0600 \u2014 tighten it (chmod 600).
6131
6477
  `);
6132
6478
  } catch {
@@ -6138,7 +6484,7 @@ function loadConfig(opts) {
6138
6484
  throw new Error("credential file is not valid JSON");
6139
6485
  }
6140
6486
  for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
6141
- const storePath = opts.storePath ?? join6(dirname2(opts.credentialPath), "bridge-state.db");
6487
+ const storePath = opts.storePath ?? join7(dirname2(opts.credentialPath), "bridge-state.db");
6142
6488
  const agentName = typeof json2.agent_name === "string" && json2.agent_name ? json2.agent_name : typeof json2.display_name === "string" && json2.display_name ? json2.display_name : void 0;
6143
6489
  const rateLimitCapacity = typeof json2.rate_limit_capacity === "number" ? json2.rate_limit_capacity : 20;
6144
6490
  const rateLimitRefillPerMinute = typeof json2.rate_limit_refill_per_minute === "number" ? json2.rate_limit_refill_per_minute : 20;
@@ -6146,6 +6492,8 @@ function loadConfig(opts) {
6146
6492
  let memoryProvider;
6147
6493
  let memoryBaseDir;
6148
6494
  let memoryAgent;
6495
+ let memoryWorkspaceDir;
6496
+ let memoryFactsCharBudget;
6149
6497
  if (memProvRaw === "none") {
6150
6498
  memoryProvider = "none";
6151
6499
  } else if (memProvRaw === "hermes") {
@@ -6159,6 +6507,10 @@ function loadConfig(opts) {
6159
6507
  memoryBaseDir = typeof bd === "string" && bd ? bd : "~/.openclaw";
6160
6508
  const ag = oc?.agent;
6161
6509
  memoryAgent = typeof ag === "string" && ag ? ag : void 0;
6510
+ const wd = oc?.workspace_dir;
6511
+ memoryWorkspaceDir = typeof wd === "string" && wd ? wd : void 0;
6512
+ const fb = oc?.facts_char_budget;
6513
+ memoryFactsCharBudget = typeof fb === "number" && Number.isInteger(fb) && fb >= 0 ? fb : void 0;
6162
6514
  } else {
6163
6515
  memoryProvider = "securecoms";
6164
6516
  }
@@ -6175,7 +6527,9 @@ function loadConfig(opts) {
6175
6527
  rateLimitRefillPerMinute,
6176
6528
  memoryProvider,
6177
6529
  memoryBaseDir,
6178
- memoryAgent
6530
+ memoryAgent,
6531
+ memoryWorkspaceDir,
6532
+ memoryFactsCharBudget
6179
6533
  };
6180
6534
  }
6181
6535
  function arg(argv, flag) {
@@ -6219,11 +6573,11 @@ import { z } from "zod";
6219
6573
 
6220
6574
  // src/auth.ts
6221
6575
  import { randomBytes as randomBytes3, timingSafeEqual } from "node:crypto";
6222
- import { existsSync, readFileSync as readFileSync5, writeFileSync } from "node:fs";
6576
+ import { existsSync, readFileSync as readFileSync6, writeFileSync } from "node:fs";
6223
6577
  function resolveToken(opts) {
6224
6578
  if (opts.token) return opts.token;
6225
6579
  const f = opts.tokenFile;
6226
- if (f && existsSync(f)) return readFileSync5(f, "utf8").trim();
6580
+ if (f && existsSync(f)) return readFileSync6(f, "utf8").trim();
6227
6581
  const tok = randomBytes3(32).toString("base64url");
6228
6582
  if (f) writeFileSync(f, tok, { mode: 384 });
6229
6583
  return tok;