@securecoms/agent-bridge 0.20.0 → 0.21.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
@@ -5172,8 +5172,11 @@ var RestMlsBackend = class {
5172
5172
  queue_ids: queueIds
5173
5173
  });
5174
5174
  }
5175
- async nackDelivery(_deviceId, queueIds) {
5176
- await this.post("/api/v1/mls/delivery/nack", { queue_ids: queueIds });
5175
+ async nackDelivery(deviceId, queueIds) {
5176
+ await this.post("/api/v1/mls/delivery/nack", {
5177
+ device_id: deviceId,
5178
+ queue_ids: queueIds
5179
+ });
5177
5180
  }
5178
5181
  async listGroupMessages(deviceId, groupId, opts) {
5179
5182
  const params = new URLSearchParams({ device_id: deviceId, limit: String(opts?.limit ?? 100) });
@@ -5681,7 +5684,7 @@ import { DatabaseSync } from "node:sqlite";
5681
5684
  import { MLSGroupManager as MLSGroupManager3, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
5682
5685
 
5683
5686
  // ../connect-mls-store/dist/schema.js
5684
- var SCHEMA_VERSION = 6;
5687
+ var SCHEMA_VERSION = 7;
5685
5688
  function openSchema(db) {
5686
5689
  db.exec("PRAGMA journal_mode=WAL");
5687
5690
  const row = db.prepare("PRAGMA user_version").get();
@@ -5773,6 +5776,17 @@ function openSchema(db) {
5773
5776
  );
5774
5777
  `);
5775
5778
  }
5779
+ if (row.user_version < 7) {
5780
+ db.exec(`
5781
+ CREATE TABLE IF NOT EXISTS agent_memory_fact_assignment (
5782
+ identity_id TEXT NOT NULL,
5783
+ assignment_key TEXT NOT NULL,
5784
+ conversation_id TEXT NOT NULL,
5785
+ applied_at INTEGER NOT NULL,
5786
+ PRIMARY KEY (identity_id, assignment_key)
5787
+ );
5788
+ `);
5789
+ }
5776
5790
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
5777
5791
  }
5778
5792
 
@@ -5812,6 +5826,10 @@ function decodeBundle(bytes) {
5812
5826
  }
5813
5827
  };
5814
5828
  }
5829
+ var DAY_MS = 24 * 60 * 60 * 1e3;
5830
+ var DEFAULT_MEMORY_RETENTION_MS = 30 * DAY_MS;
5831
+ var DEFAULT_MEMORY_FACT_RETENTION_K = 200;
5832
+ var DEFAULT_MEMORY_FACT_RETENTION_MS = 90 * DAY_MS;
5815
5833
  var SqliteMlsStateStore = class {
5816
5834
  db;
5817
5835
  id;
@@ -5825,6 +5843,10 @@ var SqliteMlsStateStore = class {
5825
5843
  bundles = /* @__PURE__ */ new Map();
5826
5844
  lastKpIds = /* @__PURE__ */ new Map();
5827
5845
  memoryRetentionK;
5846
+ memoryRetentionMs;
5847
+ memoryFactRetentionK;
5848
+ memoryFactRetentionMs;
5849
+ memoryNow;
5828
5850
  // M0(b)/F4-1 + C1 + H2: in-memory ONLY for now — the daemon side of FileSafe
5829
5851
  // (this store) isn't wired into the multi-member vault yet (Task 5's capture
5830
5852
  // sites are all connect-web/browser owner paths). Kept here purely so
@@ -5839,6 +5861,10 @@ var SqliteMlsStateStore = class {
5839
5861
  this.id = opts.identityId;
5840
5862
  this.cipher = opts.cipherFor(opts.identityId);
5841
5863
  this.memoryRetentionK = opts.memoryRetentionK ?? 500;
5864
+ this.memoryRetentionMs = opts.memoryRetentionMs ?? DEFAULT_MEMORY_RETENTION_MS;
5865
+ this.memoryFactRetentionK = opts.memoryFactRetentionK ?? DEFAULT_MEMORY_FACT_RETENTION_K;
5866
+ this.memoryFactRetentionMs = opts.memoryFactRetentionMs ?? DEFAULT_MEMORY_FACT_RETENTION_MS;
5867
+ this.memoryNow = opts.memoryNow ?? Date.now;
5842
5868
  try {
5843
5869
  this.db = new DatabaseSync(opts.dbPath);
5844
5870
  } catch (e) {
@@ -6056,23 +6082,24 @@ var SqliteMlsStateStore = class {
6056
6082
  const dedupKey = input.messageId ?? input.idempotencyKey ?? null;
6057
6083
  if (!dedupKey)
6058
6084
  throw new Error("recordMemory requires message_id or idempotency_key");
6085
+ const now = this.memoryNow();
6086
+ this.pruneMemory(input.conversationId, now);
6059
6087
  const sealed = await this.cipher.seal(new TextEncoder().encode(input.text));
6060
6088
  const info = this.db.prepare(`INSERT OR IGNORE INTO agent_memory
6061
6089
  (identity_id, conversation_id, seq, role, peer_id, sealed_text, modality, message_id, idempotency_key, dedup_key, recorded_at)
6062
6090
  VALUES (?, ?,
6063
6091
  (SELECT COALESCE(MAX(seq),0)+1 FROM agent_memory WHERE identity_id=? AND conversation_id=?),
6064
- ?, ?, ?, ?, ?, ?, ?, ?)`).run(this.id, input.conversationId, this.id, input.conversationId, input.role, input.peerId ?? null, sealed, input.modality, input.messageId ?? null, input.idempotencyKey ?? null, dedupKey, Date.now());
6092
+ ?, ?, ?, ?, ?, ?, ?, ?)`).run(this.id, input.conversationId, this.id, input.conversationId, input.role, input.peerId ?? null, sealed, input.modality, input.messageId ?? null, input.idempotencyKey ?? null, dedupKey, now);
6065
6093
  if (info.changes === 0) {
6066
6094
  const row2 = this.db.prepare("SELECT seq FROM agent_memory WHERE identity_id=? AND conversation_id=? AND dedup_key=?").get(this.id, input.conversationId, dedupKey);
6067
6095
  return { seq: row2.seq, deduped: true };
6068
6096
  }
6069
6097
  const row = this.db.prepare("SELECT MAX(seq) AS seq FROM agent_memory WHERE identity_id=? AND conversation_id=?").get(this.id, input.conversationId);
6070
- if (this.memoryRetentionK > 0) {
6071
- this.db.prepare("DELETE FROM agent_memory WHERE identity_id=? AND conversation_id=? AND seq <= ?").run(this.id, input.conversationId, row.seq - this.memoryRetentionK);
6072
- }
6098
+ this.pruneMemoryTurns(input.conversationId, now);
6073
6099
  return { seq: row.seq, deduped: false };
6074
6100
  }
6075
6101
  async memoryContext(conversationId, limit = 50) {
6102
+ this.pruneMemory(conversationId, this.memoryNow());
6076
6103
  const rows = this.db.prepare(`SELECT seq, role, peer_id, sealed_text, recorded_at FROM agent_memory
6077
6104
  WHERE identity_id=? AND conversation_id=? ORDER BY seq DESC LIMIT ?`).all(this.id, conversationId, limit);
6078
6105
  const recent = [];
@@ -6087,9 +6114,94 @@ var SqliteMlsStateStore = class {
6087
6114
  return { recent, facts };
6088
6115
  }
6089
6116
  async recordMemoryFact(conversationId, fact) {
6117
+ const now = this.memoryNow();
6090
6118
  const sealed = await this.cipher.seal(new TextEncoder().encode(fact));
6091
6119
  this.db.prepare(`INSERT INTO agent_memory_fact (identity_id, conversation_id, fact_id, sealed_fact, added_at)
6092
- VALUES (?, ?, (SELECT COALESCE(MAX(fact_id),0)+1 FROM agent_memory_fact WHERE identity_id=? AND conversation_id=?), ?, ?)`).run(this.id, conversationId, this.id, conversationId, sealed, Date.now());
6120
+ VALUES (?, ?, (SELECT COALESCE(MAX(fact_id),0)+1 FROM agent_memory_fact WHERE identity_id=? AND conversation_id=?), ?, ?)`).run(this.id, conversationId, this.id, conversationId, sealed, now);
6121
+ this.pruneMemoryFacts(conversationId, now);
6122
+ }
6123
+ /**
6124
+ * Import one owner-approved legacy fact exactly once. `assignmentKey` is
6125
+ * content-free (`source-id:line-hash`) and intentionally excludes the target
6126
+ * channel: the same legacy fact can therefore never be replayed into a
6127
+ * second audience. The ledger and sealed fact commit atomically.
6128
+ */
6129
+ async recordImportedMemoryFact(conversationId, fact, assignmentKey) {
6130
+ if (!/^[0-9a-f]{64}:[0-9a-f]{64}$/.test(assignmentKey)) {
6131
+ throw new Error("invalid legacy memory assignment key");
6132
+ }
6133
+ const existing = this.db.prepare(`SELECT conversation_id FROM agent_memory_fact_assignment
6134
+ WHERE identity_id=? AND assignment_key=?`).get(this.id, assignmentKey);
6135
+ if (existing) {
6136
+ if (existing.conversation_id !== conversationId) {
6137
+ throw new Error("legacy memory fact is already assigned to another channel");
6138
+ }
6139
+ return { factId: 0, deduped: true };
6140
+ }
6141
+ const now = this.memoryNow();
6142
+ const sealed = await this.cipher.seal(new TextEncoder().encode(fact));
6143
+ this.db.exec("BEGIN IMMEDIATE");
6144
+ try {
6145
+ const raced = this.db.prepare(`SELECT conversation_id FROM agent_memory_fact_assignment
6146
+ WHERE identity_id=? AND assignment_key=?`).get(this.id, assignmentKey);
6147
+ if (raced) {
6148
+ if (raced.conversation_id !== conversationId) {
6149
+ throw new Error("legacy memory fact is already assigned to another channel");
6150
+ }
6151
+ this.db.exec("COMMIT");
6152
+ return { factId: 0, deduped: true };
6153
+ }
6154
+ const row = this.db.prepare("SELECT COALESCE(MAX(fact_id),0)+1 AS fact_id FROM agent_memory_fact WHERE identity_id=? AND conversation_id=?").get(this.id, conversationId);
6155
+ this.db.prepare(`INSERT INTO agent_memory_fact (identity_id, conversation_id, fact_id, sealed_fact, added_at)
6156
+ VALUES (?, ?, ?, ?, ?)`).run(this.id, conversationId, row.fact_id, sealed, now);
6157
+ this.db.prepare(`INSERT INTO agent_memory_fact_assignment (identity_id, assignment_key, conversation_id, applied_at)
6158
+ VALUES (?, ?, ?, ?)`).run(this.id, assignmentKey, conversationId, now);
6159
+ this.pruneMemoryFacts(conversationId, now);
6160
+ this.db.exec("COMMIT");
6161
+ return { factId: row.fact_id, deduped: false };
6162
+ } catch (error) {
6163
+ this.db.exec("ROLLBACK");
6164
+ throw error;
6165
+ }
6166
+ }
6167
+ /** Remove every durable turn and fact in one channel-scoped namespace. */
6168
+ purgeMemory(conversationId) {
6169
+ this.db.exec("BEGIN IMMEDIATE");
6170
+ try {
6171
+ this.db.prepare("DELETE FROM agent_memory WHERE identity_id=? AND conversation_id=?").run(this.id, conversationId);
6172
+ this.db.prepare("DELETE FROM agent_memory_fact WHERE identity_id=? AND conversation_id=?").run(this.id, conversationId);
6173
+ this.db.prepare("DELETE FROM agent_memory_fact_assignment WHERE identity_id=? AND conversation_id=?").run(this.id, conversationId);
6174
+ this.db.exec("COMMIT");
6175
+ } catch (error) {
6176
+ this.db.exec("ROLLBACK");
6177
+ throw error;
6178
+ }
6179
+ }
6180
+ pruneMemory(conversationId, now) {
6181
+ this.pruneMemoryTurns(conversationId, now);
6182
+ this.pruneMemoryFacts(conversationId, now);
6183
+ }
6184
+ pruneMemoryTurns(conversationId, now) {
6185
+ if (this.memoryRetentionMs > 0) {
6186
+ this.db.prepare("DELETE FROM agent_memory WHERE identity_id=? AND conversation_id=? AND recorded_at < ?").run(this.id, conversationId, now - this.memoryRetentionMs);
6187
+ }
6188
+ if (this.memoryRetentionK > 0) {
6189
+ const row = this.db.prepare("SELECT MAX(seq) AS seq FROM agent_memory WHERE identity_id=? AND conversation_id=?").get(this.id, conversationId);
6190
+ if (row.seq != null) {
6191
+ this.db.prepare("DELETE FROM agent_memory WHERE identity_id=? AND conversation_id=? AND seq <= ?").run(this.id, conversationId, row.seq - this.memoryRetentionK);
6192
+ }
6193
+ }
6194
+ }
6195
+ pruneMemoryFacts(conversationId, now) {
6196
+ if (this.memoryFactRetentionMs > 0) {
6197
+ this.db.prepare("DELETE FROM agent_memory_fact WHERE identity_id=? AND conversation_id=? AND added_at < ?").run(this.id, conversationId, now - this.memoryFactRetentionMs);
6198
+ }
6199
+ if (this.memoryFactRetentionK > 0) {
6200
+ const row = this.db.prepare("SELECT MAX(fact_id) AS fact_id FROM agent_memory_fact WHERE identity_id=? AND conversation_id=?").get(this.id, conversationId);
6201
+ if (row.fact_id != null) {
6202
+ this.db.prepare("DELETE FROM agent_memory_fact WHERE identity_id=? AND conversation_id=? AND fact_id <= ?").run(this.id, conversationId, row.fact_id - this.memoryFactRetentionK);
6203
+ }
6204
+ }
6093
6205
  }
6094
6206
  /** @internal test-only */
6095
6207
  _rawMainForTest(identityId, channelId) {
@@ -7316,6 +7428,7 @@ import { createHash, randomUUID } from "node:crypto";
7316
7428
  import {
7317
7429
  chmodSync,
7318
7430
  existsSync,
7431
+ lstatSync,
7319
7432
  mkdirSync,
7320
7433
  readFileSync as readFileSync5,
7321
7434
  renameSync,
@@ -7323,7 +7436,7 @@ import {
7323
7436
  unlinkSync,
7324
7437
  writeFileSync
7325
7438
  } from "node:fs";
7326
- import { basename as basename2, join as join6 } from "node:path";
7439
+ import { basename as basename2, isAbsolute, join as join6 } from "node:path";
7327
7440
  var MEMORY_FILE = "MEMORY.md";
7328
7441
  var REAL_FS = {
7329
7442
  mkdir: (p, mode) => mkdirSync(p, { recursive: true, mode }),
@@ -7398,6 +7511,54 @@ function lineHash(line) {
7398
7511
  return createHash("sha256").update(line.trim(), "utf8").digest("hex");
7399
7512
  }
7400
7513
  var LOCAL_REPORT_FILE = "local-config.json";
7514
+ var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
7515
+ var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
7516
+ var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
7517
+ var LEGACY_MEMORY_FACT_MAX_BYTES = 1024;
7518
+ var LEGACY_MEMORY_FACT_DECISION_MAX = 500;
7519
+ function legacySourceId(source, bytes) {
7520
+ return createHash("sha256").update(source.kind).update("\0").update(source.channel_id ?? "").update("\0").update(source.path).update("\0").update(bytes).digest("hex");
7521
+ }
7522
+ function localLegacySources(doc) {
7523
+ if (!Array.isArray(doc.legacy_memory)) return [];
7524
+ const out = [];
7525
+ let contentBudget = LEGACY_MEMORY_REPORT_MAX_BYTES;
7526
+ for (const raw of doc.legacy_memory) {
7527
+ if (out.length >= LEGACY_MEMORY_REPORT_MAX_SOURCES) break;
7528
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
7529
+ const source = raw;
7530
+ const kind = source.kind;
7531
+ const path = source.path;
7532
+ const channelId = source.channelId;
7533
+ if (kind !== "global-memory" && kind !== "conversation-history" && kind !== "captured-memory" || typeof path !== "string" || !isAbsolute(path) || channelId !== void 0 && typeof channelId !== "string") continue;
7534
+ try {
7535
+ const st = lstatSync(path);
7536
+ if (!st.isFile() || st.isSymbolicLink()) continue;
7537
+ if (st.size <= LEGACY_MEMORY_SOURCE_MAX_BYTES && st.size > contentBudget) continue;
7538
+ const oversize = st.size > LEGACY_MEMORY_SOURCE_MAX_BYTES;
7539
+ const bytes = oversize ? Buffer.from(`oversize:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, "utf8") : Buffer.from(readFileSync5(path));
7540
+ if (!oversize && (bytes.byteLength > LEGACY_MEMORY_SOURCE_MAX_BYTES || bytes.byteLength > contentBudget)) {
7541
+ continue;
7542
+ }
7543
+ const exportable = !oversize;
7544
+ const candidate = {
7545
+ id: "",
7546
+ kind,
7547
+ ...typeof channelId === "string" && channelId ? { channel_id: channelId } : {},
7548
+ path,
7549
+ content: exportable ? bytes.toString("utf8") : null,
7550
+ bytes: exportable ? bytes.byteLength : st.size,
7551
+ exportable
7552
+ };
7553
+ candidate.id = legacySourceId(candidate, bytes);
7554
+ if (source.id !== candidate.id) continue;
7555
+ out.push(candidate);
7556
+ if (exportable) contentBudget -= bytes.byteLength;
7557
+ } catch {
7558
+ }
7559
+ }
7560
+ return out;
7561
+ }
7401
7562
  function readLocalReport(home, identityId) {
7402
7563
  const p = join6(runtimeConfigDir(home, identityId), LOCAL_REPORT_FILE);
7403
7564
  if (!existsSync(p)) return null;
@@ -7414,7 +7575,93 @@ function readLocalReport(home, identityId) {
7414
7575
  const persona = raw && typeof raw === "object" && !Array.isArray(raw) ? Object.fromEntries(
7415
7576
  Object.entries(raw).filter(([, v]) => typeof v === "string")
7416
7577
  ) : {};
7417
- return { persona, has_search_key: hasSearchKey, has_llm_key: hasLlmKey };
7578
+ return {
7579
+ persona,
7580
+ has_search_key: hasSearchKey,
7581
+ has_llm_key: hasLlmKey,
7582
+ legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source)
7583
+ };
7584
+ }
7585
+ function deleteLegacyMemorySources(home, identityId, ids) {
7586
+ if (ids.length === 0) return 0;
7587
+ const p = join6(runtimeConfigDir(home, identityId), LOCAL_REPORT_FILE);
7588
+ if (!existsSync(p)) return 0;
7589
+ let doc;
7590
+ try {
7591
+ doc = JSON.parse(readFileSync5(p, "utf8"));
7592
+ } catch {
7593
+ return 0;
7594
+ }
7595
+ const wanted = new Set(ids);
7596
+ let deleted = 0;
7597
+ for (const source of localLegacySources(doc)) {
7598
+ if (!wanted.has(source.id)) continue;
7599
+ try {
7600
+ unlinkSync(source.path);
7601
+ deleted += 1;
7602
+ } catch {
7603
+ }
7604
+ }
7605
+ return deleted;
7606
+ }
7607
+ async function applyLegacyMemoryFactDecisions(home, identityId, rawDecisions, recordFact) {
7608
+ if (!Array.isArray(rawDecisions) || rawDecisions.length > LEGACY_MEMORY_FACT_DECISION_MAX) {
7609
+ return { sourcesDeleted: 0, factsAssigned: 0 };
7610
+ }
7611
+ const bySource = /* @__PURE__ */ new Map();
7612
+ for (const raw of rawDecisions) {
7613
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
7614
+ return { sourcesDeleted: 0, factsAssigned: 0 };
7615
+ }
7616
+ const d = raw;
7617
+ if (typeof d.source_id !== "string" || !/^[0-9a-f]{64}$/.test(d.source_id) || typeof d.fact_hash !== "string" || !/^[0-9a-f]{64}$/.test(d.fact_hash) || d.action !== "assign" && d.action !== "delete" || d.action === "assign" && (typeof d.channel_id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(d.channel_id)) || d.action === "delete" && d.channel_id !== void 0) return { sourcesDeleted: 0, factsAssigned: 0 };
7618
+ const decision = d;
7619
+ const current = bySource.get(decision.source_id);
7620
+ if (current === null) continue;
7621
+ const group = current ?? /* @__PURE__ */ new Map();
7622
+ const prior = group.get(decision.fact_hash);
7623
+ if (prior && (prior.action !== decision.action || prior.channel_id !== decision.channel_id)) {
7624
+ bySource.set(decision.source_id, null);
7625
+ continue;
7626
+ }
7627
+ group.set(decision.fact_hash, decision);
7628
+ bySource.set(decision.source_id, group);
7629
+ }
7630
+ const reportPath = join6(runtimeConfigDir(home, identityId), LOCAL_REPORT_FILE);
7631
+ if (!existsSync(reportPath)) return { sourcesDeleted: 0, factsAssigned: 0 };
7632
+ let doc;
7633
+ try {
7634
+ doc = JSON.parse(readFileSync5(reportPath, "utf8"));
7635
+ } catch {
7636
+ return { sourcesDeleted: 0, factsAssigned: 0 };
7637
+ }
7638
+ let sourcesDeleted = 0;
7639
+ let factsAssigned = 0;
7640
+ for (const source of localLegacySources(doc)) {
7641
+ const decisions = bySource.get(source.id);
7642
+ if (!decisions || source.kind !== "global-memory" || source.content === null) continue;
7643
+ const facts = source.content.split("\n").map((line) => line.trim()).filter(Boolean);
7644
+ const currentHashes = new Set(facts.map(lineHash));
7645
+ if (currentHashes.size === 0 || decisions.size !== currentHashes.size || [...currentHashes].some((hash) => !decisions.has(hash)) || [...decisions].some(([hash]) => !currentHashes.has(hash))) continue;
7646
+ const uniqueFacts = /* @__PURE__ */ new Map();
7647
+ for (const fact of facts) uniqueFacts.set(lineHash(fact), fact);
7648
+ const assignments = [...decisions].filter(([, decision]) => decision.action === "assign");
7649
+ if (assignments.some(([hash]) => Buffer.byteLength(uniqueFacts.get(hash) ?? "", "utf8") > LEGACY_MEMORY_FACT_MAX_BYTES)) {
7650
+ continue;
7651
+ }
7652
+ try {
7653
+ for (const [hash, decision] of assignments) {
7654
+ await recordFact(decision.channel_id, uniqueFacts.get(hash), `${source.id}:${hash}`);
7655
+ factsAssigned += 1;
7656
+ }
7657
+ const stillCurrent = localLegacySources(doc).some((candidate) => candidate.id === source.id);
7658
+ if (!stillCurrent) continue;
7659
+ unlinkSync(source.path);
7660
+ sourcesDeleted += 1;
7661
+ } catch {
7662
+ }
7663
+ }
7664
+ return { sourcesDeleted, factsAssigned };
7418
7665
  }
7419
7666
  function readManagedMemory(home, identityId) {
7420
7667
  const p = join6(runtimeConfigDir(home, identityId), MEMORY_FILE);
@@ -7443,6 +7690,51 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
7443
7690
 
7444
7691
  // src/bridge-agent.ts
7445
7692
  import { homedir as homedir2 } from "node:os";
7693
+
7694
+ // src/reply-receipts.ts
7695
+ var DEFAULT_TTL_MS = 15 * 60 * 1e3;
7696
+ var DEFAULT_CAPACITY2 = 256;
7697
+ var RuntimeReplyReceiptLedger = class {
7698
+ receipts = /* @__PURE__ */ new Map();
7699
+ ttlMs;
7700
+ capacity;
7701
+ now;
7702
+ constructor(opts = {}) {
7703
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
7704
+ this.capacity = opts.capacity ?? DEFAULT_CAPACITY2;
7705
+ this.now = opts.now ?? Date.now;
7706
+ if (!Number.isFinite(this.ttlMs) || this.ttlMs <= 0) throw new Error("reply receipt ttlMs must be positive");
7707
+ if (!Number.isInteger(this.capacity) || this.capacity <= 0) throw new Error("reply receipt capacity must be positive");
7708
+ }
7709
+ record(inReplyTo, channelId, replyMessageId) {
7710
+ const receipt = Object.freeze({
7711
+ in_reply_to: inReplyTo,
7712
+ reply_message_id: replyMessageId,
7713
+ channel_id: channelId,
7714
+ replied_at: this.now()
7715
+ });
7716
+ this.prune(receipt.replied_at);
7717
+ this.receipts.delete(inReplyTo);
7718
+ this.receipts.set(inReplyTo, receipt);
7719
+ while (this.receipts.size > this.capacity) {
7720
+ const oldest = this.receipts.keys().next().value;
7721
+ if (oldest === void 0) break;
7722
+ this.receipts.delete(oldest);
7723
+ }
7724
+ return receipt;
7725
+ }
7726
+ get(inReplyTo) {
7727
+ this.prune(this.now());
7728
+ return this.receipts.get(inReplyTo) ?? null;
7729
+ }
7730
+ prune(now) {
7731
+ for (const [key, receipt] of this.receipts) {
7732
+ if (now - receipt.replied_at >= this.ttlMs) this.receipts.delete(key);
7733
+ }
7734
+ }
7735
+ };
7736
+
7737
+ // src/bridge-agent.ts
7446
7738
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
7447
7739
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
7448
7740
  var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
@@ -7712,6 +8004,12 @@ var BridgeAgent = class _BridgeAgent {
7712
8004
  channelOwner = /* @__PURE__ */ new Map();
7713
8005
  buffer = [];
7714
8006
  maxBuffer;
8007
+ /**
8008
+ * Short-lived, content-free runtime reply correlations. The attached runtime
8009
+ * supplies the inbound message id only when an outbound send is its reply;
8010
+ * ws-server/http-server record the receipt after send() publishes.
8011
+ */
8012
+ runtimeReplyReceipts = new RuntimeReplyReceiptLedger();
7715
8013
  /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
7716
8014
  droppedMessages = 0;
7717
8015
  /**
@@ -7937,6 +8235,7 @@ var BridgeAgent = class _BridgeAgent {
7937
8235
  waiters = [];
7938
8236
  pingHandle;
7939
8237
  safetyTimer;
8238
+ runtimeConfigTimer;
7940
8239
  listeners = /* @__PURE__ */ new Set();
7941
8240
  startedAt = 0;
7942
8241
  store;
@@ -8046,25 +8345,65 @@ var BridgeAgent = class _BridgeAgent {
8046
8345
  });
8047
8346
  if (!res.ok) throw new Error(`runtime-config ${res.status}`);
8048
8347
  const payload = await res.json();
8049
- if (!payload || !payload.version) {
8348
+ if (!payload) {
8349
+ process.stderr.write("[bridge] runtime config not applied (empty response)\n");
8350
+ return;
8351
+ }
8352
+ if (typeof payload.version !== "number") {
8050
8353
  process.stderr.write(
8051
- `[bridge] runtime config not applied (version ${JSON.stringify(payload?.version ?? null)} \u2014 no saved config)
8354
+ `[bridge] runtime config not applied (version ${JSON.stringify(payload.version ?? null)} \u2014 invalid response)
8052
8355
  `
8053
8356
  );
8054
8357
  return;
8055
8358
  }
8359
+ const version = payload.version;
8360
+ if (!version) {
8361
+ writeManagedFiles(this.home, this.cfg.identityId, {});
8362
+ process.stderr.write(
8363
+ `[bridge] runtime config not applied (version ${JSON.stringify(payload?.version ?? null)} \u2014 no saved config)
8364
+ `
8365
+ );
8366
+ }
8056
8367
  const skillsJson = payload.skills_json ?? {};
8057
- writeManagedFiles(this.home, this.cfg.identityId, {
8058
- ...payload.files ?? {},
8059
- "skills.json": `${JSON.stringify(skillsJson, null, 2)}
8368
+ if (version) {
8369
+ writeManagedFiles(this.home, this.cfg.identityId, {
8370
+ ...payload.files ?? {},
8371
+ "skills.json": `${JSON.stringify(skillsJson, null, 2)}
8060
8372
  `
8061
- });
8373
+ });
8374
+ }
8062
8375
  if (payload.memory_prunes?.length) {
8063
8376
  pruneManagedMemory(this.home, this.cfg.identityId, payload.memory_prunes);
8064
8377
  }
8378
+ if (payload.legacy_memory_deletes?.length) {
8379
+ deleteLegacyMemorySources(this.home, this.cfg.identityId, payload.legacy_memory_deletes);
8380
+ }
8381
+ if (payload.legacy_memory_fact_decisions?.length) {
8382
+ const provider = this.cfg.memoryProvider ?? "securecoms";
8383
+ const result = await applyLegacyMemoryFactDecisions(
8384
+ this.home,
8385
+ this.cfg.identityId,
8386
+ payload.legacy_memory_fact_decisions,
8387
+ async (channelId, fact, assignmentKey) => {
8388
+ if (provider === "none" || provider === "hermes") {
8389
+ throw new Error("configured memory provider has no protected channel-fact import path");
8390
+ }
8391
+ if (provider === "openclaw" && !await this.mayReadOpenClawMemory(channelId)) {
8392
+ throw new Error("OpenClaw legacy fact target is not the active owner DM");
8393
+ }
8394
+ await this.store.recordImportedMemoryFact(channelId, fact, assignmentKey);
8395
+ }
8396
+ );
8397
+ if (result.sourcesDeleted > 0) {
8398
+ process.stderr.write(
8399
+ `[bridge] legacy-memory disposition applied (sources=${result.sourcesDeleted}, assigned=${result.factsAssigned})
8400
+ `
8401
+ );
8402
+ }
8403
+ }
8065
8404
  const local = readLocalReport(this.home, this.cfg.identityId);
8066
8405
  const report = {
8067
- applied_version: payload.version,
8406
+ applied_version: version,
8068
8407
  paths: checkConfiguredPaths(skillsJson),
8069
8408
  // Read AFTER the prunes above, so the owner's next view of memory is
8070
8409
  // the file as it now stands, not the one they just pruned from.
@@ -8079,7 +8418,15 @@ var BridgeAgent = class _BridgeAgent {
8079
8418
  has_search_key: local?.has_search_key ?? false,
8080
8419
  // Same idea, for the BYOK LLM key file: only the runtime can see
8081
8420
  // whether one is configured in its own environment.
8082
- has_llm_key: local?.has_llm_key ?? false
8421
+ has_llm_key: local?.has_llm_key ?? false,
8422
+ // The backend uses this only to expose target channels compatible with
8423
+ // the actual protected-memory adapter. It is an enum, never a path or
8424
+ // provider credential.
8425
+ memory_provider: this.cfg.memoryProvider ?? "securecoms",
8426
+ // Inert legacy files only. `readLocalReport` revalidates the current
8427
+ // regular-file bytes and strips every local path before this leaves the
8428
+ // machine.
8429
+ legacy_memory: local?.legacy_memory ?? []
8083
8430
  };
8084
8431
  const posted = await this.fetchImpl(`${base}/report`, {
8085
8432
  method: "POST",
@@ -8091,13 +8438,16 @@ var BridgeAgent = class _BridgeAgent {
8091
8438
  });
8092
8439
  if (!posted.ok) {
8093
8440
  process.stderr.write(
8094
- `[bridge] runtime config v${payload.version} written, but reporting it failed (${posted.status}) \u2014 the console will keep showing it as not applied until the next cycle
8441
+ version ? `[bridge] runtime config v${version} written, but reporting it failed (${posted.status}) \u2014 the console will keep showing it as not applied until the next cycle
8442
+ ` : `[bridge] legacy-memory inventory report failed (${posted.status}); no saved runtime config was applied
8095
8443
  `
8096
8444
  );
8097
8445
  return;
8098
8446
  }
8099
- process.stderr.write(`[bridge] runtime config v${payload.version} applied
8100
- `);
8447
+ process.stderr.write(
8448
+ version ? `[bridge] runtime config v${version} applied
8449
+ ` : "[bridge] legacy-memory inventory reported; no saved runtime config was applied\n"
8450
+ );
8101
8451
  } catch (e) {
8102
8452
  process.stderr.write(
8103
8453
  `[bridge] runtime config refetch failed: ${e?.message ?? e}
@@ -8133,6 +8483,10 @@ var BridgeAgent = class _BridgeAgent {
8133
8483
  await this.provider.publishKeyPackage();
8134
8484
  await this.refetchVoiceSettings();
8135
8485
  await this.refetchRuntimeConfig();
8486
+ this.runtimeConfigTimer = setInterval(() => {
8487
+ void this.refetchRuntimeConfig();
8488
+ }, 6 * 60 * 60 * 1e3);
8489
+ this.runtimeConfigTimer.unref?.();
8136
8490
  this.startReceivePump();
8137
8491
  if (this.selfHealSweepEnabled) {
8138
8492
  this.bootSweepTimer = setTimeout(
@@ -8935,7 +9289,47 @@ var BridgeAgent = class _BridgeAgent {
8935
9289
  async recordMemory(input) {
8936
9290
  return this.memory.record(input);
8937
9291
  }
9292
+ /**
9293
+ * OpenClaw collapses text history and workspace facts into the owner's
9294
+ * session. Resolve the channel from fresh, authenticated server metadata
9295
+ * before exposing that provider through the local bridge API. A cold or
9296
+ * stale cache is refreshed once; any failure remains a fail-closed denial.
9297
+ */
9298
+ async mayReadOpenClawMemory(channelId) {
9299
+ if (this.cfg.memoryProvider !== "openclaw") return true;
9300
+ if (!this.freshMeta(channelId)) await this.refreshChannelMeta();
9301
+ if (!this.freshRoster(channelId)) await this.refreshChannelRoster(channelId);
9302
+ const meta = this.freshMeta(channelId);
9303
+ const roster = this.freshRoster(channelId);
9304
+ if (meta?.is_dm !== true || !roster) return false;
9305
+ const humanCounterparts = [...roster.entries()].filter(
9306
+ ([identityId, member]) => identityId !== this.cfg.identityId && !member.is_agent
9307
+ );
9308
+ return humanCounterparts.length === 1 && humanCounterparts[0][1].is_agent_owner;
9309
+ }
9310
+ /** An inbound turn may receive OpenClaw memory only from an active owner DM. */
9311
+ mayReadOpenClawMemoryForTurn(authorization) {
9312
+ return this.cfg.memoryProvider !== "openclaw" || authorization.sender_membership === "active" && authorization.audience === "owner_dm";
9313
+ }
9314
+ /**
9315
+ * Resolve the prior-turn snapshot carried to the native runtime. OpenClaw
9316
+ * stays owner-DM-only; every provider failure degrades to empty context and
9317
+ * never falls back to the runtime's retired plaintext JSON files.
9318
+ */
9319
+ async memoryForTurn(channelId, authorization, limit = 50) {
9320
+ if (!this.mayReadOpenClawMemoryForTurn(authorization)) {
9321
+ return { recent: [], facts: [] };
9322
+ }
9323
+ try {
9324
+ return await this.memory.context(channelId, limit);
9325
+ } catch {
9326
+ return { recent: [], facts: [] };
9327
+ }
9328
+ }
8938
9329
  async memoryContext(conversationId, limit) {
9330
+ if (!await this.mayReadOpenClawMemory(conversationId)) {
9331
+ return { recent: [], facts: [] };
9332
+ }
8939
9333
  return this.memory.context(conversationId, limit);
8940
9334
  }
8941
9335
  /**
@@ -8979,10 +9373,14 @@ var BridgeAgent = class _BridgeAgent {
8979
9373
  }
8980
9374
  try {
8981
9375
  let memory;
8982
- try {
8983
- memory = await this.memory.context(m.channelId, 30);
8984
- } catch {
9376
+ if (!this.mayReadOpenClawMemoryForTurn(m.authorization)) {
8985
9377
  memory = { recent: [], facts: [] };
9378
+ } else {
9379
+ try {
9380
+ memory = await this.memory.context(m.channelId, 30);
9381
+ } catch {
9382
+ memory = { recent: [], facts: [] };
9383
+ }
8986
9384
  }
8987
9385
  const messages = buildFastMessages({
8988
9386
  persona: this.cfg.voice.persona,
@@ -9235,13 +9633,42 @@ var BridgeAgent = class _BridgeAgent {
9235
9633
  const rows = await res.json();
9236
9634
  const members = /* @__PURE__ */ new Map();
9237
9635
  for (const r of rows) {
9238
- members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
9636
+ members.set(r.identity_id, {
9637
+ name: r.display_name,
9638
+ is_agent: r.is_agent,
9639
+ is_agent_owner: r.is_agent_owner === true,
9640
+ is_partner: r.is_partner === true
9641
+ });
9239
9642
  }
9240
9643
  this.channelRoster.set(channelId, { members, at: Date.now() });
9241
9644
  await this.resolveAndPinOwner(channelId, rows);
9242
9645
  } catch {
9243
9646
  }
9244
9647
  }
9648
+ /**
9649
+ * Classify the sender only from server-authenticated channel/roster data.
9650
+ * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
9651
+ * guess. The returned object and grant array are frozen before they cross the
9652
+ * daemon boundary so one consumer cannot widen a later execution check.
9653
+ */
9654
+ authorizationFor(channelId, senderId) {
9655
+ const meta = this.freshMeta(channelId);
9656
+ const sender = this.freshRoster(channelId)?.get(senderId);
9657
+ let audience = "unknown";
9658
+ if (meta && sender) {
9659
+ if (sender.is_agent) audience = "agent_to_agent";
9660
+ else if (meta.is_dm && sender.is_agent_owner) audience = "owner_dm";
9661
+ else if (meta.is_dm) audience = "other_human_dm";
9662
+ else if (sender.is_partner) audience = "partner_room";
9663
+ else audience = "team_room";
9664
+ }
9665
+ return Object.freeze({
9666
+ version: 1,
9667
+ audience,
9668
+ sender_membership: sender ? "active" : "unknown",
9669
+ tool_grants: Object.freeze([])
9670
+ });
9671
+ }
9245
9672
  /**
9246
9673
  * Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
9247
9674
  * NEVER read the server's `role` field to pick the owner. In a DM the owner
@@ -9755,6 +10182,7 @@ var BridgeAgent = class _BridgeAgent {
9755
10182
  sender_id: m.envelope.sender_id,
9756
10183
  text: typeof parsed.text === "string" ? parsed.text : "",
9757
10184
  ts: m.receivedAt,
10185
+ authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
9758
10186
  sender_name: senderName,
9759
10187
  sender_is_agent: senderIsAgent,
9760
10188
  attachments: [attachment],
@@ -9793,6 +10221,11 @@ var BridgeAgent = class _BridgeAgent {
9793
10221
  const senderName = this.freshRoster(m.channelId)?.get(
9794
10222
  m.envelope.sender_id
9795
10223
  )?.name;
10224
+ const turnAuthorization = this.authorizationFor(
10225
+ m.channelId,
10226
+ m.envelope.sender_id
10227
+ );
10228
+ const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
9796
10229
  const speaker = parsed.relay === true && typeof parsed.speaker_identity_id === "string" && typeof parsed.speaker_display_name === "string" ? {
9797
10230
  identityId: parsed.speaker_identity_id,
9798
10231
  displayName: parsed.speaker_display_name
@@ -9809,12 +10242,14 @@ var BridgeAgent = class _BridgeAgent {
9809
10242
  const turnText = parsed.text;
9810
10243
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
9811
10244
  const fastGreeting = isCallStart && fastPathFiring;
9812
- const runtimeEntry = (ackSent) => ({
10245
+ const runtimeEntry = (ackSent, memory) => ({
9813
10246
  channel_id: m.channelId,
9814
10247
  message_id: m.messageId,
9815
10248
  sender_id: m.envelope.sender_id,
9816
10249
  text: turnText,
9817
10250
  ts: m.receivedAt,
10251
+ authorization: turnAuthorization,
10252
+ memory,
9818
10253
  // A relay-attributed turn is the SPEAKER's message to the runtime:
9819
10254
  // name it after them and never flag it as agent-authored.
9820
10255
  sender_name: speaker?.displayName ?? senderName,
@@ -9828,18 +10263,23 @@ var BridgeAgent = class _BridgeAgent {
9828
10263
  if (fastPathFiring) {
9829
10264
  const fastSpeaker = speaker ?? (senderName ? { identityId: m.envelope.sender_id, displayName: senderName } : void 0);
9830
10265
  void this.runFastPath(
9831
- { channelId: m.channelId, messageId: m.messageId, senderId: m.envelope.sender_id },
10266
+ {
10267
+ channelId: m.channelId,
10268
+ messageId: m.messageId,
10269
+ senderId: m.envelope.sender_id,
10270
+ authorization: turnAuthorization
10271
+ },
9832
10272
  fastGreeting ? greetingTurnFor(this.voiceSettings ?? DEFAULT_VOICE_SETTINGS, fastSpeaker?.displayName) : parsed.text,
9833
10273
  voiceGuidance,
9834
10274
  fastSpeaker
9835
- ).then((outcome) => {
10275
+ ).then(async (outcome) => {
9836
10276
  if (outcome === "answered") return;
9837
- this.buffer.push(runtimeEntry(outcome === "deferred"));
10277
+ this.buffer.push(runtimeEntry(outcome === "deferred", await runtimeMemory));
9838
10278
  this.capBuffer();
9839
10279
  this.wakeListeners();
9840
10280
  });
9841
10281
  } else {
9842
- this.buffer.push(runtimeEntry(false));
10282
+ this.buffer.push(runtimeEntry(false, await runtimeMemory));
9843
10283
  }
9844
10284
  this.capBuffer();
9845
10285
  void recordInboundMemory(
@@ -9905,6 +10345,12 @@ var BridgeAgent = class _BridgeAgent {
9905
10345
  this.buffer.push(...keep);
9906
10346
  return out;
9907
10347
  }
10348
+ recordRuntimeReply(inReplyTo, channelId, replyMessageId) {
10349
+ return this.runtimeReplyReceipts.record(inReplyTo, channelId, replyMessageId);
10350
+ }
10351
+ runtimeReplyReceipt(inReplyTo) {
10352
+ return this.runtimeReplyReceipts.get(inReplyTo);
10353
+ }
9908
10354
  /**
9909
10355
  * Put messages back at the front (SSE/disconnect write-failure recovery).
9910
10356
  *
@@ -10056,6 +10502,7 @@ var BridgeAgent = class _BridgeAgent {
10056
10502
  this.closingDown = true;
10057
10503
  this.pingHandle?.close();
10058
10504
  if (this.safetyTimer) clearInterval(this.safetyTimer);
10505
+ if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
10059
10506
  if (this.bootSweepTimer) clearTimeout(this.bootSweepTimer);
10060
10507
  if (this.selfHealTimer) clearTimeout(this.selfHealTimer);
10061
10508
  await this.provider.drainInFlight(_BridgeAgent.GRACEFUL_DRAIN_MS);
@@ -10289,6 +10736,7 @@ var sendSchema = z3.object({
10289
10736
  channel_id: z3.string().min(1),
10290
10737
  text: z3.string().optional(),
10291
10738
  idempotency_key: z3.string().optional(),
10739
+ in_reply_to: z3.string().min(1).max(200).optional(),
10292
10740
  a2a: a2aSchema.optional()
10293
10741
  }).refine((d) => typeof d.text === "string" || d.a2a !== void 0, {
10294
10742
  message: "send requires either text or a2a"
@@ -10405,10 +10853,19 @@ function createHttpServer(agent, opts) {
10405
10853
  if (method === "GET" && path === "/v1/metrics") {
10406
10854
  return json(res, 200, agent.metrics());
10407
10855
  }
10856
+ if (method === "GET" && path === "/v1/reply-receipt") {
10857
+ const inReplyTo = url.searchParams.get("in_reply_to");
10858
+ if (!inReplyTo || inReplyTo.length > 200) return jsonError(res, "invalid_request");
10859
+ const receipt = agent.runtimeReplyReceipt(inReplyTo);
10860
+ return json(res, 200, receipt ? { found: true, receipt } : { found: false });
10861
+ }
10408
10862
  if (method === "POST" && path === "/v1/send") {
10409
10863
  const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
10410
10864
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
10411
10865
  const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a) : await agent.send(parsed.data.channel_id, parsed.data.text);
10866
+ if (parsed.data.in_reply_to) {
10867
+ agent.recordRuntimeReply(parsed.data.in_reply_to, parsed.data.channel_id, r.message_id);
10868
+ }
10412
10869
  return json(res, 200, r);
10413
10870
  }
10414
10871
  if (method === "POST" && path === "/v1/send-attachment") {
@@ -10664,6 +11121,10 @@ async function handleInbound(sock, agent, data, inflight) {
10664
11121
  sendJson(sock, wsErrorFrame("invalid_request"));
10665
11122
  return;
10666
11123
  }
11124
+ if (m.in_reply_to !== void 0 && (typeof m.in_reply_to !== "string" || m.in_reply_to.length === 0 || m.in_reply_to.length > 200)) {
11125
+ sendJson(sock, wsErrorFrame("invalid_request"));
11126
+ return;
11127
+ }
10667
11128
  if (m.a2a && typeof m.a2a === "object") {
10668
11129
  const a = m.a2a;
10669
11130
  if (typeof a.a2a_version !== "string" || !a.a2a_version || a.kind !== "message" && a.kind !== "task" && a.kind !== "artifact" || typeof a.object !== "object" || a.object === null) {
@@ -10672,6 +11133,9 @@ async function handleInbound(sock, agent, data, inflight) {
10672
11133
  }
10673
11134
  try {
10674
11135
  const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
11136
+ if (typeof m.in_reply_to === "string") {
11137
+ agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
11138
+ }
10675
11139
  sendJson(sock, { type: "sent", message_id: r.message_id });
10676
11140
  } catch (e) {
10677
11141
  if (e instanceof ChannelPendingError) {
@@ -10690,6 +11154,9 @@ async function handleInbound(sock, agent, data, inflight) {
10690
11154
  }
10691
11155
  try {
10692
11156
  const r = await agent.send(m.channel_id, m.text);
11157
+ if (typeof m.in_reply_to === "string") {
11158
+ agent.recordRuntimeReply(m.in_reply_to, m.channel_id, r.message_id);
11159
+ }
10693
11160
  sendJson(sock, { type: "sent", message_id: r.message_id });
10694
11161
  } catch (e) {
10695
11162
  if (e instanceof ChannelPendingError) {