@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/bin.js CHANGED
@@ -19331,6 +19331,8 @@ function isEpochConflict(e) {
19331
19331
  var LABEL = "agentvault-connect-v1";
19332
19332
  var DEFAULT_STUCK_THRESHOLD = 2;
19333
19333
  var DEFAULT_QUARANTINE_THRESHOLD = 8;
19334
+ var KEPT_WELCOME_COMMIT_DEFER_MS = 24 * 60 * 60 * 1e3;
19335
+ var KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS = 32;
19334
19336
  var DELIVERY_PULL_PAGE = 100;
19335
19337
  var DELIVERY_MAX_RETRIES = 5;
19336
19338
  var DrainPageFullError = class extends Error {
@@ -19535,6 +19537,15 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19535
19537
  * {@link errorStats}; mutated only through {@link recordDecryptError} /
19536
19538
  * {@link recordDecryptSuccess}.
19537
19539
  */
19540
+ /** {@link KEPT_WELCOME_COMMIT_DEFER_MS}: channel -> when its first commit was
19541
+ * deferred behind the kept Welcome `welcomeId`. Restarted for a different
19542
+ * Welcome; cleared when the channel decrypts, or when a pass that saw the
19543
+ * whole queue kept no Welcome for it (fix round 1, L2: the Welcome
19544
+ * resolved, so a later re-Welcome gets a fresh, bounded window). An entry
19545
+ * past its bound stays (expired) until then, so the bound cannot restart
19546
+ * itself pass after pass. In memory: a reload restarts it, and the
19547
+ * per-pass row cap is what bounds the page cost across reloads. */
19548
+ keptWelcomeDeferSince = /* @__PURE__ */ new Map();
19538
19549
  errors = /* @__PURE__ */ new Map();
19539
19550
  stuckThreshold;
19540
19551
  /** Consecutive-error threshold at which a wedged channel's row is quarantined
@@ -19753,8 +19764,33 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
19753
19764
  * channel resumes normal processing and can quarantine again later if it
19754
19765
  * re-wedges (the onWedged guard is reset so the hook can fire again).
19755
19766
  */
19767
+ /** See {@link MlsGroupKeyProviderOpts.deferCommitsWhileWelcomePending}. The
19768
+ * caller has already established that this pass KEPT a no-state Welcome
19769
+ * for `channelId`. True: defer the commit row. Never throws. */
19770
+ deferKeptWelcomeCommit(channelId, welcomeId) {
19771
+ const pred = this.o.deferCommitsWhileWelcomePending;
19772
+ if (!pred)
19773
+ return false;
19774
+ let boundMs = 0;
19775
+ try {
19776
+ const v = pred(channelId);
19777
+ 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;
19778
+ } catch {
19779
+ boundMs = 0;
19780
+ }
19781
+ if (boundMs <= 0)
19782
+ return false;
19783
+ const now = this.nowMs();
19784
+ const entry = this.keptWelcomeDeferSince.get(channelId);
19785
+ if (entry === void 0 || entry.welcomeId !== welcomeId) {
19786
+ this.keptWelcomeDeferSince.set(channelId, { since: now, welcomeId });
19787
+ return true;
19788
+ }
19789
+ return now - entry.since < boundMs;
19790
+ }
19756
19791
  recordDecryptSuccess(channelId) {
19757
19792
  this.lastDecryptAt.set(channelId, Date.now());
19793
+ this.keptWelcomeDeferSince.delete(channelId);
19758
19794
  const e = this.errors.get(channelId);
19759
19795
  if (!e)
19760
19796
  return;
@@ -20150,6 +20186,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20150
20186
  * server's REJOIN_THRESHOLD, there was nothing to apply, or a commit would
20151
20187
  * not apply (which means the state is genuinely FORKED, not merely behind,
20152
20188
  * and replay cannot fix a fork).
20189
+ *
20190
+ * THROWS (retryable, never `false`, #1290): CustodyPersistError when the
20191
+ * channel is under the custody hold (nothing applied), and the C2 classes
20192
+ * (PendingCommitUnresolvedError from the guarded MAIN write) after evicting
20193
+ * the advanced cache, so the persisted state is unchanged. A /sync request
20194
+ * that fails also throws, before anything is applied.
20153
20195
  */
20154
20196
  async resyncByReplay(channelId, groupId) {
20155
20197
  if (!await this.hasPersistedChannelState(channelId))
@@ -20169,12 +20211,26 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20169
20211
  let applied = 0;
20170
20212
  try {
20171
20213
  await this.locks.run(channelId, async () => {
20214
+ if (await this.custodyHeld(channelId))
20215
+ throw new CustodyPersistError(channelId);
20172
20216
  const mgr = await this.manager(channelId);
20173
20217
  try {
20174
20218
  for (const c of commits) {
20175
20219
  if (c.epoch <= Number(mgr.epoch))
20176
20220
  continue;
20177
- await mgr.decrypt(hexToBytes22(c.payload));
20221
+ const commitBytes = hexToBytes22(c.payload);
20222
+ try {
20223
+ await mgr.decrypt(commitBytes);
20224
+ } catch (e) {
20225
+ if (await probeOwnRemoval(mgr, commitBytes) !== null) {
20226
+ this.noteOwnLeafRemoved(channelId, {
20227
+ group_id: groupId,
20228
+ epoch: c.epoch,
20229
+ message_id: c.id
20230
+ });
20231
+ }
20232
+ throw e;
20233
+ }
20178
20234
  applied++;
20179
20235
  }
20180
20236
  if (applied > 0)
@@ -20186,6 +20242,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
20186
20242
  }
20187
20243
  });
20188
20244
  } catch (e) {
20245
+ if (isSdkError(e, "CustodyPersistError") || e instanceof CustodyPersistError || e instanceof PendingCommitUnresolvedError || e instanceof GroupSupersededError || e instanceof AttestedOrphanRefusedError) {
20246
+ writeServiceLog2("warn", "resync.replay_held", { applied }, e);
20247
+ throw e;
20248
+ }
20189
20249
  writeServiceLog2("warn", "resync.replay_commit_failed", { applied }, e);
20190
20250
  return false;
20191
20251
  }
@@ -22886,6 +22946,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
22886
22946
  out.push({
22887
22947
  queueId: `replay-${m.id}`,
22888
22948
  messageId: envelope.message_id,
22949
+ // #1246 C-1: the group-messages row id is the same mls_messages key.
22950
+ ...typeof m.id === "string" && m.id ? { serverMessageId: m.id } : {},
22889
22951
  channelId,
22890
22952
  plaintext,
22891
22953
  envelope,
@@ -23786,6 +23848,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23786
23848
  const heldBundleRefs = () => heldBundleRefsMemo ??= this.readHeldBundleRefs();
23787
23849
  const w2Acked = /* @__PURE__ */ new Map();
23788
23850
  const w2Kept = /* @__PURE__ */ new Set();
23851
+ const keptWelcomeId = /* @__PURE__ */ new Map();
23852
+ const deferredThisPass = /* @__PURE__ */ new Map();
23789
23853
  const unpinnedRefusals = /* @__PURE__ */ new Map();
23790
23854
  const welcomes = rows.filter((r) => r.message_type === "welcome");
23791
23855
  const nonWelcomes = rows.filter((r) => r.message_type !== "welcome");
@@ -23944,6 +24008,8 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23944
24008
  writeServiceLog2("warn", "welcome.unopenable");
23945
24009
  } else {
23946
24010
  w2Kept.add(wcid);
24011
+ if (!keptWelcomeId.has(wcid))
24012
+ keptWelcomeId.set(wcid, row.queue_id);
23947
24013
  if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
23948
24014
  writeServiceLog2("debug", "delivery.welcome_no_local_state");
23949
24015
  }
@@ -23954,6 +24020,10 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
23954
24020
  const cid = row.connect_channel_id;
23955
24021
  if (!cid)
23956
24022
  continue;
24023
+ if (w2Kept.has(cid) && (deferredThisPass.get(cid) ?? 0) < KEPT_WELCOME_COMMIT_DEFER_MAX_ROWS && this.deferKeptWelcomeCommit(cid, keptWelcomeId.get(cid) ?? "")) {
24024
+ deferredThisPass.set(cid, (deferredThisPass.get(cid) ?? 0) + 1);
24025
+ continue;
24026
+ }
23957
24027
  let advancedEpoch;
23958
24028
  let custodyHeld = false;
23959
24029
  await this.locks.run(cid, async () => {
@@ -24110,6 +24180,12 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
24110
24180
  this.leftStreak.set(cid, { pulls: 1, since: pullNow });
24111
24181
  this.scheduleAutoResolve(cid);
24112
24182
  }
24183
+ if (rows.length < DELIVERY_PULL_PAGE) {
24184
+ for (const cid of [...this.keptWelcomeDeferSince.keys()]) {
24185
+ if (!w2Kept.has(cid))
24186
+ this.keptWelcomeDeferSince.delete(cid);
24187
+ }
24188
+ }
24113
24189
  for (const [cid, gid] of w2Acked) {
24114
24190
  if (w2Kept.has(cid))
24115
24191
  continue;
@@ -24389,7 +24465,9 @@ var MlsGroupKeyProvider = class _MlsGroupKeyProvider {
24389
24465
  voiceDefaultResponder: row.voice_default_responder ?? void 0,
24390
24466
  ...typeof row.identity_signature === "string" && row.identity_signature ? { identitySignature: row.identity_signature } : {},
24391
24467
  payloadHex: row.payload,
24392
- openedAt
24468
+ openedAt,
24469
+ // #1246 C-1: the server row id, beside the sender-chosen envelope id.
24470
+ ...typeof row.message_id === "string" && row.message_id ? { serverMessageId: row.message_id } : {}
24393
24471
  };
24394
24472
  const custodiedAt = this.nowMs();
24395
24473
  this.custody.set(row.queue_id, entry);
@@ -26911,6 +26989,11 @@ var FakeConnectBackend = class _FakeConnectBackend {
26911
26989
  // Owner-signed tool grants (spec §7.0): like Plan 0's backend, each row carries
26912
26990
  // the envelope's `identity_proof.signature` as `identity_signature` beside the
26913
26991
  // unsigned canonical payload. `omitSignature` models a pre-Plan-0 row.
26992
+ /** `rowMessageId` (#1246 C-1): the server row id to deliver, when it is NOT
26993
+ * the envelope's id. The REST send path keys the row by the envelope id
26994
+ * (connect_messages.py, `MLSMessage(id=message_id)`); the WS `message_mls`
26995
+ * path stores through `store_mls_message`, whose row id is server-generated,
26996
+ * so there the two differ and the envelope id is the sender's free choice. */
26914
26997
  async ingestApplication(envelope, opts) {
26915
26998
  const channelId = envelope.channel_id;
26916
26999
  const canonical = await canonicalEnvelopeBytes(envelope);
@@ -26922,7 +27005,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
26922
27005
  continue;
26923
27006
  this.q(dev).push({
26924
27007
  queue_id: "q-" + this._seq++,
26925
- message_id: envelope.message_id,
27008
+ message_id: opts?.rowMessageId ?? envelope.message_id,
26926
27009
  group_id: groupId,
26927
27010
  message_type: "application",
26928
27011
  sender_device_id: senderDeviceId ?? "",
@@ -27306,6 +27389,7 @@ function openSchema(db) {
27306
27389
  ensureRuntimeInboxDeliveryAttempts(db);
27307
27390
  ensureRuntimeReplyIntent(db);
27308
27391
  ensureRuntimeReplyIntentEnvelopeGroup(db);
27392
+ ensureRuntimeReplyIntentCredit(db);
27309
27393
  ensureGroupLink(db);
27310
27394
  ensureMlsCustody(db);
27311
27395
  ensureToolGrantTables(db);
@@ -27386,6 +27470,19 @@ function ensureRuntimeReplyIntentEnvelopeGroup(db) {
27386
27470
  throw e;
27387
27471
  }
27388
27472
  }
27473
+ function ensureRuntimeReplyIntentCredit(db) {
27474
+ const hasColumn = () => db.prepare("PRAGMA table_info(runtime_reply_intent)").all().some((c) => c.name === "reply_credit");
27475
+ const cols = db.prepare("PRAGMA table_info(runtime_reply_intent)").all();
27476
+ if (cols.length === 0 || cols.some((c) => c.name === "reply_credit"))
27477
+ return;
27478
+ try {
27479
+ db.exec("ALTER TABLE runtime_reply_intent ADD COLUMN reply_credit TEXT");
27480
+ } catch (e) {
27481
+ if (hasColumn())
27482
+ return;
27483
+ throw e;
27484
+ }
27485
+ }
27389
27486
  var RECALL_STATE_COLUMNS = [
27390
27487
  ["recall_enabled", "INTEGER NOT NULL DEFAULT 0"],
27391
27488
  ["recall_mode_epoch", "INTEGER NOT NULL DEFAULT 0"],
@@ -28437,8 +28534,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28437
28534
  this.db.prepare("DELETE FROM runtime_reply_intent WHERE identity_id=? AND updated_at < ?").run(this.id, now - RUNTIME_REPLY_INTENT_RETENTION_MS);
28438
28535
  const info = this.db.prepare(`INSERT OR IGNORE INTO runtime_reply_intent
28439
28536
  (identity_id, scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28440
- sealed_body, sealed_envelope, state, created_at, updated_at)
28441
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now);
28537
+ sealed_body, sealed_envelope, state, created_at, updated_at, reply_credit)
28538
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?, ?, ?)`).run(this.id, input.scope, input.part, input.inReplyTo, input.channelId, input.replyMessageId, input.bodyMac, sealed, now, now, input.replyCredit ?? null);
28442
28539
  const count = this.db.prepare("SELECT COUNT(*) AS n FROM runtime_reply_intent WHERE identity_id=?").get(this.id);
28443
28540
  if (count.n > RUNTIME_REPLY_INTENT_MAX) {
28444
28541
  this.db.prepare(`DELETE FROM runtime_reply_intent
@@ -28448,7 +28545,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28448
28545
  )`).run(this.id, this.id, RUNTIME_REPLY_INTENT_MAX);
28449
28546
  }
28450
28547
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28451
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
28548
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
28452
28549
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, input.scope, input.part);
28453
28550
  if (!row)
28454
28551
  throw new Error("reply intent vanished after claim");
@@ -28457,7 +28554,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28457
28554
  /** SEC-06 #18: every intent answering `inReplyTo`, in claim order. */
28458
28555
  async listReplyIntents(inReplyTo) {
28459
28556
  const rows = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28460
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
28557
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
28461
28558
  FROM runtime_reply_intent WHERE identity_id=? AND in_reply_to=?
28462
28559
  ORDER BY created_at ASC, rowid ASC`).all(this.id, inReplyTo);
28463
28560
  const out = [];
@@ -28468,7 +28565,7 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28468
28565
  /** SEC-06 #18: re-read one intent by its key (null when absent). */
28469
28566
  async getReplyIntent(scope, part) {
28470
28567
  const row = this.db.prepare(`SELECT scope, part, in_reply_to, channel_id, reply_message_id, body_mac,
28471
- sealed_body, sealed_envelope, sealed_envelope_group, state, created_at
28568
+ sealed_body, sealed_envelope, sealed_envelope_group, state, created_at, reply_credit
28472
28569
  FROM runtime_reply_intent WHERE identity_id=? AND scope=? AND part=?`).get(this.id, scope, part);
28473
28570
  return row ? this.openReplyIntent(row) : null;
28474
28571
  }
@@ -28542,7 +28639,8 @@ var SqliteMlsStateStore = class _SqliteMlsStateStore {
28542
28639
  envelope,
28543
28640
  envelopeGroupId: envelopeGroupFor(envelope, await openOrNull(row.sealed_envelope_group)),
28544
28641
  state: row.state === "sent" ? "sent" : row.state === "skipped" ? "skipped" : "pending",
28545
- createdAt: Number(row.created_at)
28642
+ createdAt: Number(row.created_at),
28643
+ replyCredit: typeof row.reply_credit === "string" ? row.reply_credit : null
28546
28644
  };
28547
28645
  }
28548
28646
  async recordMemory(input) {
@@ -30312,8 +30410,8 @@ function decodeReplyBody(bytes) {
30312
30410
  if (b?.kind === "a2a" && b.a2a && typeof b.a2a === "object") return b;
30313
30411
  throw new Error("reply intent body is malformed");
30314
30412
  }
30315
- function replyBodyMac(key, replyId, channelId, body) {
30316
- return createHmac("sha256", key).update(JSON.stringify(["reply-body", replyId, channelId])).update("\0").update(body).digest("hex");
30413
+ function replyBodyMac(key, replyId, channelId, body, credit) {
30414
+ 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");
30317
30415
  }
30318
30416
  function partOrdinal(part) {
30319
30417
  const m = /^p:(\d+)$/.exec(part);
@@ -35154,6 +35252,8 @@ function checkConfiguredPaths(skillsJson, statFn = (p) => {
35154
35252
  }
35155
35253
  const search = skillsJson.search;
35156
35254
  if (search?.keyFile) paths.push(search.keyFile);
35255
+ const weather = skillsJson.weather;
35256
+ if (weather?.keyFile) paths.push(weather.keyFile);
35157
35257
  return paths.map((path) => {
35158
35258
  let st;
35159
35259
  try {
@@ -35179,7 +35279,7 @@ function parseSecretPathsReport(raw) {
35179
35279
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
35180
35280
  const r = raw;
35181
35281
  const f = r.outside_by_field;
35182
- 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;
35282
+ 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;
35183
35283
  return {
35184
35284
  mode: r.mode,
35185
35285
  inside: r.inside,
@@ -35189,7 +35289,8 @@ function parseSecretPathsReport(raw) {
35189
35289
  headers_file: f.headers_file,
35190
35290
  postgres_url_file: f.postgres_url_file,
35191
35291
  search_key_file: f.search_key_file,
35192
- ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
35292
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {},
35293
+ ...f.weather_key_file !== void 0 ? { weather_key_file: f.weather_key_file } : {}
35193
35294
  }
35194
35295
  };
35195
35296
  }
@@ -35269,6 +35370,7 @@ function readLocalReport(home, identityId) {
35269
35370
  has_search_key: hasSearchKey,
35270
35371
  has_llm_key: hasLlmKey,
35271
35372
  has_extract_provider: doc.has_extract_provider === true,
35373
+ has_weather_key: doc.has_weather_key === true,
35272
35374
  ...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
35273
35375
  legacy_memory: localLegacySources(doc).map(({ path: _path, content: _content, ...source }) => source),
35274
35376
  secret_paths: parseSecretPathsReport(doc.secret_paths)
@@ -35370,6 +35472,19 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
35370
35472
  return removed;
35371
35473
  }
35372
35474
 
35475
+ // src/reply-credit.ts
35476
+ var REPLY_CREDIT_FEATURE = "reply_credit_v1";
35477
+ var WEATHER_CREDIT_LINE = "Weather data: WeatherAPI.com. For general information only.";
35478
+ var WEATHER_CREDIT_SUFFIX = `
35479
+
35480
+ ${WEATHER_CREDIT_LINE}`;
35481
+ function isReplyCredit(v) {
35482
+ return v === "weatherapi";
35483
+ }
35484
+ function stripCreditSuffix(text) {
35485
+ return text.endsWith(WEATHER_CREDIT_SUFFIX) ? text.slice(0, text.length - WEATHER_CREDIT_SUFFIX.length) : text;
35486
+ }
35487
+
35373
35488
  // src/grants/binding.ts
35374
35489
  import { sha256 as sha25611 } from "@noble/hashes/sha256";
35375
35490
 
@@ -35377,9 +35492,10 @@ import { sha256 as sha25611 } from "@noble/hashes/sha256";
35377
35492
  import { ed25519 as ed255195 } from "@noble/curves/ed25519";
35378
35493
  var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
35379
35494
  var MANIFEST_KNOWN_TOOLS = Object.freeze({
35380
- owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query"]),
35495
+ owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query", "weather_forecast", "currency_convert"]),
35381
35496
  // web_extract: owner DM only, never team_room (spec §5.4, D5)
35382
- team_room: Object.freeze(["web_search"])
35497
+ // Weather/currency spec §6.3 (U5): both utility tools in both audiences, each held behind utility_tools_v1 (TOOL_FEATURE).
35498
+ team_room: Object.freeze(["web_search", "weather_forecast", "currency_convert"])
35383
35499
  });
35384
35500
  function manifestSignedBytes(manifest) {
35385
35501
  const domain = new TextEncoder().encode(TOOL_GRANTS_DOMAIN);
@@ -35703,20 +35819,30 @@ function bindTargets(raw, signed, equals) {
35703
35819
  }
35704
35820
  return { written, mismatches };
35705
35821
  }
35706
- function signedManifestTools(manifest, audience, searchProvidersCapable) {
35707
- return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && (t !== "web_extract" || searchProvidersCapable));
35822
+ var NO_FEATURES = Object.freeze(/* @__PURE__ */ new Set());
35823
+ var TOOL_FEATURE = Object.freeze({
35824
+ web_extract: "search_providers_v2",
35825
+ weather_forecast: "utility_tools_v1",
35826
+ currency_convert: "utility_tools_v1"
35827
+ });
35828
+ function toolAllowedByFeatures(tool, features) {
35829
+ if (!Object.hasOwn(TOOL_FEATURE, tool)) return true;
35830
+ return features.has(TOOL_FEATURE[tool]);
35831
+ }
35832
+ function signedManifestTools(manifest, audience, features) {
35833
+ return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && toolAllowedByFeatures(t, features));
35708
35834
  }
35709
- function manifestPolicyFor(manifest, switches, searchProvidersCapable) {
35835
+ function manifestPolicyFor(manifest, switches, features) {
35710
35836
  return {
35711
35837
  v: 1,
35712
35838
  manifest_tools: [.../* @__PURE__ */ new Set([
35713
- ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", searchProvidersCapable) : [],
35714
- ...switches.room ? signedManifestTools(manifest, "team_room", searchProvidersCapable) : []
35839
+ ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", features) : [],
35840
+ ...switches.room ? signedManifestTools(manifest, "team_room", features) : []
35715
35841
  ])].sort(),
35716
35842
  secret_path_mode: "enforce"
35717
35843
  };
35718
35844
  }
35719
- function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, searchProvidersCapable = false) {
35845
+ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, features = NO_FEATURES) {
35720
35846
  const mismatches = /* @__PURE__ */ new Set();
35721
35847
  const cfg = manifest.config;
35722
35848
  const out = {};
@@ -35745,7 +35871,8 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35745
35871
  for (const name of Object.keys(files)) {
35746
35872
  if (name !== LLM_SOURCE && !PERSONA_FILES.includes(name)) mismatches.add("files");
35747
35873
  }
35748
- const ownerDmTools = signedManifestTools(manifest, "owner_dm", searchProvidersCapable);
35874
+ const ownerDmTools = signedManifestTools(manifest, "owner_dm", features);
35875
+ const ownerDmSearchTools = ownerDmTools.filter((t) => t === "web_search" || t === "web_extract");
35749
35876
  const sj = isObj2(payload.skills_json) ? payload.skills_json : void 0;
35750
35877
  const eps = bindTargets(sj?.endpoints, manifest.endpoints, endpointEquals);
35751
35878
  const dbs = bindTargets(sj?.databases, manifest.databases, databaseEquals);
@@ -35766,17 +35893,20 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35766
35893
  const usablePrior = cfg.search !== void 0 && prior.search?.provider === cfg.search.provider ? prior.search : void 0;
35767
35894
  searchBlock = cfg.search === void 0 ? void 0 : usablePrior ?? Object.freeze({ provider: cfg.search.provider });
35768
35895
  }
35896
+ const pw = isObj2(sj?.weather) ? sj.weather : void 0;
35897
+ const weatherBlock = typeof pw?.keyFile === "string" && pw.keyFile !== "" ? { keyFile: pw.keyFile } : void 0;
35769
35898
  const skillsDoc = {
35770
35899
  endpoints: eps.written,
35771
35900
  databases: dbs.written,
35772
35901
  flags: [...cfg.flags],
35773
35902
  ...deny !== void 0 || cfg.tools_allow !== void 0 ? { tools: { ...deny !== void 0 ? { deny } : {}, ...cfg.tools_allow !== void 0 ? { allow: [...cfg.tools_allow] } : {} } } : {},
35774
35903
  ...searchBlock ? { search: searchBlock } : {},
35904
+ ...weatherBlock ? { weather: weatherBlock } : {},
35775
35905
  // D31: the marker the runtime keys D24 (iii) on; written under every manifest, true when config.search is signed.
35776
35906
  search_signed: cfg.search !== void 0,
35777
35907
  // D36, D39: the signed owner-DM cap, only when the owner-DM switch is on and the manifest grants an owner-DM tool;
35778
35908
  // otherwise none, so the runtime clamps a signed raise to the default. Never read from the payload.
35779
- ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
35909
+ ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmSearchTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
35780
35910
  };
35781
35911
  out["skills.json"] = JSON.stringify(skillsDoc, null, 2) + "\n";
35782
35912
  const signedMemory = { mode: cfg.memory.mode, ...cfg.memory.recall_days !== void 0 ? { recall_days: cfg.memory.recall_days } : {} };
@@ -35807,7 +35937,7 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
35807
35937
  mismatches.add("legacy_assign");
35808
35938
  legacyDecisions = decisions.filter((d) => isObj2(d) && d.action === "delete");
35809
35939
  }
35810
- const policy = manifestPolicyFor(manifest, switches, searchProvidersCapable);
35940
+ const policy = manifestPolicyFor(manifest, switches, features);
35811
35941
  return {
35812
35942
  mismatches: CONFIG_BINDING_FIELDS.filter((f) => mismatches.has(f)).sort(),
35813
35943
  targetMismatches: { endpoint: eps.mismatches, database: dbs.mismatches },
@@ -35950,7 +36080,11 @@ var TOOL_ROOM_CLASSES = Object.freeze({
35950
36080
  // D5: spends the owner's key and sends URLs out
35951
36081
  fetch_url: Object.freeze(["egress"]),
35952
36082
  sql_query: Object.freeze(["owner_credential"]),
35953
- http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
36083
+ http_request: Object.freeze(["owner_credential", "egress", "side_effect"]),
36084
+ // Weather/currency (plan audit r1; supersedes spec §8's values): both send room words to an outside host, and weather
36085
+ // spends the owner's key.
36086
+ weather_forecast: Object.freeze(["owner_credential", "egress"]),
36087
+ currency_convert: Object.freeze(["egress"])
35954
36088
  });
35955
36089
  var LIVE_MEMBER_STATUSES = /* @__PURE__ */ new Set(["active", "add_pending"]);
35956
36090
  function isLiveRow(r) {
@@ -37764,7 +37898,7 @@ var BridgeAgent = class _BridgeAgent {
37764
37898
  }
37765
37899
  let bound;
37766
37900
  try {
37767
- bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable());
37901
+ bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures());
37768
37902
  } catch (e) {
37769
37903
  bound = null;
37770
37904
  return this.bindFailed(manifest, payload.memory, e);
@@ -37785,7 +37919,7 @@ var BridgeAgent = class _BridgeAgent {
37785
37919
  try {
37786
37920
  this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, bound.files);
37787
37921
  if (manifest.config.llm === void 0) this.grantWriters.removeManagedLlmSource(this.home, this.cfg.identityId);
37788
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable()));
37922
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.capabilityFeatures()));
37789
37923
  } catch (e) {
37790
37924
  this.manifestBinding = null;
37791
37925
  throw e;
@@ -37839,6 +37973,7 @@ var BridgeAgent = class _BridgeAgent {
37839
37973
  async postRuntimeConfigReport(base, version, skillsJson) {
37840
37974
  const local = readLocalReport(this.home, this.cfg.identityId);
37841
37975
  const reportedSearchProviders = this.searchProvidersCapable();
37976
+ const reportedUtilityTools = this.utilityToolsCapable();
37842
37977
  const report = {
37843
37978
  applied_version: version,
37844
37979
  paths: checkConfiguredPaths(skillsJson),
@@ -37856,6 +37991,9 @@ var BridgeAgent = class _BridgeAgent {
37856
37991
  // Final review I-2 (controller ruling): AND the current search_providers_v2, so the web never shows extract while
37857
37992
  // the bridge withholds web_extract from the runtime.
37858
37993
  has_extract_provider: (local?.has_extract_provider ?? false) && reportedSearchProviders,
37994
+ // Weather/currency L-1, §8 item 5: AND the current utility_tools_v1, so the web never shows a usable weather key
37995
+ // while the bridge withholds weather_forecast from the runtime.
37996
+ has_weather_key: (local?.has_weather_key ?? false) && reportedUtilityTools,
37859
37997
  // D39: the owner-DM daily cap the runtime enforces, passed through when it reported a valid one; never the signed value.
37860
37998
  ...local?.owner_dm_search_cap_effective !== void 0 ? { owner_dm_search_cap_effective: local.owner_dm_search_cap_effective } : {},
37861
37999
  // Same idea, for the BYOK LLM key file: only the runtime can see
@@ -37903,7 +38041,9 @@ var BridgeAgent = class _BridgeAgent {
37903
38041
  // D28 (§11.1 step 3): a separate key, never a value in the capability enum. False with no runtime attached.
37904
38042
  search_providers_v2: reportedSearchProviders,
37905
38043
  // D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
37906
- search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders)
38044
+ search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders),
38045
+ // Weather/currency §8 item 5: a separate key, false with no runtime attached.
38046
+ utility_tools_v1: reportedUtilityTools
37907
38047
  }
37908
38048
  };
37909
38049
  const reportedVerifyFailures = this.ownerVerifyFailures;
@@ -37925,6 +38065,7 @@ var BridgeAgent = class _BridgeAgent {
37925
38065
  }
37926
38066
  this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
37927
38067
  this.lastReportedSearchProviders = reportedSearchProviders;
38068
+ this.lastReportedUtilityTools = reportedUtilityTools;
37928
38069
  if (version) writeServiceLog("info", "runtime_config.applied", { version });
37929
38070
  else writeServiceLog("info", "runtime_config.legacy_memory_reported");
37930
38071
  }
@@ -37994,7 +38135,7 @@ var BridgeAgent = class _BridgeAgent {
37994
38135
  this.startedAt = Date.now();
37995
38136
  await this.restoreRuntimeInbox();
37996
38137
  await this.loadToolGrantState();
37997
- this.recomputeManifestPolicy(false);
38138
+ this.recomputeManifestPolicy(NO_FEATURES);
37998
38139
  try {
37999
38140
  this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
38000
38141
  } catch (e) {
@@ -38977,13 +39118,13 @@ var BridgeAgent = class _BridgeAgent {
38977
39118
  * absent, because the id is derived.
38978
39119
  */
38979
39120
  async send(channelId, text, messageId, opts = {}) {
38980
- const body = { kind: "text", text };
39121
+ const body = { kind: "text", text: opts.credit !== void 0 ? text + WEATHER_CREDIT_SUFFIX : text };
38981
39122
  if (this.isIdempotentSend(messageId, opts)) return this.trackReplyWork(this.sendIdempotent(channelId, body, opts));
38982
39123
  const id = resolveMessageId(messageId);
38983
39124
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
38984
39125
  const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
38985
39126
  await this.publishBody(channelId, body, id);
38986
- this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
39127
+ this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, text);
38987
39128
  return { message_id: id, status: "sent" };
38988
39129
  }
38989
39130
  /** PR #716 review MEDIUM-5: register reply work so close() can wait for it. */
@@ -39002,7 +39143,7 @@ var BridgeAgent = class _BridgeAgent {
39002
39143
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(channelId);
39003
39144
  const replyCapture = this.unifiedReplyCapture(channelId, opts.inReplyTo);
39004
39145
  await this.publishBody(channelId, body, id);
39005
- this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture);
39146
+ this.afterPublish(channelId, body, id, opts, purgeEpochBeforePublish, replyCapture, null);
39006
39147
  return { message_id: id, status: "sent" };
39007
39148
  }
39008
39149
  /**
@@ -39135,18 +39276,24 @@ var BridgeAgent = class _BridgeAgent {
39135
39276
  const meta = this.freshMeta(capChannel);
39136
39277
  if (meta && !meta.is_dm) this.enforceRateFloor("room", capChannel);
39137
39278
  }
39138
- /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
39139
- afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture) {
39279
+ /**
39280
+ * Bookkeeping after a CONFIRMED publish of `body` under `id`. `recordText` (weather/currency Task 17) is what a text
39281
+ * reply is RECORDED as: the published text minus any credit line. Every record site below reads `recorded`, never
39282
+ * `body.text`. Every production caller passes it (pinned by reply-credit.test.ts: text, or null for an a2a body);
39283
+ * undefined is left only for direct test calls of an uncredited body, where the published and record texts are equal.
39284
+ */
39285
+ afterPublish(channelId, body, id, opts, purgeEpoch, replyCapture, recordText) {
39286
+ const recorded = recordText ?? (recordText === void 0 && body.kind === "text" ? body.text : "");
39140
39287
  if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
39141
39288
  const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
39142
- const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(body.text);
39289
+ const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(recorded);
39143
39290
  if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
39144
- if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(body.text) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
39145
- this.callBuffer.appendAgent(channelId, body.text, "reply");
39291
+ if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(recorded) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
39292
+ this.callBuffer.appendAgent(channelId, recorded, "reply");
39146
39293
  }
39147
39294
  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";
39148
- if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch, origin);
39149
- if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null, origin);
39295
+ if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, recorded, id, modality, purgeEpoch, origin);
39296
+ if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, recorded, id, replyCapture ?? null, origin);
39150
39297
  }
39151
39298
  /**
39152
39299
  * Final review bridge I-1 (ledger T25): the agent reply's U11 capture, taken BEFORE the publish await. The clear generation
@@ -39224,14 +39371,14 @@ var BridgeAgent = class _BridgeAgent {
39224
39371
  const k = replyIntentKey({ inReplyTo: opts.inReplyTo, idempotencyKey: opts.idempotencyKey, ordinal: () => 0 });
39225
39372
  if (!k) throw new Error("idempotent send without a key");
39226
39373
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
39227
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
39228
- const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
39374
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, opts.credit);
39375
+ const { intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit: opts.credit ?? null });
39229
39376
  if (intent.bodyMac !== mac2 || intent.channelId !== channelId || intent.state === "skipped") {
39230
39377
  throw new IdempotencyConflictError();
39231
39378
  }
39232
39379
  return k;
39233
39380
  }
39234
- return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true);
39381
+ return this.claimUnkeyedPart(opts.inReplyTo, channelId, bytes, true, opts.credit ?? null);
39235
39382
  });
39236
39383
  return this.publishIntent(key, opts);
39237
39384
  }
@@ -39240,22 +39387,23 @@ var BridgeAgent = class _BridgeAgent {
39240
39387
  * the reply to `inReplyTo`. Caller holds the scope lock. `matchRetry` false
39241
39388
  * after a slot collision: the body must go to a NEW part.
39242
39389
  */
39243
- async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry) {
39390
+ async claimUnkeyedPart(inReplyTo, channelId, bytes, matchRetry, replyCredit) {
39391
+ const credit = replyCredit ?? void 0;
39244
39392
  const scope = `reply:${inReplyTo}`;
39245
39393
  let latest = null;
39246
39394
  for (const intent of await this.store.listReplyIntents(inReplyTo)) {
39247
39395
  const n = intent.scope === scope ? partOrdinal(intent.part) : null;
39248
39396
  if (n !== null && (latest === null || n > latest.ordinal)) latest = { ordinal: n, intent };
39249
39397
  }
39250
- if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes)) {
39398
+ if (matchRetry && latest && latest.intent.state !== "skipped" && latest.intent.channelId === channelId && latest.intent.bodyMac === replyBodyMac(this.replyIdKey, latest.intent.replyMessageId, channelId, bytes, credit)) {
39251
39399
  return { scope, part: latest.intent.part, inReplyTo };
39252
39400
  }
39253
39401
  let ordinal = latest ? latest.ordinal + 1 : 0;
39254
39402
  for (let i = 0; i < REPLY_MAX_PART_ADVANCES; i++, ordinal++) {
39255
39403
  const k = { scope, part: `p:${ordinal}`, inReplyTo };
39256
39404
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, channelId, k);
39257
- const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes);
39258
- const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes });
39405
+ const mac2 = replyBodyMac(this.replyIdKey, id, channelId, bytes, credit);
39406
+ const { created, intent } = await this.store.claimReplyIntent({ ...k, channelId, replyMessageId: id, bodyMac: mac2, body: bytes, replyCredit });
39259
39407
  if (created) return k;
39260
39408
  if (matchRetry && intent.state !== "skipped" && intent.bodyMac === mac2 && intent.channelId === channelId) return k;
39261
39409
  writeServiceLog("info", "reply.part_slot_taken", { attempt: i + 1 });
@@ -39300,6 +39448,12 @@ var BridgeAgent = class _BridgeAgent {
39300
39448
  }
39301
39449
  const bytes = intent.body;
39302
39450
  const body = decodeReplyBody(bytes);
39451
+ const recordText = this.recordTextOf(intent.replyCredit, body);
39452
+ if (recordText === void 0) {
39453
+ writeServiceLog("error", "reply.intent_integrity_refused");
39454
+ this.store.deletePendingReplyIntent(key.scope, key.part);
39455
+ throw new ReplyIntentIntegrityError();
39456
+ }
39303
39457
  const purgeEpochBeforePublish = this.store.conversationPurgeEpoch(intent.channelId);
39304
39458
  const replyCapture = this.unifiedReplyCapture(intent.channelId, intent.inReplyTo ?? void 0);
39305
39459
  try {
@@ -39328,14 +39482,17 @@ var BridgeAgent = class _BridgeAgent {
39328
39482
  const inReplyTo = intent.inReplyTo;
39329
39483
  const next = await this.withReplyLock(
39330
39484
  JSON.stringify(["scope", key.scope]),
39331
- () => this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false)
39485
+ () => (
39486
+ // r2 BLOCKER 1: the colliding row's OWN credit (verified above), never `opts`.
39487
+ this.claimUnkeyedPart(inReplyTo, intent.channelId, bytes, false, isReplyCredit(intent.replyCredit) ? intent.replyCredit : null)
39488
+ )
39332
39489
  );
39333
39490
  return this.publishIntent(next, opts, advances + 1);
39334
39491
  }
39335
39492
  throw e;
39336
39493
  }
39337
39494
  this.store.markReplyIntentSent(key.scope, key.part);
39338
- this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture);
39495
+ this.afterPublish(intent.channelId, body, intent.replyMessageId, opts, purgeEpochBeforePublish, replyCapture, recordText);
39339
39496
  return { message_id: intent.replyMessageId, status: "sent" };
39340
39497
  });
39341
39498
  }
@@ -39345,9 +39502,21 @@ var BridgeAgent = class _BridgeAgent {
39345
39502
  */
39346
39503
  intentVerifies(intent) {
39347
39504
  if (!intent.body) return false;
39505
+ const credit = intent.replyCredit ?? null;
39506
+ if (credit !== null && !isReplyCredit(credit)) return false;
39348
39507
  const id = replyMessageId(this.replyIdKey, this.cfg.identityId, intent.channelId, intent);
39349
39508
  if (id !== intent.replyMessageId) return false;
39350
- return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body) === intent.bodyMac;
39509
+ return replyBodyMac(this.replyIdKey, intent.replyMessageId, intent.channelId, intent.body, credit ?? void 0) === intent.bodyMac;
39510
+ }
39511
+ /**
39512
+ * Weather/currency Task 17: what a verified row's reply is RECORDED as. Null credit: the decoded text unchanged (null
39513
+ * for an a2a body). "weatherapi": the text minus the exact credit suffix. Undefined: the row is refused (an unknown
39514
+ * credit, a credited a2a body, or a credited text without the exact suffix).
39515
+ */
39516
+ recordTextOf(credit, body) {
39517
+ if (credit === null) return body.kind === "text" ? body.text : null;
39518
+ if (!isReplyCredit(credit) || body.kind !== "text" || !body.text.endsWith(WEATHER_CREDIT_SUFFIX)) return void 0;
39519
+ return body.text.slice(0, body.text.length - WEATHER_CREDIT_SUFFIX.length);
39351
39520
  }
39352
39521
  /**
39353
39522
  * SEC-06 #18 item 3: a rate-limited reply retries the PUBLISH in-process
@@ -43471,7 +43640,7 @@ var BridgeAgent = class _BridgeAgent {
43471
43640
  this.memory,
43472
43641
  m.channelId,
43473
43642
  m.envelope.sender_id,
43474
- parsed.text,
43643
+ m.envelope.sender_id === this.cfg.identityId ? stripCreditSuffix(parsed.text) : parsed.text,
43475
43644
  m.messageId,
43476
43645
  m.voiceMode ? "voice" : "text",
43477
43646
  purgeEpochAtTap
@@ -44269,21 +44438,49 @@ ${channelId}`;
44269
44438
  */
44270
44439
  searchProvidersSockets = /* @__PURE__ */ new Map();
44271
44440
  /**
44272
- * The D28 value the files were last recomputed for; null until the first socket observation, so that observation
44273
- * always recomputes (final review I-1), even when it is non-capable.
44441
+ * Weather/currency U6 (H-1): the same AND-over-sockets state for utility_tools_v1, kept separately per socket, so a
44442
+ * runtime that declares search_providers_v2 but not utility_tools_v1 (0.14.0, 0.14.1) keeps web_extract and never
44443
+ * sees a utility name.
44274
44444
  */
44275
- searchProvidersState = null;
44445
+ utilityToolsSockets = /* @__PURE__ */ new Map();
44446
+ /**
44447
+ * The (search, utility) tuple the files were last recomputed for; null until the first socket observation, so that
44448
+ * observation always recomputes (final review I-1), even when it is non-capable. One trigger keyed on BOTH values.
44449
+ */
44450
+ capabilityState = null;
44276
44451
  searchCapabilityRefetchTimer = null;
44277
44452
  /** When the current burst's first change happened (valid while the timer is set). */
44278
44453
  searchCapabilityBurstStart = 0;
44279
44454
  /** The D28 value in the last report posted (null until one is); a change needs a refetch to reach the web. */
44280
44455
  lastReportedSearchProviders = null;
44456
+ /** U6: the utility_tools_v1 value in the last report posted (null until one is). */
44457
+ lastReportedUtilityTools = null;
44281
44458
  noteSearchProvidersSocket(token, declared) {
44282
44459
  this.searchProvidersSockets.set(token, declared);
44283
- this.onSearchProvidersMaybeChanged();
44460
+ this.onCapabilityMaybeChanged();
44284
44461
  }
44285
44462
  clearSearchProvidersSocket(token) {
44286
- if (this.searchProvidersSockets.delete(token)) this.onSearchProvidersMaybeChanged();
44463
+ if (this.searchProvidersSockets.delete(token)) this.onCapabilityMaybeChanged();
44464
+ }
44465
+ noteUtilityToolsSocket(token, declared) {
44466
+ this.utilityToolsSockets.set(token, declared);
44467
+ this.onCapabilityMaybeChanged();
44468
+ }
44469
+ clearUtilityToolsSocket(token) {
44470
+ if (this.utilityToolsSockets.delete(token)) this.onCapabilityMaybeChanged();
44471
+ }
44472
+ /** U6: every attached runtime socket declared utility_tools_v1; no socket attached means absent (false). */
44473
+ utilityToolsCapable() {
44474
+ if (this.utilityToolsSockets.size === 0) return false;
44475
+ for (const v of this.utilityToolsSockets.values()) if (!v) return false;
44476
+ return true;
44477
+ }
44478
+ /** U6: the features every attached runtime socket declared, for the policy functions. */
44479
+ capabilityFeatures() {
44480
+ const out = /* @__PURE__ */ new Set();
44481
+ if (this.searchProvidersCapable()) out.add("search_providers_v2");
44482
+ if (this.utilityToolsCapable()) out.add("utility_tools_v1");
44483
+ return out;
44287
44484
  }
44288
44485
  /** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
44289
44486
  searchProvidersCapable() {
@@ -44310,14 +44507,14 @@ ${channelId}`;
44310
44507
  * D35 (P-4): on a capability change, rewrite manifest-policy.json at once from the stored manifest, the kill
44311
44508
  * switches and the new capability, then a debounced refetch (one per burst) only when needed.
44312
44509
  */
44313
- onSearchProvidersMaybeChanged() {
44314
- const capable = this.searchProvidersCapable();
44315
- const prev = this.searchProvidersState;
44316
- if (capable === prev) return;
44317
- this.searchProvidersState = capable;
44318
- this.recomputeManifestPolicy(capable);
44510
+ onCapabilityMaybeChanged() {
44511
+ const next = { search: this.searchProvidersCapable(), utility: this.utilityToolsCapable() };
44512
+ const prev = this.capabilityState;
44513
+ if (prev !== null && prev.search === next.search && prev.utility === next.utility) return;
44514
+ this.capabilityState = next;
44515
+ this.recomputeManifestPolicy(this.capabilityFeatures());
44319
44516
  if (this.closingDown) return;
44320
- if (prev === null && !capable) return;
44517
+ if (prev === null && !next.search && !next.utility) return;
44321
44518
  const now = Date.now();
44322
44519
  if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
44323
44520
  else this.searchCapabilityBurstStart = now;
@@ -44335,7 +44532,7 @@ ${channelId}`;
44335
44532
  searchCapabilityRefetchNeeded() {
44336
44533
  if (!this.bootConfigSettled || this.closingDown) return false;
44337
44534
  const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
44338
- return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable();
44535
+ return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable() || this.lastReportedUtilityTools !== this.utilityToolsCapable();
44339
44536
  }
44340
44537
  /**
44341
44538
  * D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
@@ -44343,15 +44540,16 @@ ${channelId}`;
44343
44540
  * lists web_extract loses it at once, so an older runtime never reads it as unreadable (which drops every grant).
44344
44541
  * Content-free; a write failure is logged and the debounced refetch tries again.
44345
44542
  */
44346
- recomputeManifestPolicy(capable) {
44543
+ recomputeManifestPolicy(features) {
44347
44544
  const pin = this.cfg.ownerGrantPin?.pin ?? null;
44348
44545
  const manifest = pin !== null ? this.toolGrantManifest : null;
44349
44546
  try {
44350
44547
  if (manifest !== null && this.manifestBinding !== null && this.manifestBinding.counter === manifest.counter) {
44351
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, capable));
44548
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, features));
44352
44549
  return;
44353
44550
  }
44354
- if (capable) return;
44551
+ const withheld = new Set(Object.keys(TOOL_FEATURE).filter((t) => !toolAllowedByFeatures(t, features)));
44552
+ if (withheld.size === 0) return;
44355
44553
  const path = join19(runtimeConfigDir(this.home, this.cfg.identityId), MANIFEST_POLICY_FILE);
44356
44554
  if (!existsSync5(path)) return;
44357
44555
  let doc;
@@ -44361,10 +44559,10 @@ ${channelId}`;
44361
44559
  return;
44362
44560
  }
44363
44561
  const tools = doc?.manifest_tools;
44364
- if (!Array.isArray(tools) || !tools.includes("web_extract")) return;
44562
+ if (!Array.isArray(tools) || !tools.some((t) => typeof t === "string" && withheld.has(t))) return;
44365
44563
  this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, {
44366
44564
  v: 1,
44367
- manifest_tools: tools.filter((t) => typeof t === "string" && t !== "web_extract"),
44565
+ manifest_tools: tools.filter((t) => typeof t === "string" && !withheld.has(t)),
44368
44566
  secret_path_mode: "enforce"
44369
44567
  });
44370
44568
  } catch (e) {
@@ -44755,6 +44953,9 @@ var BRIDGE_ERRORS = {
44755
44953
  // C9 (spec §4.1.6 step 1): another copy of this agent took its SecureComs
44756
44954
  // session (WS 4409), so this copy stopped. Every local route but /healthz.
44757
44955
  session_superseded: { http: 503, message: "another copy of this agent took its session; this copy stopped" },
44956
+ // Weather/currency Task 17 (OQ-P1): a send frame's credit is not the closed value, or rides an a2a frame. TERMINAL:
44957
+ // the inbound is retired, so the model (and the weather call) never runs again for it.
44958
+ reply_credit_unknown: { http: 400, message: "reply credit not recognised; not sent" },
44758
44959
  internal: { http: 500, message: "internal error" }
44759
44960
  };
44760
44961
  function errorBody(code, detail) {
@@ -44784,6 +44985,7 @@ function withConsumerGrants(m, perToolConsumer) {
44784
44985
  }
44785
44986
  var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
44786
44987
  var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
44988
+ var UTILITY_TOOLS_FEATURE = "utility_tools_v1";
44787
44989
  var CONSTANT_TOOL_NAMES = Object.freeze({
44788
44990
  owner_dm: Object.freeze(["current_time", "web_search"]),
44789
44991
  team_room: Object.freeze(["current_time"])
@@ -44815,6 +45017,15 @@ function withSearchCapability(m, consumerHasFeature, signedProvider) {
44815
45017
  if (kept.length === a.tool_grants.length) return m;
44816
45018
  return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
44817
45019
  }
45020
+ var UTILITY_TOOL_NAMES = /* @__PURE__ */ new Set(["weather_forecast", "currency_convert"]);
45021
+ function withUtilityCapability(m, consumerHasFeature) {
45022
+ if (consumerHasFeature) return m;
45023
+ const a = m.authorization;
45024
+ if (!a) return m;
45025
+ const kept = a.tool_grants.filter((g) => !UTILITY_TOOL_NAMES.has(g.tool));
45026
+ if (kept.length === a.tool_grants.length) return m;
45027
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
45028
+ }
44818
45029
 
44819
45030
  // src/http-server.ts
44820
45031
  function writeSse(res, msgs, onWriteError) {
@@ -44838,7 +45049,7 @@ data: ${JSON.stringify(msgs[i])}
44838
45049
  function deliverSseBatch(res, batch, agent, onWriteError) {
44839
45050
  const withheld = agent.harnessGrantsWithheld();
44840
45051
  const provider = agent.signedSearchProvider();
44841
- const left3 = writeSse(res, batch.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)), onWriteError);
45052
+ const left3 = writeSse(res, batch.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)), onWriteError);
44842
45053
  const leftIds = new Set(left3.map((message) => message.message_id));
44843
45054
  agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
44844
45055
  if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
@@ -44870,7 +45081,7 @@ var OTHER_CAP = 8 * 1024;
44870
45081
  var a2aSchema = z3.object({
44871
45082
  a2a_version: z3.string().min(1),
44872
45083
  kind: z3.enum(["message", "task", "artifact"]),
44873
- object: z3.record(z3.unknown())
45084
+ object: z3.record(z3.string(), z3.unknown())
44874
45085
  });
44875
45086
  var sendAttachmentSchema = z3.object({
44876
45087
  channel_id: z3.string().min(1),
@@ -45112,7 +45323,7 @@ function createHttpServer(agent, opts) {
45112
45323
  const drained = await agent.drain(channel, wait);
45113
45324
  const withheld = agent.harnessGrantsWithheld();
45114
45325
  const provider = agent.signedSearchProvider();
45115
- return json(res, 200, drained.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)));
45326
+ return json(res, 200, drained.map((m) => withUtilityCapability(withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider), false)));
45116
45327
  }
45117
45328
  if (method === "GET" && path === "/v1/events") {
45118
45329
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -45287,6 +45498,7 @@ function attachAgentWebSocket(server, agent, opts) {
45287
45498
  perToolGrants: false,
45288
45499
  manifestGrants: false,
45289
45500
  searchProviders: false,
45501
+ utilityTools: false,
45290
45502
  // helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
45291
45503
  deliveryOpen: helloWaitMs <= 0,
45292
45504
  deliveryToken: /* @__PURE__ */ new Map(),
@@ -45340,14 +45552,14 @@ function attachAgentWebSocket(server, agent, opts) {
45340
45552
  continue;
45341
45553
  }
45342
45554
  const out = withConsumerNotes(
45343
- withSearchCapability(
45555
+ withUtilityCapability(withSearchCapability(
45344
45556
  withManifestCapability(
45345
45557
  withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
45346
45558
  session.manifestGrants
45347
45559
  ),
45348
45560
  session.searchProviders,
45349
45561
  agent.signedSearchProvider()
45350
- ),
45562
+ ), session.utilityTools),
45351
45563
  batch[i],
45352
45564
  session,
45353
45565
  agent
@@ -45404,6 +45616,7 @@ function attachAgentWebSocket(server, agent, opts) {
45404
45616
  if (session.perToolGrants) agent.clearPerToolConsumer(session);
45405
45617
  if (session.manifestGrants) agent.clearManifestConsumer(session);
45406
45618
  agent.clearSearchProvidersSocket(session);
45619
+ agent.clearUtilityToolsSocket(session);
45407
45620
  };
45408
45621
  current = { sock, cleanup, sweepStale };
45409
45622
  sock.on("close", cleanup);
@@ -45444,7 +45657,10 @@ function sendJson(sock, body) {
45444
45657
  } catch {
45445
45658
  }
45446
45659
  }
45660
+ var ReplyCreditUnknownError = class extends Error {
45661
+ };
45447
45662
  function sendErrorCode(e) {
45663
+ if (e instanceof ReplyCreditUnknownError) return "reply_credit_unknown";
45448
45664
  if (e instanceof ChannelPendingError) return "channel_pending";
45449
45665
  if (e instanceof RateLimitedError) return "rate_limited";
45450
45666
  if (e instanceof AgentPausedSendError) return "agent_paused";
@@ -45505,6 +45721,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45505
45721
  agent.noteConsumerCapability(manifest ? "ok" : session.harnessClaimed ? "harness_consumer" : "missing");
45506
45722
  session.searchProviders = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(SEARCH_PROVIDERS_FEATURE);
45507
45723
  agent.noteSearchProvidersSocket(session, session.searchProviders);
45724
+ session.utilityTools = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(UTILITY_TOOLS_FEATURE);
45725
+ agent.noteUtilityToolsSocket(session, session.utilityTools);
45508
45726
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
45509
45727
  agent.noteNotesConsumer();
45510
45728
  }
@@ -45512,7 +45730,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45512
45730
  if (session.recallConsumer) agent.noteRecallConsumer();
45513
45731
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
45514
45732
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
45515
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE] });
45733
+ 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] });
45516
45734
  if (!session.deliveryOpen) {
45517
45735
  session.deliveryOpen = true;
45518
45736
  flush();
@@ -45541,7 +45759,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45541
45759
  }
45542
45760
  const replyFailed = (e, inReplyTo) => {
45543
45761
  const code = sendErrorCode(e);
45544
- if ((code === "agent_paused" || code === "send_forbidden") && typeof inReplyTo === "string") {
45762
+ const terminal = code === "agent_paused" || code === "send_forbidden" || code === "reply_credit_unknown";
45763
+ if (terminal && typeof inReplyTo === "string") {
45545
45764
  inflight.delete(inReplyTo);
45546
45765
  session.deliveryToken.delete(inReplyTo);
45547
45766
  session.shownRecall.delete(inReplyTo);
@@ -45549,7 +45768,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45549
45768
  agent.ackRuntimeMessages([inReplyTo]);
45550
45769
  }
45551
45770
  sendJson(sock, { ...wsErrorFrame(code), ...typeof inReplyTo === "string" ? { in_reply_to: inReplyTo } : {} });
45552
- if (code === "agent_paused" || code === "send_forbidden") flush();
45771
+ if (terminal) flush();
45553
45772
  };
45554
45773
  if (m.type === "send") {
45555
45774
  if (typeof m.channel_id !== "string" || !m.channel_id) {
@@ -45566,8 +45785,14 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
45566
45785
  }
45567
45786
  const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
45568
45787
  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);
45788
+ if (m.credit !== void 0 && (m.a2a !== void 0 || !isReplyCredit(m.credit))) {
45789
+ writeServiceLog("error", "reply.credit_unknown");
45790
+ replyFailed(new ReplyCreditUnknownError(), m.in_reply_to);
45791
+ return;
45792
+ }
45569
45793
  const sendOpts = {
45570
45794
  agentTurn: isAgentTurn(agent, m),
45795
+ ...isReplyCredit(m.credit) ? { credit: m.credit } : {},
45571
45796
  ...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
45572
45797
  ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
45573
45798
  ...callBufferReply ? { callBufferReply: true } : {}