@securecoms/agent-bridge 0.33.1 → 0.34.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
@@ -18585,6 +18585,8 @@ function isEpochConflict(e) {
18585
18585
  var LABEL = "agentvault-connect-v1";
18586
18586
  var DEFAULT_STUCK_THRESHOLD = 2;
18587
18587
  var DEFAULT_QUARANTINE_THRESHOLD = 8;
18588
+ var KEPT_WELCOME_COMMIT_DEFER_MS = 24 * 60 * 60 * 1e3;
18589
+ var KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS = 32;
18588
18590
  var DELIVERY_PULL_PAGE = 100;
18589
18591
  var DELIVERY_MAX_RETRIES = 5;
18590
18592
  var DrainPageFullError = class extends Error {
@@ -18789,6 +18791,15 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
18789
18791
  * {@link errorStats}; mutated only through {@link recordDecryptError} /
18790
18792
  * {@link recordDecryptSuccess}.
18791
18793
  */
18794
+ /** {@link KEPT_WELCOME_COMMIT_DEFER_MS}: channel -> when its first commit was
18795
+ * deferred behind the kept Welcome `welcomeId`. Restarted for a different
18796
+ * Welcome; cleared when the channel decrypts, or when a pass that saw the
18797
+ * whole queue kept no Welcome for it (fix round 1, L2: the Welcome
18798
+ * resolved, so a later re-Welcome gets a fresh, bounded window). An entry
18799
+ * past its bound stays (expired) until then, so the bound cannot restart
18800
+ * itself pass after pass. In memory: a reload restarts it, and the
18801
+ * per-pass row cap is what bounds the page cost across reloads. */
18802
+ keptWelcomeDeferSince = /* @__PURE__ */ new Map();
18792
18803
  errors = /* @__PURE__ */ new Map();
18793
18804
  stuckThreshold;
18794
18805
  /** Consecutive-error threshold at which a wedged channel's row is quarantined
@@ -19007,8 +19018,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19007
19018
  * channel resumes normal processing and can quarantine again later if it
19008
19019
  * re-wedges (the onWedged guard is reset so the hook can fire again).
19009
19020
  */
19021
+ /** See {@link MlsGroupKeyProviderOpts.deferCommitsWhileWelcomePending}. The
19022
+ * caller has already established that this pass KEPT a no-state Welcome
19023
+ * for `channelId`. True: defer the commit row. Never throws. */
19024
+ deferKeptWelcomeCommit(channelId, welcomeId) {
19025
+ const pred = this.o.deferCommitsWhileWelcomePending;
19026
+ if (!pred)
19027
+ return false;
19028
+ let boundMs = 0;
19029
+ try {
19030
+ const v = pred(channelId);
19031
+ boundMs = v === true ? KEPT_WELCOME_COMMIT_DEFER_MS : typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.min(v, KEPT_WELCOME_COMMIT_DEFER_MS) : 0;
19032
+ } catch {
19033
+ boundMs = 0;
19034
+ }
19035
+ if (boundMs <= 0)
19036
+ return false;
19037
+ const now = this.nowMs();
19038
+ const entry = this.keptWelcomeDeferSince.get(channelId);
19039
+ if (entry === void 0 || entry.welcomeId !== welcomeId) {
19040
+ this.keptWelcomeDeferSince.set(channelId, { since: now, welcomeId });
19041
+ return true;
19042
+ }
19043
+ return now - entry.since < boundMs;
19044
+ }
19010
19045
  recordDecryptSuccess(channelId) {
19011
19046
  this.lastDecryptAt.set(channelId, Date.now());
19047
+ this.keptWelcomeDeferSince.delete(channelId);
19012
19048
  const e = this.errors.get(channelId);
19013
19049
  if (!e)
19014
19050
  return;
@@ -19404,6 +19440,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19404
19440
  * server's REJOIN_THRESHOLD, there was nothing to apply, or a commit would
19405
19441
  * not apply (which means the state is genuinely FORKED, not merely behind,
19406
19442
  * and replay cannot fix a fork).
19443
+ *
19444
+ * THROWS (retryable, never `false`, #1290): CustodyPersistError when the
19445
+ * channel is under the custody hold (nothing applied), and the C2 classes
19446
+ * (PendingCommitUnresolvedError from the guarded MAIN write) after evicting
19447
+ * the advanced cache, so the persisted state is unchanged. A /sync request
19448
+ * that fails also throws, before anything is applied.
19407
19449
  */
19408
19450
  async resyncByReplay(channelId, groupId) {
19409
19451
  if (!await this.hasPersistedChannelState(channelId))
@@ -19423,12 +19465,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19423
19465
  let applied = 0;
19424
19466
  try {
19425
19467
  await this.locks.run(channelId, async () => {
19468
+ if (await this.custodyHeld(channelId))
19469
+ throw new CustodyPersistError(channelId);
19426
19470
  const mgr = await this.manager(channelId);
19427
19471
  try {
19428
19472
  for (const c of commits) {
19429
19473
  if (c.epoch <= Number(mgr.epoch))
19430
19474
  continue;
19431
- await mgr.decrypt(hexToBytes22(c.payload));
19475
+ const commitBytes = hexToBytes22(c.payload);
19476
+ try {
19477
+ await mgr.decrypt(commitBytes);
19478
+ } catch (e) {
19479
+ if (await probeOwnRemoval(mgr, commitBytes) !== null) {
19480
+ this.noteOwnLeafRemoved(channelId, {
19481
+ group_id: groupId,
19482
+ epoch: c.epoch,
19483
+ message_id: c.id
19484
+ });
19485
+ }
19486
+ throw e;
19487
+ }
19432
19488
  applied++;
19433
19489
  }
19434
19490
  if (applied > 0)
@@ -19440,6 +19496,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19440
19496
  }
19441
19497
  });
19442
19498
  } catch (e) {
19499
+ if (isSdkError(e, "CustodyPersistError") || e instanceof CustodyPersistError || e instanceof PendingCommitUnresolvedError || e instanceof GroupSupersededError || e instanceof AttestedOrphanRefusedError) {
19500
+ writeServiceLog("warn", "resync.replay_held", { applied }, e);
19501
+ throw e;
19502
+ }
19443
19503
  writeServiceLog("warn", "resync.replay_commit_failed", { applied }, e);
19444
19504
  return false;
19445
19505
  }
@@ -22140,6 +22200,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22140
22200
  out.push({
22141
22201
  queueId: `replay-${m.id}`,
22142
22202
  messageId: envelope.message_id,
22203
+ // #1246 C-1: the group-messages row id is the same mls_messages key.
22204
+ ...typeof m.id === "string" && m.id ? { serverMessageId: m.id } : {},
22143
22205
  channelId,
22144
22206
  plaintext,
22145
22207
  envelope,
@@ -23040,6 +23102,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23040
23102
  const heldBundleRefs = () => heldBundleRefsMemo ??= this.readHeldBundleRefs();
23041
23103
  const w2Acked = /* @__PURE__ */ new Map();
23042
23104
  const w2Kept = /* @__PURE__ */ new Set();
23105
+ const keptWelcomeId = /* @__PURE__ */ new Map();
23106
+ const deferredThisPass = /* @__PURE__ */ new Map();
23043
23107
  const unpinnedRefusals = /* @__PURE__ */ new Map();
23044
23108
  const welcomes = rows.filter((r) => r.message_type === "welcome");
23045
23109
  const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
@@ -23198,6 +23262,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23198
23262
  writeServiceLog("warn", "welcome.unopenable");
23199
23263
  } else {
23200
23264
  w2Kept.add(wcid);
23265
+ if (!keptWelcomeId.has(wcid))
23266
+ keptWelcomeId.set(wcid, row.queue_id);
23201
23267
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
23202
23268
  writeServiceLog("debug", "delivery.welcome_no_local_state");
23203
23269
  }
@@ -23208,6 +23274,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23208
23274
  const cid = row.connect_channel_id;
23209
23275
  if (!cid)
23210
23276
  continue;
23277
+ if (w2Kept.has(cid) && (deferredThisPass.get(cid) ?? 0) < KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS && this.deferKeptWelcomeCommit(cid, keptWelcomeId.get(cid) ?? "")) {
23278
+ deferredThisPass.set(cid, (deferredThisPass.get(cid) ?? 0) + 1);
23279
+ continue;
23280
+ }
23211
23281
  let advancedEpoch;
23212
23282
  let custodyHeld = false;
23213
23283
  await this.locks.run(cid, async () => {
@@ -23364,6 +23434,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23364
23434
  this.leftStreak.set(cid, { pulls: 1, since: pullNow });
23365
23435
  this.scheduleAutoResolve(cid);
23366
23436
  }
23437
+ if (rows.length < DELIVERY_PULL_PAGE) {
23438
+ for (const cid of [...this.keptWelcomeDeferSince.keys()]) {
23439
+ if (!w2Kept.has(cid))
23440
+ this.keptWelcomeDeferSince.delete(cid);
23441
+ }
23442
+ }
23367
23443
  for (const [cid, gid] of w2Acked) {
23368
23444
  if (w2Kept.has(cid))
23369
23445
  continue;
@@ -23643,7 +23719,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23643
23719
  voiceDefaultResponder: row.voice_default_responder ?? void 0,
23644
23720
  ...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
23645
23721
  payloadHex: row.payload,
23646
- openedAt
23722
+ openedAt,
23723
+ // #1246 C-1: the server row id, beside the sender-chosen envelope id.
23724
+ ...typeof row.message_id === "string" && row.message_id ? { serverMessageId: row.message_id } : {}
23647
23725
  };
23648
23726
  const custodiedAt = this.nowMs();
23649
23727
  this.custody.set(row.queue_id, entry);
@@ -26165,6 +26243,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
26165
26243
  // Owner-signed tool grants (spec §7.0): like Plan 0's backend, each row carries
26166
26244
  // the envelope's `identity_proof.signature` as `identity_signature` beside the
26167
26245
  // unsigned canonical payload. `omitSignature` models a pre-Plan-0 row.
26246
+ /** `rowMessageId` (#1246 C-1): the server row id to deliver, when it is NOT
26247
+ * the envelope's id. The REST send path keys the row by the envelope id
26248
+ * (connect_messages.py, `MLSMessage(id=message_id)`); the WS `message_mls`
26249
+ * path stores through `store_mls_message`, whose row id is server-generated,
26250
+ * so there the two differ and the envelope id is the sender's free choice. */
26168
26251
  async ingestApplication(envelope, opts) {
26169
26252
  const channelId = envelope.channel_id;
26170
26253
  const canonical = await canonicalEnvelopeBytes(envelope);
@@ -26176,7 +26259,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26176
26259
  continue;
26177
26260
  this.q(dev).push({
26178
26261
  queue_id: "q-" + this._seq++,
26179
- message_id: envelope.message_id,
26262
+ message_id: opts?.rowMessageId ?? envelope.message_id,
26180
26263
  group_id: groupId,
26181
26264
  message_type: "application",
26182
26265
  sender_device_id: senderDeviceId ?? "",
@@ -26560,6 +26643,7 @@ function openSchema(db) {
26560
26643
  ensureRuntimeInboxDeliveryAttempts(db);
26561
26644
  ensureRuntimeReplyIntent(db);
26562
26645
  ensureRuntimeReplyIntentEnvelopeGroup(db);
26646
+ ensureRuntimeReplyIntentCredit(db);
26563
26647
  ensureGroupLink(db);
26564
26648
  ensureMlsCustody(db);
26565
26649
  ensureToolGrantTables(db);
@@ -26640,6 +26724,19 @@ function ensureRuntimeReplyIntentEnvelopeGroup(db) {
26640
26724
  throw e;
26641
26725
  }
26642
26726
  }
26727
+ function ensureRuntimeReplyIntentCredit(db) {
26728
+ const hasColumn = () => db.prepare("PRAGMA table_info(runtime_reply_intent)").all().some((c) => c.name === "reply_credit");
26729
+ const cols = db.prepare("PRAGMA table_info(runtime_reply_intent)").all();
26730
+ if (cols.length === 0 || cols.some((c) => c.name === "reply_credit"))
26731
+ return;
26732
+ try {
26733
+ db.exec("ALTER TABLE runtime_reply_intent ADD COLUMN reply_credit TEXT");
26734
+ } catch (e) {
26735
+ if (hasColumn())
26736
+ return;
26737
+ throw e;
26738
+ }
26739
+ }
26643
26740
  var RECALL_STATE_COLUMNS = [
26644
26741
  ["recall_enabled", "INTEGER NOT NULL DEFAULT 0"],
26645
26742
  ["recall_mode_epoch", "INTEGER NOT NULL DEFAULT 0"],
@@ -27691,8 +27788,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27691
27788
  this.db.prepare("DELETE FROM runtime_reply_intent WHERE identity_id=? AND updated_at < ?").run(this.id, now - RUNTIME_REPLY_INTENT_RETENTION_MS);
27692
27789
  const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_reply_intent
27693
27790
  (identity_id, scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27694
- sealed_body, sealed_envelope, state, created_at, updated_at)
27695
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now);
27791
+ sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit)
27792
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null);
27696
27793
  const count = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_intent WHERE identity_id=?").get(this.id);
27697
27794
  if (count.n > RUNTIME_REPLY_INTENT_MAX) {
27698
27795
  this.db.prepare(`DELETE FROM runtime_reply_intent
@@ -27702,7 +27799,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27702
27799
  )`).run(this.id, this.id, RUNTIME_REPLY_INTENT_MAX);
27703
27800
  }
27704
27801
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27705
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
27802
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27706
27803
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, input.scope, input.part);
27707
27804
  if (!row)
27708
27805
  throw new Error("reply intent vanished after claim");
@@ -27711,7 +27808,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27711
27808
  /** SEC-06 #18: every intent answering `inReplyTo`, in claim order. */
27712
27809
  async listReplyIntents(inReplyTo) {
27713
27810
  const rows = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27714
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
27811
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27715
27812
  FROM runtime_reply_intent WHERE identity_id=? AND in_reply_to=?
27716
27813
  ORDER BY created_at ASC, rowid ASC`).all(this.id, inReplyTo);
27717
27814
  const out = [];
@@ -27722,7 +27819,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27722
27819
  /** SEC-06 #18: re-read one intent by its key (null when absent). */
27723
27820
  async getReplyIntent(scope, part) {
27724
27821
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
27725
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
27822
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
27726
27823
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, scope, part);
27727
27824
  return row ? this.openReplyIntent(row) : null;
27728
27825
  }
@@ -27796,7 +27893,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
27796
27893
  envelope,
27797
27894
  envelopeGroupId: envelopeGroupFor(envelope, await openOrNull(row.sealed_envelope_group)),
27798
27895
  state: row.state === "sent" ? "sent" : row.state === "skipped" ? "skipped" : "pending",
27799
- createdAt: Number(row.created_at)
27896
+ createdAt: Number(row.created_at),
27897
+ replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null
27800
27898
  };
27801
27899
  }
27802
27900
  async recordMemory(input) {
@@ -29566,8 +29664,8 @@ function decodeReplyBody(bytes) {
29566
29664
  if (b?.kind === "a2a" && b.a2a && typeof b.a2a === "object") return b;
29567
29665
  throw new Error("reply intent body is malformed");
29568
29666
  }
29569
- function replyBodyMac(key, replyId, channelId, body) {
29570
- return createHmac("sha256", key).update(JSON.stringify(["reply-body", replyId, channelId])).update("\0").update(body).digest("hex");
29667
+ function replyBodyMac(key, replyId, channelId, body, credit) {
29668
+ return createHmac("sha256", key).update(JSON.stringify(credit === void 0 ? ["reply-body", replyId, channelId] : ["reply-body", replyId, channelId, credit])).update("\0").update(body).digest("hex");
29571
29669
  }
29572
29670
  function partOrdinal(part) {
29573
29671
  const m = /^p:(\d+)$/.exec(part);
@@ -34789,6 +34887,8 @@ function checkConfiguredPaths(skillsJson, statFn = (p) => {
34789
34887
  }
34790
34888
  const search = skillsJson.search;
34791
34889
  if (search?.keyFile) paths.push(search.keyFile);
34890
+ const weather = skillsJson.weather;
34891
+ if (weather?.keyFile) paths.push(weather.keyFile);
34792
34892
  return paths.map((path) => {
34793
34893
  let st;
34794
34894
  try {
@@ -34814,7 +34914,7 @@ function parseSecretPathsReport(raw) {
34814
34914
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
34815
34915
  const r = raw;
34816
34916
  const f = r.outside_by_field;
34817
- if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite)) return void 0;
34917
+ if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite) || f.weather_key_file !== void 0 && !isCount(f.weather_key_file)) return void 0;
34818
34918
  return {
34819
34919
  mode: r.mode,
34820
34920
  inside: r.inside,
@@ -34824,7 +34924,8 @@ function parseSecretPathsReport(raw) {
34824
34924
  headers_file: f.headers_file,
34825
34925
  postgres_url_file: f.postgres_url_file,
34826
34926
  search_key_file: f.search_key_file,
34827
- ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
34927
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {},
34928
+ ...f.weather_key_file !== void 0 ? { weather_key_file: f.weather_key_file } : {}
34828
34929
  }
34829
34930
  };
34830
34931
  }
@@ -34904,6 +35005,7 @@ function readLocalReport(home, identityId) {
34904
35005
  has_search_key: hasSearchKey,
34905
35006
  has_llm_key: hasLlmKey,
34906
35007
  has_extract_provider: doc.has_extract_provider === true,
35008
+ has_weather_key: doc.has_weather_key === true,
34907
35009
  ...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
34908
35010
  legacy_memory: localLegacySources(doc).map(({ path: _path, content: _content, ...source }) => source),
34909
35011
  secret_paths: parseSecretPathsReport(doc.secret_paths)
@@ -35005,6 +35107,19 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
35005
35107
  return removed;
35006
35108
  }
35007
35109
 
35110
+ // src/reply-credit.ts
35111
+ var REPLY_CREDIT_FEATURE = "reply_credit_v1";
35112
+ var WEATHER_CREDIT_LINE = "Weather data: WeatherAPI.com. For general information only.";
35113
+ var WEATHER_CREDIT_SUFFIX = `
35114
+
35115
+ ${WEATHER_CREDIT_LINE}`;
35116
+ function isReplyCredit(v) {
35117
+ return v === "weatherapi";
35118
+ }
35119
+ function stripCreditSuffix(text) {
35120
+ return text.endsWith(WEATHER_CREDIT_SUFFIX) ? text.slice(0, text.length - WEATHER_CREDIT_SUFFIX.length) : text;
35121
+ }
35122
+
35008
35123
  // src/grants/binding.ts
35009
35124
  import { sha256 as sha25611 } from "@noble/hashes/sha256";
35010
35125
 
@@ -35093,9 +35208,10 @@ function loadOwnerGrantPin(opts) {
35093
35208
  // src/grants/manifest.ts
35094
35209
  var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
35095
35210
  var MANIFEST_KNOWN_TOOLS = Object.freeze({
35096
- owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query"]),
35211
+ owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query", "weather_forecast", "currency_convert"]),
35097
35212
  // web_extract: owner DM only, never team_room (spec §5.4, D5)
35098
- team_room: Object.freeze(["web_search"])
35213
+ // Weather/currency spec §6.3 (U5): both utility tools in both audiences, each held behind utility_tools_v1 (TOOL_FEATURE).
35214
+ team_room: Object.freeze(["web_search", "weather_forecast", "currency_convert"])
35099
35215
  });
35100
35216
  function manifestSignedBytes(manifest) {
35101
35217
  const domain = new TextEncoder().encode(TOOL_GRANTS_DOMAIN);
@@ -35419,20 +35535,30 @@ function bindTargets(raw, signed, equals) {
35419
35535
  }
35420
35536
  return { written, mismatches };
35421
35537
  }
35422
- function signedManifestTools(manifest, audience, searchProvidersCapable) {
35423
- return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && (t !== "web_extract" || searchProvidersCapable));
35538
+ var NO_FEATURES = Object.freeze(/* @__PURE__ */ new Set());
35539
+ var TOOL_FEATURE = Object.freeze({
35540
+ web_extract: "search_providers_v2",
35541
+ weather_forecast: "utility_tools_v1",
35542
+ currency_convert: "utility_tools_v1"
35543
+ });
35544
+ function toolAllowedByFeatures(tool, features) {
35545
+ if (!Object.hasOwn(TOOL_FEATURE, tool)) return true;
35546
+ return features.has(TOOL_FEATURE[tool]);
35547
+ }
35548
+ function signedManifestTools(manifest, audience, features) {
35549
+ return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && toolAllowedByFeatures(t, features));
35424
35550
  }
35425
- function manifestPolicyFor(manifest, switches, searchProvidersCapable) {
35551
+ function manifestPolicyFor(manifest, switches, features) {
35426
35552
  return {
35427
35553
  v: 1,
35428
35554
  manifest_tools: [.../* @__PURE__ */ new Set([
35429
- ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", searchProvidersCapable) : [],
35430
- ...switches.room ? signedManifestTools(manifest, "team_room", searchProvidersCapable) : []
35555
+ ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", features) : [],
35556
+ ...switches.room ? signedManifestTools(manifest, "team_room", features) : []
35431
35557
  ])].sort(),
35432
35558
  secret_path_mode: "enforce"
35433
35559
  };
35434
35560
  }
35435
- function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, searchProvidersCapable = false) {
35561
+ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, features = NO_FEATURES) {
35436
35562
  const mismatches = /* @__PURE__ */ new Set();
35437
35563
  const cfg = manifest.config;
35438
35564
  const out = {};
@@ -35461,7 +35587,8 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35461
35587
  for (const name of Object.keys(files)) {
35462
35588
  if (name !== LLM_SOURCE && !PERSONA_FILES.includes(name)) mismatches.add("files");
35463
35589
  }
35464
- const ownerDmTools = signedManifestTools(manifest, "owner_dm", searchProvidersCapable);
35590
+ const ownerDmTools = signedManifestTools(manifest, "owner_dm", features);
35591
+ const ownerDmSearchTools = ownerDmTools.filter((t) => t === "web_search" || t === "web_extract");
35465
35592
  const sj = isObj2(payload.skills_json) ? payload.skills_json : void 0;
35466
35593
  const eps = bindTargets(sj?.endpoints, manifest.endpoints, endpointEquals);
35467
35594
  const dbs = bindTargets(sj?.databases, manifest.databases, databaseEquals);
@@ -35482,17 +35609,20 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35482
35609
  const usablePrior = cfg.search !== void 0 && prior.search?.provider === cfg.search.provider ? prior.search : void 0;
35483
35610
  searchBlock = cfg.search === void 0 ? void 0 : usablePrior ?? Object.freeze({ provider: cfg.search.provider });
35484
35611
  }
35612
+ const pw = isObj2(sj?.weather) ? sj.weather : void 0;
35613
+ const weatherBlock = typeof pw?.keyFile === "string" && pw.keyFile !== "" ? { keyFile: pw.keyFile } : void 0;
35485
35614
  const skillsDoc = {
35486
35615
  endpoints: eps.written,
35487
35616
  databases: dbs.written,
35488
35617
  flags: [...cfg.flags],
35489
35618
  ...deny !== void 0 || cfg.tools_allow !== void 0 ? { tools: { ...deny !== void 0 ? { deny } : {}, ...cfg.tools_allow !== void 0 ? { allow: [...cfg.tools_allow] } : {} } } : {},
35490
35619
  ...searchBlock ? { search: searchBlock } : {},
35620
+ ...weatherBlock ? { weather: weatherBlock } : {},
35491
35621
  // D31: the marker the runtime keys D24 (iii) on; written under every manifest, true when config.search is signed.
35492
35622
  search_signed: cfg.search !== void 0,
35493
35623
  // D36, D39: the signed owner-DM cap, only when the owner-DM switch is on and the manifest grants an owner-DM tool;
35494
35624
  // otherwise none, so the runtime clamps a signed raise to the default. Never read from the payload.
35495
- ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
35625
+ ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmSearchTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
35496
35626
  };
35497
35627
  out["skills.json"] = JSON.stringify(skillsDoc, null, 2) + "\n";
35498
35628
  const signedMemory = { mode: cfg.memory.mode, ...cfg.memory.recall_days !== void 0 ? { recall_days: cfg.memory.recall_days } : {} };
@@ -35523,7 +35653,7 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35523
35653
  mismatches.add("legacy_assign");
35524
35654
  legacyDecisions = decisions.filter((d) => isObj2(d) && d.action === "delete");
35525
35655
  }
35526
- const policy = manifestPolicyFor(manifest, switches, searchProvidersCapable);
35656
+ const policy = manifestPolicyFor(manifest, switches, features);
35527
35657
  return {
35528
35658
  mismatches: CONFIG_BINDING_FIELDS.filter((f) => mismatches.has(f)).sort(),
35529
35659
  targetMismatches: { endpoint: eps.mismatches, database: dbs.mismatches },
@@ -35666,7 +35796,11 @@ var TOOL_ROOM_CLASSES = Object.freeze({
35666
35796
  // D5: spends the owner's key and sends URLs out
35667
35797
  fetch_url: Object.freeze(["egress"]),
35668
35798
  sql_query: Object.freeze(["owner_credential"]),
35669
- http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
35799
+ http_request: Object.freeze(["owner_credential", "egress", "side_effect"]),
35800
+ // Weather/currency (plan audit r1; supersedes spec §8's values): both send room words to an outside host, and weather
35801
+ // spends the owner's key.
35802
+ weather_forecast: Object.freeze(["owner_credential", "egress"]),
35803
+ currency_convert: Object.freeze(["egress"])
35670
35804
  });
35671
35805
  var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
35672
35806
  function isLiveRow(r) {
@@ -37480,7 +37614,7 @@ var BridgeAgent = class _BridgeAgent {
37480
37614
  }
37481
37615
  let bound;
37482
37616
  try {
37483
- bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable());
37617
+ bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures());
37484
37618
  } catch (e) {
37485
37619
  bound = null;
37486
37620
  return this.bindFailed(manifest, payload.memory, e);
@@ -37501,7 +37635,7 @@ var BridgeAgent = class _BridgeAgent {
37501
37635
  try {
37502
37636
  this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, bound.files);
37503
37637
  if (manifest.config.llm === void 0) this.grantWriters.removeManagedLlmSource(this.home, this.cfg.identityId);
37504
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable()));
37638
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures()));
37505
37639
  } catch (e) {
37506
37640
  this.manifestBinding = null;
37507
37641
  throw e;
@@ -37555,6 +37689,7 @@ var BridgeAgent = class _BridgeAgent {
37555
37689
  async postRuntimeConfigReport(base, version, skillsJson) {
37556
37690
  const local = readLocalReport(this.home, this.cfg.identityId);
37557
37691
  const reportedSearchProviders = this.searchProvidersCapable();
37692
+ const reportedUtilityTools = this.utilityToolsCapable();
37558
37693
  const report = {
37559
37694
  applied_version: version,
37560
37695
  paths: checkConfiguredPaths(skillsJson),
@@ -37572,6 +37707,9 @@ var BridgeAgent = class _BridgeAgent {
37572
37707
  // Final review I-2 (controller ruling): AND the current search_providers_v2, so the web never shows extract while
37573
37708
  // the bridge withholds web_extract from the runtime.
37574
37709
  has_extract_provider: (local?.has_extract_provider ?? false) && reportedSearchProviders,
37710
+ // Weather/currency L-1, §8 item 5: AND the current utility_tools_v1, so the web never shows a usable weather key
37711
+ // while the bridge withholds weather_forecast from the runtime.
37712
+ has_weather_key: (local?.has_weather_key ?? false) && reportedUtilityTools,
37575
37713
  // D39: the owner-DM daily cap the runtime enforces, passed through when it reported a valid one; never the signed value.
37576
37714
  ...local?.owner_dm_search_cap_effective !== void 0 ? { owner_dm_search_cap_effective: local.owner_dm_search_cap_effective } : {},
37577
37715
  // Same idea, for the BYOK LLM key file: only the runtime can see
@@ -37619,7 +37757,9 @@ var BridgeAgent = class _BridgeAgent {
37619
37757
  // D28 (§11.1 step 3): a separate key, never a value in the capability enum. False with no runtime attached.
37620
37758
  search_providers_v2: reportedSearchProviders,
37621
37759
  // D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
37622
- search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders)
37760
+ search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders),
37761
+ // Weather/currency §8 item 5: a separate key, false with no runtime attached.
37762
+ utility_tools_v1: reportedUtilityTools
37623
37763
  }
37624
37764
  };
37625
37765
  const reportedVerifyFailures = this.ownerVerifyFailures;
@@ -37641,6 +37781,7 @@ var BridgeAgent = class _BridgeAgent {
37641
37781
  }
37642
37782
  this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
37643
37783
  this.lastReportedSearchProviders = reportedSearchProviders;
37784
+ this.lastReportedUtilityTools = reportedUtilityTools;
37644
37785
  if (version) writeServiceLog2("info", "runtime_config.applied", { version });
37645
37786
  else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
37646
37787
  }
@@ -37710,7 +37851,7 @@ var BridgeAgent = class _BridgeAgent {
37710
37851
  this.startedAt = Date.now();
37711
37852
  await this.restoreRuntimeInbox();
37712
37853
  await this.loadToolGrantState();
37713
- this.recomputeManifestPolicy(false);
37854
+ this.recomputeManifestPolicy(NO_FEATURES);
37714
37855
  try {
37715
37856
  this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
37716
37857
  } catch (e) {
@@ -38693,13 +38834,13 @@ var BridgeAgent = class _BridgeAgent {
38693
38834
  * absent, because the id is derived.
38694
38835
  */
38695
38836
  async send(channelId, text, messageId, opts = {}) {
38696
- const body = { kind: "text", text };
38837
+ const body = { kind: "text", text: opts.credit !== void 0 ? text + WEATHER_CREDIT_SUFFIX : text };
38697
38838
  if (this.isIdempotentSend(messageId, opts)) return this.trackReplyWork(this.sendIdempotent(channelId, body, opts));
38698
38839
  const id = resolveMessageId(messageId);
38699
38840
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
38700
38841
  const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
38701
38842
  await this.publishBody(channelId, body, id);
38702
- this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
38843
+ this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, text);
38703
38844
  return { message_id: id, status: "sent" };
38704
38845
  }
38705
38846
  /** PR #716 review MEDIUM-5: register reply work so close() can wait for it. */
@@ -38718,7 +38859,7 @@ var BridgeAgent = class _BridgeAgent {
38718
38859
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
38719
38860
  const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
38720
38861
  await this.publishBody(channelId, body, id);
38721
- this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
38862
+ this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, null);
38722
38863
  return { message_id: id, status: "sent" };
38723
38864
  }
38724
38865
  /**
@@ -38851,18 +38992,24 @@ var BridgeAgent = class _BridgeAgent {
38851
38992
  const meta = this.freshMeta(capChannel);
38852
38993
  if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
38853
38994
  }
38854
- /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
38855
- afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture) {
38995
+ /**
38996
+ * Bookkeeping after a CONFIRMED publish of `body` under `id`. `recordText` (weather/currency Task 17) is what a text
38997
+ * reply is RECORDED as: the published text minus any credit line. Every record site below reads `recorded`, never
38998
+ * `body.text`. Every production caller passes it (pinned by reply-credit.test.ts: text, or null for an a2a body);
38999
+ * undefined is left only for direct test calls of an uncredited body, where the published and record texts are equal.
39000
+ */
39001
+ afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture, recordText) {
39002
+ const recorded = recordText ?? (recordText === void 0 && body.kind === "text" ? body.text : "");
38856
39003
  if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
38857
39004
  const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
38858
- const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(body.text);
39005
+ const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(recorded);
38859
39006
  if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
38860
- if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(body.text) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
38861
- this.callBuffer.appendAgent(channelId, body.text, "reply");
39007
+ if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(recorded) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
39008
+ this.callBuffer.appendAgent(channelId, recorded, "reply");
38862
39009
  }
38863
39010
  const origin = this.spokenReplyIds.has(id) ? "fast_path" : !this.taintScope(channelId) ? "reply" : opts.inReplyTo !== void 0 && this.cleanTurns.has(opts.inReplyTo) && !this.taintedTurns.has(opts.inReplyTo) ? "reply" : "untrusted";
38864
- if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch, origin);
38865
- if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null, origin);
39011
+ if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, recorded, id, modality, purgeEpoch, origin);
39012
+ if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, recorded, id, replyCapture ?? null, origin);
38866
39013
  }
38867
39014
  /**
38868
39015
  * Final review bridge I-1 (ledger T25): the agent reply's U11 capture, taken BEFORE the publish await. The clear generation
@@ -38940,14 +39087,14 @@ var BridgeAgent = class _BridgeAgent {
38940
39087
  const k = replyIntentKey({ inReplyTo: opts.inReplyTo, idempotencyKey: opts.idempotencyKey, ordinal: () => 0 });
38941
39088
  if (!k) throw new Error("idempotent send without a key");
38942
39089
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
38943
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
38944
- const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
39090
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit);
39091
+ const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null });
38945
39092
  if (intent.bodyMac !== mac2 || intent.channelId !== channelId || intent.state === "skipped") {
38946
39093
  throw new IdempotencyConflictError();
38947
39094
  }
38948
39095
  return k;
38949
39096
  }
38950
- return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true);
39097
+ return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null);
38951
39098
  });
38952
39099
  return this.publishIntent(key, opts);
38953
39100
  }
@@ -38956,22 +39103,23 @@ var BridgeAgent = class _BridgeAgent {
38956
39103
  * the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
38957
39104
  * after a slot collision: the body must go to a NEW part.
38958
39105
  */
38959
- async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry) {
39106
+ async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit) {
39107
+ const credit = replyCredit ?? void 0;
38960
39108
  const scope = `reply:${inReplyTo}`;
38961
39109
  let latest = null;
38962
39110
  for (const intent of await this.store.listReplyIntents(inReplyTo)) {
38963
39111
  const n = intent.scope === scope ? partOrdinal(intent.part) : null;
38964
39112
  if (n !== null && (latest === null || n > latest.ordinal)) latest = { ordinal: n, intent };
38965
39113
  }
38966
- if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes)) {
39114
+ if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit)) {
38967
39115
  return { scope, part: latest.intent.part, inReplyTo };
38968
39116
  }
38969
39117
  let ordinal = latest ? latest.ordinal + 1 : 0;
38970
39118
  for (let i = 0; i < REPLY_MAX_PART_ADVANCES; i++, ordinal++) {
38971
39119
  const k = { scope, part: `p:${ordinal}`, inReplyTo };
38972
39120
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
38973
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
38974
- const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
39121
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit);
39122
+ const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit });
38975
39123
  if (created) return k;
38976
39124
  if (matchRetry && intent.state !== "skipped" && intent.bodyMac === mac2 && intent.channelId === channelId) return k;
38977
39125
  writeServiceLog2("info", "reply.part_slot_taken", { attempt: i + 1 });
@@ -39016,6 +39164,12 @@ var BridgeAgent = class _BridgeAgent {
39016
39164
  }
39017
39165
  const bytes = intent.body;
39018
39166
  const body = decodeReplyBody(bytes);
39167
+ const recordText = this.recordTextOf(intent.replyCredit, body);
39168
+ if (recordText === void 0) {
39169
+ writeServiceLog2("error", "reply.intent_integrity_refused");
39170
+ this.store.deletePendingReplyIntent(key.scope, key.part);
39171
+ throw new ReplyIntentIntegrityError();
39172
+ }
39019
39173
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(intent.channelId);
39020
39174
  const replyCapture = this.unifiedReplyCapture(intent.channelId, intent.inReplyTo ?? void 0);
39021
39175
  try {
@@ -39044,14 +39198,17 @@ var BridgeAgent = class _BridgeAgent {
39044
39198
  const inReplyTo = intent.inReplyTo;
39045
39199
  const next = await this.withReplyLock(
39046
39200
  JSON.stringify(["scope", key.scope]),
39047
- () => this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false)
39201
+ () => (
39202
+ // r2 BLOCKER 1: the colliding row's OWN credit (verified above), never `opts`.
39203
+ this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null)
39204
+ )
39048
39205
  );
39049
39206
  return this.publishIntent(next, opts, advances + 1);
39050
39207
  }
39051
39208
  throw e;
39052
39209
  }
39053
39210
  this.store.markReplyIntentSent(key.scope, key.part);
39054
- this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture);
39211
+ this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture, recordText);
39055
39212
  return { message_id: intent.replyMessageId, status: "sent" };
39056
39213
  });
39057
39214
  }
@@ -39061,9 +39218,21 @@ var BridgeAgent = class _BridgeAgent {
39061
39218
  */
39062
39219
  intentVerifies(intent) {
39063
39220
  if (!intent.body) return false;
39221
+ const credit = intent.replyCredit ?? null;
39222
+ if (credit !== null && !isReplyCredit(credit)) return false;
39064
39223
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, intent.channelId, intent);
39065
39224
  if (id !== intent.replyMessageId) return false;
39066
- return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body) === intent.bodyMac;
39225
+ return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0) === intent.bodyMac;
39226
+ }
39227
+ /**
39228
+ * Weather/currency Task 17: what a verified row's reply is RECORDED as. Null credit: the decoded text unchanged (null
39229
+ * for an a2a body). "weatherapi": the text minus the exact credit suffix. Undefined: the row is refused (an unknown
39230
+ * credit, a credited a2a body, or a credited text without the exact suffix).
39231
+ */
39232
+ recordTextOf(credit, body) {
39233
+ if (credit === null) return body.kind === "text" ? body.text : null;
39234
+ if (!isReplyCredit(credit) || body.kind !== "text" || !body.text.endsWith(WEATHER_CREDIT_SUFFIX)) return void 0;
39235
+ return body.text.slice(0, body.text.length - WEATHER_CREDIT_SUFFIX.length);
39067
39236
  }
39068
39237
  /**
39069
39238
  * SEC-06 #18 item 3: a rate-limited reply retries the PUBLISH in-process
@@ -43187,7 +43356,7 @@ var BridgeAgent = class _BridgeAgent {
43187
43356
  this.memory,
43188
43357
  m.channelId,
43189
43358
  m.envelope.sender_id,
43190
- parsed.text,
43359
+ m.envelope.sender_id === this.cfg.identityId ? stripCreditSuffix(parsed.text) : parsed.text,
43191
43360
  m.messageId,
43192
43361
  m.voiceMode ? "voice" : "text",
43193
43362
  purgeEpochAtTap
@@ -43985,21 +44154,49 @@ ${channelId}`;
43985
44154
  */
43986
44155
  searchProvidersSockets = /* @__PURE__ */ new Map();
43987
44156
  /**
43988
- * The D28 value the files were last recomputed for; null until the first socket observation, so that observation
43989
- * always recomputes (final review I-1), even when it is non-capable.
44157
+ * Weather/currency U6 (H-1): the same AND-over-sockets state for utility_tools_v1, kept separately per socket, so a
44158
+ * runtime that declares search_providers_v2 but not utility_tools_v1 (0.14.0, 0.14.1) keeps web_extract and never
44159
+ * sees a utility name.
43990
44160
  */
43991
- searchProvidersState = null;
44161
+ utilityToolsSockets = /* @__PURE__ */ new Map();
44162
+ /**
44163
+ * The (search, utility) tuple the files were last recomputed for; null until the first socket observation, so that
44164
+ * observation always recomputes (final review I-1), even when it is non-capable. One trigger keyed on BOTH values.
44165
+ */
44166
+ capabilityState = null;
43992
44167
  searchCapabilityRefetchTimer = null;
43993
44168
  /** When the current burst's first change happened (valid while the timer is set). */
43994
44169
  searchCapabilityBurstStart = 0;
43995
44170
  /** The D28 value in the last report posted (null until one is); a change needs a refetch to reach the web. */
43996
44171
  lastReportedSearchProviders = null;
44172
+ /** U6: the utility_tools_v1 value in the last report posted (null until one is). */
44173
+ lastReportedUtilityTools = null;
43997
44174
  noteSearchProvidersSocket(token, declared) {
43998
44175
  this.searchProvidersSockets.set(token, declared);
43999
- this.onSearchProvidersMaybeChanged();
44176
+ this.onCapabilityMaybeChanged();
44000
44177
  }
44001
44178
  clearSearchProvidersSocket(token) {
44002
- if (this.searchProvidersSockets.delete(token)) this.onSearchProvidersMaybeChanged();
44179
+ if (this.searchProvidersSockets.delete(token)) this.onCapabilityMaybeChanged();
44180
+ }
44181
+ noteUtilityToolsSocket(token, declared) {
44182
+ this.utilityToolsSockets.set(token, declared);
44183
+ this.onCapabilityMaybeChanged();
44184
+ }
44185
+ clearUtilityToolsSocket(token) {
44186
+ if (this.utilityToolsSockets.delete(token)) this.onCapabilityMaybeChanged();
44187
+ }
44188
+ /** U6: every attached runtime socket declared utility_tools_v1; no socket attached means absent (false). */
44189
+ utilityToolsCapable() {
44190
+ if (this.utilityToolsSockets.size === 0) return false;
44191
+ for (const v of this.utilityToolsSockets.values()) if (!v) return false;
44192
+ return true;
44193
+ }
44194
+ /** U6: the features every attached runtime socket declared, for the policy functions. */
44195
+ capabilityFeatures() {
44196
+ const out = /* @__PURE__ */ new Set();
44197
+ if (this.searchProvidersCapable()) out.add("search_providers_v2");
44198
+ if (this.utilityToolsCapable()) out.add("utility_tools_v1");
44199
+ return out;
44003
44200
  }
44004
44201
  /** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
44005
44202
  searchProvidersCapable() {
@@ -44026,14 +44223,14 @@ ${channelId}`;
44026
44223
  * D35 (P-4): on a capability change, rewrite manifest-policy.json at once from the stored manifest, the kill
44027
44224
  * switches and the new capability, then a debounced refetch (one per burst) only when needed.
44028
44225
  */
44029
- onSearchProvidersMaybeChanged() {
44030
- const capable = this.searchProvidersCapable();
44031
- const prev = this.searchProvidersState;
44032
- if (capable === prev) return;
44033
- this.searchProvidersState = capable;
44034
- this.recomputeManifestPolicy(capable);
44226
+ onCapabilityMaybeChanged() {
44227
+ const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable() };
44228
+ const prev = this.capabilityState;
44229
+ if (prev !== null && prev.search === next.search && prev.utility === next.utility) return;
44230
+ this.capabilityState = next;
44231
+ this.recomputeManifestPolicy(this.capabilityFeatures());
44035
44232
  if (this.closingDown) return;
44036
- if (prev === null && !capable) return;
44233
+ if (prev === null && !next.search && !next.utility) return;
44037
44234
  const now = Date.now();
44038
44235
  if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
44039
44236
  else this.searchCapabilityBurstStart = now;
@@ -44051,7 +44248,7 @@ ${channelId}`;
44051
44248
  searchCapabilityRefetchNeeded() {
44052
44249
  if (!this.bootConfigSettled || this.closingDown) return false;
44053
44250
  const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
44054
- return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable();
44251
+ return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable();
44055
44252
  }
44056
44253
  /**
44057
44254
  * D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
@@ -44059,15 +44256,16 @@ ${channelId}`;
44059
44256
  * lists web_extract loses it at once, so an older runtime never reads it as unreadable (which drops every grant).
44060
44257
  * Content-free; a write failure is logged and the debounced refetch tries again.
44061
44258
  */
44062
- recomputeManifestPolicy(capable) {
44259
+ recomputeManifestPolicy(features) {
44063
44260
  const pin = this.cfg.ownerGrantPin?.pin ?? null;
44064
44261
  const manifest = pin !== null ? this.toolGrantManifest : null;
44065
44262
  try {
44066
44263
  if (manifest !== null && this.manifestBinding !== null && this.manifestBinding.counter === manifest.counter) {
44067
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, capable));
44264
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, features));
44068
44265
  return;
44069
44266
  }
44070
- if (capable) return;
44267
+ const withheld = new Set(Object.keys(TOOL_FEATURE).filter((t) => !toolAllowedByFeatures(t, features)));
44268
+ if (withheld.size === 0) return;
44071
44269
  const path = join17(runtimeConfigDir(this.home, this.cfg.identityId), MANIFEST_POLICY_FILE);
44072
44270
  if (!existsSync5(path)) return;
44073
44271
  let doc;
@@ -44077,10 +44275,10 @@ ${channelId}`;
44077
44275
  return;
44078
44276
  }
44079
44277
  const tools = doc?.manifest_tools;
44080
- if (!Array.isArray(tools) || !tools.includes("web_extract")) return;
44278
+ if (!Array.isArray(tools) || !tools.some((t) => typeof t === "string" && withheld.has(t))) return;
44081
44279
  this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, {
44082
44280
  v: 1,
44083
- manifest_tools: tools.filter((t) => typeof t === "string" && t !== "web_extract"),
44281
+ manifest_tools: tools.filter((t) => typeof t === "string" && !withheld.has(t)),
44084
44282
  secret_path_mode: "enforce"
44085
44283
  });
44086
44284
  } catch (e) {
@@ -44617,6 +44815,9 @@ var BRIDGE_ERRORS = {
44617
44815
  // C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
44618
44816
  // session (WS 4409), so this copy stopped. Every local route but /healthz.
44619
44817
  session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
44818
+ // Weather/currency Task 17 (OQ-P1): a send frame's credit is not the closed value, or rides an a2a frame. TERMINAL:
44819
+ // the inbound is retired, so the model (and the weather call) never runs again for it.
44820
+ reply_credit_unknown: { http: 400, message: "reply credit not recognised; not sent" },
44620
44821
  internal: { http: 500, message: "internal error" }
44621
44822
  };
44622
44823
  function errorBody(code, detail) {
@@ -44646,6 +44847,7 @@ function withConsumerGrants(m, perToolConsumer) {
44646
44847
  }
44647
44848
  var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
44648
44849
  var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
44850
+ var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
44649
44851
  var CONSTANT_TOOL_NAMES = Object.freeze({
44650
44852
  owner_dm: Object.freeze(["current_time", "web_search"]),
44651
44853
  team_room: Object.freeze(["current_time"])
@@ -44677,6 +44879,15 @@ function withSearchCapability(m, consumerHasFeature, signedProvider) {
44677
44879
  if (kept.length === a.tool_grants.length) return m;
44678
44880
  return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
44679
44881
  }
44882
+ var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
44883
+ function withUtilityCapability(m, consumerHasFeature) {
44884
+ if (consumerHasFeature) return m;
44885
+ const a = m.authorization;
44886
+ if (!a) return m;
44887
+ const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
44888
+ if (kept.length === a.tool_grants.length) return m;
44889
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
44890
+ }
44680
44891
 
44681
44892
  // src/http-server.ts
44682
44893
  function writeSse(res, msgs, onWriteError) {
@@ -44700,7 +44911,7 @@ data: ${JSON.stringify(msgs[i])}
44700
44911
  function deliverSseBatch(res, batch, agent, onWriteError) {
44701
44912
  const withheld = agent.harnessGrantsWithheld();
44702
44913
  const provider = agent.signedSearchProvider();
44703
- const left3 = writeSse(res, batch.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)), onWriteError);
44914
+ const left3 = writeSse(res, batch.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)), onWriteError);
44704
44915
  const leftIds = new Set(left3.map((message) => message.message_id));
44705
44916
  agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
44706
44917
  if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
@@ -44732,7 +44943,7 @@ var OTHER_CAP = 8 * 1024;
44732
44943
  var a2aSchema = z3.object({
44733
44944
  a2a_version: z3.string().min(1),
44734
44945
  kind: z3.enum(["message", "task", "artifact"]),
44735
- object: z3.record(z3.unknown())
44946
+ object: z3.record(z3.string(), z3.unknown())
44736
44947
  });
44737
44948
  var sendAttachmentSchema = z3.object({
44738
44949
  channel_id: z3.string().min(1),
@@ -44974,7 +45185,7 @@ function createHttpServer(agent, opts) {
44974
45185
  const drained = await agent.drain(channel, wait);
44975
45186
  const withheld = agent.harnessGrantsWithheld();
44976
45187
  const provider = agent.signedSearchProvider();
44977
- return json(res, 200, drained.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)));
45188
+ return json(res, 200, drained.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)));
44978
45189
  }
44979
45190
  if (method === "GET" && path === "/v1/events") {
44980
45191
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -45149,6 +45360,7 @@ function attachAgentWebSocket(server, agent, opts) {
45149
45360
  perToolGrants: false,
45150
45361
  manifestGrants: false,
45151
45362
  searchProviders: false,
45363
+ utilityTools: false,
45152
45364
  // helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
45153
45365
  deliveryOpen: helloWaitMs <= 0,
45154
45366
  deliveryToken: /* @__PURE__ */ new Map(),
@@ -45202,14 +45414,14 @@ function attachAgentWebSocket(server, agent, opts) {
45202
45414
  continue;
45203
45415
  }
45204
45416
  const out = withConsumerNotes(
45205
- withSearchCapability(
45417
+ withUtilityCapability(withSearchCapability(
45206
45418
  withManifestCapability(
45207
45419
  withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
45208
45420
  session.manifestGrants
45209
45421
  ),
45210
45422
  session.searchProviders,
45211
45423
  agent.signedSearchProvider()
45212
- ),
45424
+ ), session.utilityTools),
45213
45425
  batch[i],
45214
45426
  session,
45215
45427
  agent
@@ -45266,6 +45478,7 @@ function attachAgentWebSocket(server, agent, opts) {
45266
45478
  if (session.perToolGrants) agent.clearPerToolConsumer(session);
45267
45479
  if (session.manifestGrants) agent.clearManifestConsumer(session);
45268
45480
  agent.clearSearchProvidersSocket(session);
45481
+ agent.clearUtilityToolsSocket(session);
45269
45482
  };
45270
45483
  current = { sock, cleanup, sweepStale };
45271
45484
  sock.on("close", cleanup);
@@ -45306,7 +45519,10 @@ function sendJson(sock, body) {
45306
45519
  } catch {
45307
45520
  }
45308
45521
  }
45522
+ var ReplyCreditUnknownError = class extends Error {
45523
+ };
45309
45524
  function sendErrorCode(e) {
45525
+ if (e instanceof ReplyCreditUnknownError) return "reply_credit_unknown";
45310
45526
  if (e instanceof ChannelPendingError) return "channel_pending";
45311
45527
  if (e instanceof RateLimitedError) return "rate_limited";
45312
45528
  if (e instanceof AgentPausedSendError) return "agent_paused";
@@ -45367,6 +45583,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45367
45583
  agent.noteConsumerCapability(manifest ? "ok" : session.harnessClaimed ? "harness_consumer" : "missing");
45368
45584
  session.searchProviders = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(SEARCH_PROVIDERS_FEATURE);
45369
45585
  agent.noteSearchProvidersSocket(session, session.searchProviders);
45586
+ session.utilityTools = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(UTILITY_TOOLS_FEATURE);
45587
+ agent.noteUtilityToolsSocket(session, session.utilityTools);
45370
45588
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
45371
45589
  agent.noteNotesConsumer();
45372
45590
  }
@@ -45374,7 +45592,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45374
45592
  if (session.recallConsumer) agent.noteRecallConsumer();
45375
45593
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
45376
45594
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
45377
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE] });
45595
+ sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE, REPLY_CREDIT_FEATURE] });
45378
45596
  if (!session.deliveryOpen) {
45379
45597
  session.deliveryOpen = true;
45380
45598
  flush();
@@ -45403,7 +45621,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45403
45621
  }
45404
45622
  const replyFailed = (e, inReplyTo) => {
45405
45623
  const code = sendErrorCode(e);
45406
- if ((code === "agent_paused" || code === "send_forbidden") && typeof inReplyTo === "string") {
45624
+ const terminal = code === "agent_paused" || code === "send_forbidden" || code === "reply_credit_unknown";
45625
+ if (terminal && typeof inReplyTo === "string") {
45407
45626
  inflight.delete(inReplyTo);
45408
45627
  session.deliveryToken.delete(inReplyTo);
45409
45628
  session.shownRecall.delete(inReplyTo);
@@ -45411,7 +45630,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45411
45630
  agent.ackRuntimeMessages([inReplyTo]);
45412
45631
  }
45413
45632
  sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
45414
- if (code === "agent_paused" || code === "send_forbidden") flush();
45633
+ if (terminal) flush();
45415
45634
  };
45416
45635
  if (m.type === "send") {
45417
45636
  if (typeof m.channel_id !== "string" || !m.channel_id) {
@@ -45428,8 +45647,14 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45428
45647
  }
45429
45648
  const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
45430
45649
  const callBufferReply = session.consumer === "runtime" && session.perToolGrants === true && session.harnessClaimed !== true && typeof m.in_reply_to === "string" && record !== void 0 && session.deliveryToken.get(m.in_reply_to) !== void 0 && record.channel_id === m.channel_id && !agent.isRuntimeMessageRetired(m.in_reply_to);
45650
+ if (m.credit !== void 0 && (m.a2a !== void 0 || !isReplyCredit(m.credit))) {
45651
+ writeServiceLog2("error", "reply.credit_unknown");
45652
+ replyFailed(new ReplyCreditUnknownError(), m.in_reply_to);
45653
+ return;
45654
+ }
45431
45655
  const sendOpts = {
45432
45656
  agentTurn: isAgentTurn(agent, m),
45657
+ ...isReplyCredit(m.credit) ? { credit: m.credit } : {},
45433
45658
  ...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
45434
45659
  ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
45435
45660
  ...callBufferReply ? { callBufferReply: true } : {}