@securecoms/agent-bridge 0.30.2 → 0.31.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
@@ -29101,7 +29101,7 @@ function loadPackageMeta(importMetaUrl) {
29101
29101
  }
29102
29102
 
29103
29103
  // src/bridge-agent.ts
29104
- import { existsSync as existsSync5, promises as fsp, statSync as statSync4 } from "node:fs";
29104
+ import { existsSync as existsSync5, promises as fsp, readFileSync as readFileSync10, statSync as statSync4 } from "node:fs";
29105
29105
  import { join as join17, dirname as dirname5 } from "node:path";
29106
29106
  import { fileURLToPath as fileURLToPath2 } from "node:url";
29107
29107
 
@@ -33431,7 +33431,8 @@ var FastLlmClient = class {
33431
33431
  try {
33432
33432
  res = await this.fetchImpl(`${this.cfg.baseUrl}${this.cfg.path ?? "/chat/completions"}`, {
33433
33433
  method: "POST",
33434
- headers: { "content-type": "application/json", ...this.key ? { authorization: `Bearer ${this.key}` } : {} },
33434
+ // Final fix T8: configured headers first, so they can never displace content-type or the bearer.
33435
+ headers: { ...this.cfg.headers, "content-type": "application/json", ...this.key ? { authorization: `Bearer ${this.key}` } : {} },
33435
33436
  body: JSON.stringify({ model: this.cfg.model, messages, max_tokens: this.cfg.maxTokens, temperature: 0.4 }),
33436
33437
  signal: ac.signal
33437
33438
  });
@@ -33635,7 +33636,9 @@ function settingsToVoiceConfig(s, proxy) {
33635
33636
  model: "securecoms-fast",
33636
33637
  apiKey: proxy.apiKey,
33637
33638
  timeoutMs: 2500,
33638
- maxTokens: 80
33639
+ maxTokens: 80,
33640
+ // Spec 7.2: tells the proxy this bridge scopes voice turns. Sent to the SecureComs proxy only.
33641
+ headers: { "x-securecoms-bridge-caps": "voice-scope-v1" }
33639
33642
  },
33640
33643
  settings: s
33641
33644
  };
@@ -33643,6 +33646,7 @@ function settingsToVoiceConfig(s, proxy) {
33643
33646
 
33644
33647
  // src/voice/persona.ts
33645
33648
  var CONTINUITY_CLAUSE = "The text messages in this chat and this voice call are one and the same ongoing conversation, and the earlier turns you can see ARE those messages. If asked whether you remember or received a message, answer from that history \u2014 never say you cannot access previous messages.";
33649
+ var VOICE_CALL_CLAUSE = "This is a live voice call. The earlier turns you can see are from this call only; during a call you cannot see the typed chat, saved notes or memory. If asked about a message, file or note you cannot see, say plainly that you can't see the chat while on a call and offer to pick it up in the chat afterwards. Never guess at what it said.";
33646
33650
  var schema = z2.object({
33647
33651
  persona: z2.object({
33648
33652
  display_name: z2.string().min(1).optional(),
@@ -33723,7 +33727,7 @@ function buildFastMessages(args2) {
33723
33727
  const sp = systemPrompt(args2.persona, args2.settings, args2.agentName);
33724
33728
  const head = sp.slice(0, sp.length - CONTINUITY_CLAUSE.length).trimEnd();
33725
33729
  const notesLine = args2.notesDeferLine ? ` ${FAST_NOTES_DEFER_LINE}` : "";
33726
- const system = `${head} ${args2.guidance.trim()}${notesLine} ${FAST_DEFER_RULE} ${CONTINUITY_CLAUSE}`;
33730
+ const system = `${head} ${args2.guidance.trim()}${notesLine} ${FAST_DEFER_RULE} ${VOICE_CALL_CLAUSE}`;
33727
33731
  const recent = args2.memory.recent.map((t) => ({
33728
33732
  role: t.role === "agent" ? "assistant" : "user",
33729
33733
  content: t.text
@@ -33760,6 +33764,160 @@ function normalizeFastReply(raw, maxChars = 200, maxSentences = 2) {
33760
33764
  return sentences.slice(0, maxSentences).join(" ").slice(0, maxChars);
33761
33765
  }
33762
33766
 
33767
+ // src/voice/voice-scope.ts
33768
+ function isVoiceScopedTurn(t) {
33769
+ return t.speaker !== void 0 || t.transcribed === true || t.senderIsRelay || t.text.trim() === VOICE_CALL_START_MARKER;
33770
+ }
33771
+ function admitsToCallBuffer(t) {
33772
+ return t.speaker !== void 0 || t.transcribed === true;
33773
+ }
33774
+
33775
+ // src/voice/call-buffer.ts
33776
+ var CALL_BUFFER_CAP = 50;
33777
+ var CALL_BUFFER_IDLE_MS = 30 * 6e4;
33778
+ var CALL_BUFFER_ID_CAP = 5e3;
33779
+ var CallBuffer = class {
33780
+ constructor(opts) {
33781
+ this.opts = opts;
33782
+ }
33783
+ channels = /* @__PURE__ */ new Map();
33784
+ /** messageId -> channelId, insertion ordered (oldest first) for the cap. */
33785
+ voiceIds = /* @__PURE__ */ new Map();
33786
+ /** messageId -> { channelId, the channel's bufSeq at the mark, the channel's generation then }. */
33787
+ seqNotes = /* @__PURE__ */ new Map();
33788
+ /**
33789
+ * Final fix I-1: messageId -> { channelId, the channel's bufSeq just before the turn's own peer entry }. A row the
33790
+ * backend redelivers (a failed ack, runtime_inbox.full backpressure) is ingested again; its id here means its text is
33791
+ * already in the call, so it is not appended again and its snapshot stops before its own entry (A-6).
33792
+ */
33793
+ admitted = /* @__PURE__ */ new Map();
33794
+ /** The last bufSeq handed out per channel (kept across resets, so the sequence never runs backwards). */
33795
+ lastSeq = /* @__PURE__ */ new Map();
33796
+ nextSeq = 1;
33797
+ /** Per-channel reset generation (advanced by every reset, the idle rule included). */
33798
+ gens = /* @__PURE__ */ new Map();
33799
+ /**
33800
+ * Append a spoken turn's text, once per turn id (final fix I-1). Returns false (and appends nothing) when the id was
33801
+ * already admitted in this call, or when the buffer is disabled.
33802
+ */
33803
+ appendPeer(channelId, text, messageId) {
33804
+ if (!this.opts.enabled) return false;
33805
+ if (this.admittedAt(messageId) !== void 0) return false;
33806
+ const before = this.seqAt(channelId);
33807
+ this.append(channelId, { role: "peer", text });
33808
+ this.admitted.set(messageId, { channelId, before });
33809
+ if (this.admitted.size > CALL_BUFFER_ID_CAP) this.admitted.delete(this.admitted.keys().next().value);
33810
+ return true;
33811
+ }
33812
+ /**
33813
+ * Final fix I-1: for a turn already admitted in this call, the channel's bufSeq just before its own entry (the end seq
33814
+ * of its first snapshot); undefined for a turn not admitted (or forgotten by a reset, the idle rule or the id cap).
33815
+ */
33816
+ admittedAt(messageId) {
33817
+ const a = this.admitted.get(messageId);
33818
+ if (a === void 0) return void 0;
33819
+ this.expireIfIdle(a.channelId);
33820
+ return this.admitted.get(messageId)?.before;
33821
+ }
33822
+ appendAgent(channelId, text, origin) {
33823
+ this.append(channelId, { role: "agent", text, origin });
33824
+ }
33825
+ /**
33826
+ * Record a voice-scoped turn's id (for reply admission only, A-4) and note the channel's seq at this point. Final fix
33827
+ * I-1: a turn already admitted (a redelivered row) keeps the seq of its first admission, so the note never moves past
33828
+ * the turn's own entry.
33829
+ */
33830
+ markVoiceScoped(channelId, messageId) {
33831
+ if (!this.opts.enabled) return;
33832
+ this.expireIfIdle(channelId);
33833
+ this.voiceIds.delete(messageId);
33834
+ this.voiceIds.set(messageId, channelId);
33835
+ if (this.voiceIds.size > CALL_BUFFER_ID_CAP) this.voiceIds.delete(this.voiceIds.keys().next().value);
33836
+ const seq = this.admittedAt(messageId) ?? this.seqAt(channelId);
33837
+ this.seqNotes.delete(messageId);
33838
+ this.seqNotes.set(messageId, { channelId, seq, generation: this.generation(channelId) });
33839
+ if (this.seqNotes.size > CALL_BUFFER_ID_CAP) this.seqNotes.delete(this.seqNotes.keys().next().value);
33840
+ }
33841
+ /** A-4: read ONLY by the bridge's `afterPublish` reply admission. */
33842
+ isVoiceScopedId(messageId) {
33843
+ const channelId = this.voiceIds.get(messageId);
33844
+ if (channelId === void 0) return false;
33845
+ this.expireIfIdle(channelId);
33846
+ return this.voiceIds.has(messageId);
33847
+ }
33848
+ /** The channel's mark noted when `messageId` was marked (the release re-read), or undefined (e.g. after a restart). */
33849
+ seqNoted(messageId) {
33850
+ const note = this.seqNotes.get(messageId);
33851
+ if (note === void 0) return void 0;
33852
+ this.expireIfIdle(note.channelId);
33853
+ const kept = this.seqNotes.get(messageId);
33854
+ return kept === void 0 ? void 0 : { seq: kept.seq, generation: kept.generation };
33855
+ }
33856
+ /** The bufSeq of the channel's newest entry (0 before any append). A snapshot's end seq. */
33857
+ seqAt(channelId) {
33858
+ this.expireIfIdle(channelId);
33859
+ return this.lastSeq.get(channelId) ?? 0;
33860
+ }
33861
+ /**
33862
+ * The channel's last `limit` entries, oldest first. With `upTo` (final fix I-1, a redelivered turn's re-snapshot),
33863
+ * only entries at or below that bufSeq.
33864
+ */
33865
+ recent(channelId, limit, upTo) {
33866
+ if (limit <= 0) return [];
33867
+ this.expireIfIdle(channelId);
33868
+ const all = this.channels.get(channelId)?.entries ?? [];
33869
+ const entries = upTo === void 0 ? all : all.filter((e) => e.bufSeq <= upTo);
33870
+ return entries.slice(-limit).map(deliver);
33871
+ }
33872
+ /**
33873
+ * The channel's AGENT entries appended after `since.seq` (lines spoken or answered since a snapshot), oldest first.
33874
+ * Final fix M-4: nothing when the channel has been reset since the mark (a later call's lines).
33875
+ */
33876
+ agentAfter(channelId, since) {
33877
+ if (this.generation(channelId) !== since.generation) return [];
33878
+ const entries = this.channels.get(channelId)?.entries ?? [];
33879
+ return entries.filter((e) => e.role === "agent" && e.bufSeq > since.seq).map(deliver);
33880
+ }
33881
+ /**
33882
+ * The channel's reset generation: it changes on every reset (explicit or idle) and on nothing else. A caller that
33883
+ * holds text across an await (the coalesce window) compares it to skip an append into a call that has since ended.
33884
+ */
33885
+ generation(channelId) {
33886
+ this.expireIfIdle(channelId);
33887
+ return this.gens.get(channelId) ?? 0;
33888
+ }
33889
+ /** Clear the channel: its entries, its voice-scoped ids, its seq notes and its admitted ids. Only ever removes. */
33890
+ reset(channelId) {
33891
+ this.gens.set(channelId, (this.gens.get(channelId) ?? 0) + 1);
33892
+ this.channels.delete(channelId);
33893
+ for (const [id, c] of this.voiceIds) if (c === channelId) this.voiceIds.delete(id);
33894
+ for (const [id, n] of this.seqNotes) if (n.channelId === channelId) this.seqNotes.delete(id);
33895
+ for (const [id, a] of this.admitted) if (a.channelId === channelId) this.admitted.delete(id);
33896
+ }
33897
+ append(channelId, e) {
33898
+ if (!this.opts.enabled) return;
33899
+ this.expireIfIdle(channelId);
33900
+ let ch = this.channels.get(channelId);
33901
+ if (ch === void 0) {
33902
+ ch = { entries: [], lastAppend: 0 };
33903
+ this.channels.set(channelId, ch);
33904
+ }
33905
+ const bufSeq = this.nextSeq++;
33906
+ this.lastSeq.set(channelId, bufSeq);
33907
+ ch.entries.push({ ...e, bufSeq });
33908
+ if (ch.entries.length > this.opts.cap) ch.entries.splice(0, ch.entries.length - this.opts.cap);
33909
+ ch.lastAppend = this.opts.now();
33910
+ }
33911
+ /** Reset (c): more than `idleMs` since the channel's last append clears it, checked on every read and append. */
33912
+ expireIfIdle(channelId) {
33913
+ const ch = this.channels.get(channelId);
33914
+ if (ch !== void 0 && this.opts.now() - ch.lastAppend > this.opts.idleMs) this.reset(channelId);
33915
+ }
33916
+ };
33917
+ function deliver(e) {
33918
+ return { role: e.role, text: e.text, seq: 0, ...e.origin ? { origin: e.origin } : {} };
33919
+ }
33920
+
33763
33921
  // src/voice/defer-intent.ts
33764
33922
  var NEEDS_RUNTIME = [
33765
33923
  // Current time / date / day.
@@ -34032,6 +34190,15 @@ import {
34032
34190
  writeFileSync
34033
34191
  } from "node:fs";
34034
34192
  import { basename as basename4, isAbsolute, join as join15 } from "node:path";
34193
+
34194
+ // src/grants/search-providers.ts
34195
+ var SEARCH_PROVIDERS = /* @__PURE__ */ new Set(["brave", "tavily", "parallel", "exa", "firecrawl"]);
34196
+ var OWNER_DM_SEARCH_DAILY_CAP_MAX = 2e3;
34197
+ function isSearchProvider(value) {
34198
+ return typeof value === "string" && SEARCH_PROVIDERS.has(value);
34199
+ }
34200
+
34201
+ // src/service/runtime-config-dir.ts
34035
34202
  var MEMORY_FILE = "MEMORY.md";
34036
34203
  var REAL_FS = {
34037
34204
  mkdir: (p, mode) => mkdirSync5(p, { recursive: true, mode }),
@@ -34214,10 +34381,14 @@ function readLocalReport(home, identityId) {
34214
34381
  const persona = raw && typeof raw === "object" && !Array.isArray(raw) ? Object.fromEntries(
34215
34382
  Object.entries(raw).filter(([, v]) => typeof v === "string")
34216
34383
  ) : {};
34384
+ const capRaw = doc.owner_dm_search_cap_effective;
34385
+ const ownerDmCap = typeof capRaw === "number" && Number.isInteger(capRaw) && capRaw >= 0 && capRaw <= OWNER_DM_SEARCH_DAILY_CAP_MAX ? capRaw : void 0;
34217
34386
  return {
34218
34387
  persona,
34219
34388
  has_search_key: hasSearchKey,
34220
34389
  has_llm_key: hasLlmKey,
34390
+ has_extract_provider: doc.has_extract_provider === true,
34391
+ ...ownerDmCap !== void 0 ? { owner_dm_search_cap_effective: ownerDmCap } : {},
34221
34392
  legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
34222
34393
  secret_paths: parseSecretPathsReport(doc.secret_paths)
34223
34394
  };
@@ -34416,7 +34587,8 @@ function loadOwnerGrantPin(opts) {
34416
34587
  // src/grants/manifest.ts
34417
34588
  var TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
34418
34589
  var MANIFEST_KNOWN_TOOLS = Object.freeze({
34419
- owner_dm: Object.freeze(["web_search", "fetch_url", "http_request", "sql_query"]),
34590
+ owner_dm: Object.freeze(["web_search", "web_extract", "fetch_url", "http_request", "sql_query"]),
34591
+ // web_extract: owner DM only, never team_room (spec §5.4, D5)
34420
34592
  team_room: Object.freeze(["web_search"])
34421
34593
  });
34422
34594
  function manifestSignedBytes(manifest) {
@@ -34491,7 +34663,11 @@ function parseConfig(v) {
34491
34663
  }
34492
34664
  if ("search" in v) {
34493
34665
  const sr = v.search;
34494
- if (!isPlainObject(sr) || !exactKeys(sr, ["provider"]) || sr.provider !== "brave" && sr.provider !== "tavily") return null;
34666
+ if (!isPlainObject(sr) || !exactKeys(sr, ["provider"], ["owner_dm_daily_cap"]) || !isSearchProvider(sr.provider)) return null;
34667
+ if ("owner_dm_daily_cap" in sr) {
34668
+ const cap = sr.owner_dm_daily_cap;
34669
+ if (typeof cap !== "number" || !Number.isSafeInteger(cap) || cap < 0 || cap > OWNER_DM_SEARCH_DAILY_CAP_MAX) return null;
34670
+ }
34495
34671
  }
34496
34672
  const mem = v.memory;
34497
34673
  if (!isPlainObject(mem) || !exactKeys(mem, ["mode"], ["recall_days"])) return null;
@@ -34501,7 +34677,12 @@ function parseConfig(v) {
34501
34677
  return Object.freeze({
34502
34678
  persona_sha256: v.persona_sha256,
34503
34679
  ...isPlainObject(v.llm) ? { llm: Object.freeze({ provider: v.llm.provider, model: v.llm.model }) } : {},
34504
- ...isPlainObject(v.search) ? { search: Object.freeze({ provider: v.search.provider }) } : {},
34680
+ ...isPlainObject(v.search) ? {
34681
+ search: Object.freeze({
34682
+ provider: v.search.provider,
34683
+ ..."owner_dm_daily_cap" in v.search ? { owner_dm_daily_cap: v.search.owner_dm_daily_cap } : {}
34684
+ })
34685
+ } : {},
34505
34686
  ...isStringArray(v.tools_allow) ? { tools_allow: Object.freeze([...v.tools_allow]) } : {},
34506
34687
  flags: Object.freeze([...v.flags]),
34507
34688
  memory: Object.freeze({ mode: mem.mode, ..."recall_days" in mem ? { recall_days: mem.recall_days } : {} }),
@@ -34732,7 +34913,20 @@ function bindTargets(raw, signed, equals) {
34732
34913
  }
34733
34914
  return { written, mismatches };
34734
34915
  }
34735
- function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }) {
34916
+ function signedManifestTools(manifest, audience, searchProvidersCapable) {
34917
+ return MANIFEST_KNOWN_TOOLS[audience].filter((t) => (manifest.grants[audience] ?? []).includes(t) && (t !== "web_extract" || searchProvidersCapable));
34918
+ }
34919
+ function manifestPolicyFor(manifest, switches, searchProvidersCapable) {
34920
+ return {
34921
+ v: 1,
34922
+ manifest_tools: [.../* @__PURE__ */ new Set([
34923
+ ...switches.ownerDm ? signedManifestTools(manifest, "owner_dm", searchProvidersCapable) : [],
34924
+ ...switches.room ? signedManifestTools(manifest, "team_room", searchProvidersCapable) : []
34925
+ ])].sort(),
34926
+ secret_path_mode: "enforce"
34927
+ };
34928
+ }
34929
+ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true, room: true }, searchProvidersCapable = false) {
34736
34930
  const mismatches = /* @__PURE__ */ new Set();
34737
34931
  const cfg = manifest.config;
34738
34932
  const out = {};
@@ -34761,6 +34955,7 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
34761
34955
  for (const name of Object.keys(files)) {
34762
34956
  if (name !== LLM_SOURCE && !PERSONA_FILES.includes(name)) mismatches.add("files");
34763
34957
  }
34958
+ const ownerDmTools = signedManifestTools(manifest, "owner_dm", searchProvidersCapable);
34764
34959
  const sj = isObj2(payload.skills_json) ? payload.skills_json : void 0;
34765
34960
  const eps = bindTargets(sj?.endpoints, manifest.endpoints, endpointEquals);
34766
34961
  const dbs = bindTargets(sj?.databases, manifest.databases, databaseEquals);
@@ -34778,14 +34973,20 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
34778
34973
  appliedSearch = searchBlock;
34779
34974
  } else {
34780
34975
  mismatches.add("search");
34781
- searchBlock = (prior.search?.provider ?? void 0) === cfg.search?.provider ? prior.search : void 0;
34976
+ const usablePrior = cfg.search !== void 0 && prior.search?.provider === cfg.search.provider ? prior.search : void 0;
34977
+ searchBlock = cfg.search === void 0 ? void 0 : usablePrior ?? Object.freeze({ provider: cfg.search.provider });
34782
34978
  }
34783
34979
  const skillsDoc = {
34784
34980
  endpoints: eps.written,
34785
34981
  databases: dbs.written,
34786
34982
  flags: [...cfg.flags],
34787
34983
  ...deny !== void 0 || cfg.tools_allow !== void 0 ? { tools: { ...deny !== void 0 ? { deny } : {}, ...cfg.tools_allow !== void 0 ? { allow: [...cfg.tools_allow] } : {} } } : {},
34788
- ...searchBlock ? { search: searchBlock } : {}
34984
+ ...searchBlock ? { search: searchBlock } : {},
34985
+ // D31: the marker the runtime keys D24 (iii) on; written under every manifest, true when config.search is signed.
34986
+ search_signed: cfg.search !== void 0,
34987
+ // D36, D39: the signed owner-DM cap, only when the owner-DM switch is on and the manifest grants an owner-DM tool;
34988
+ // otherwise none, so the runtime clamps a signed raise to the default. Never read from the payload.
34989
+ ...cfg.search?.owner_dm_daily_cap !== void 0 && switches.ownerDm && ownerDmTools.length > 0 ? { owner_dm_search_cap: cfg.search.owner_dm_daily_cap } : {}
34789
34990
  };
34790
34991
  out["skills.json"] = JSON.stringify(skillsDoc, null, 2) + "\n";
34791
34992
  const signedMemory = { mode: cfg.memory.mode, ...cfg.memory.recall_days !== void 0 ? { recall_days: cfg.memory.recall_days } : {} };
@@ -34816,12 +35017,7 @@ function bindRuntimeConfig(manifest, payload, prior, switches = { ownerDm: true,
34816
35017
  mismatches.add("legacy_assign");
34817
35018
  legacyDecisions = decisions.filter((d) => isObj2(d) && d.action === "delete");
34818
35019
  }
34819
- const signedTools = (a) => MANIFEST_KNOWN_TOOLS[a].filter((t) => (manifest.grants[a] ?? []).includes(t));
34820
- const policy = {
34821
- v: 1,
34822
- manifest_tools: [.../* @__PURE__ */ new Set([...switches.ownerDm ? signedTools("owner_dm") : [], ...switches.room ? signedTools("team_room") : []])].sort(),
34823
- secret_path_mode: "enforce"
34824
- };
35020
+ const policy = manifestPolicyFor(manifest, switches, searchProvidersCapable);
34825
35021
  return {
34826
35022
  mismatches: CONFIG_BINDING_FIELDS.filter((f) => mismatches.has(f)).sort(),
34827
35023
  targetMismatches: { endpoint: eps.mismatches, database: dbs.mismatches },
@@ -34960,6 +35156,8 @@ var TOOL_ROOM_CLASSES = Object.freeze({
34960
35156
  current_time: Object.freeze(["room_safe_read"]),
34961
35157
  echo: Object.freeze(["room_safe_read"]),
34962
35158
  web_search: Object.freeze(["owner_credential"]),
35159
+ web_extract: Object.freeze(["owner_credential", "egress"]),
35160
+ // D5: spends the owner's key and sends URLs out
34963
35161
  fetch_url: Object.freeze(["egress"]),
34964
35162
  sql_query: Object.freeze(["owner_credential"]),
34965
35163
  http_request: Object.freeze(["owner_credential", "egress", "side_effect"])
@@ -35073,6 +35271,10 @@ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
35073
35271
  var ROOM_DEFAULT_READ_GRANTS = Object.freeze([
35074
35272
  Object.freeze({ tool: "current_time", mode: "read" })
35075
35273
  ]);
35274
+ var RELAYED_VOICE_GRANTS = Object.freeze([
35275
+ Object.freeze({ tool: "current_time", mode: "read" }),
35276
+ Object.freeze({ tool: "web_search", mode: "read" })
35277
+ ]);
35076
35278
  var NO_TOOL_GRANTS = Object.freeze([]);
35077
35279
  var WS_CLOSE_SESSION_SUPERSEDED2 = 4409;
35078
35280
  var OWNER_REPLAY_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
@@ -35381,6 +35583,8 @@ function hexToBytes4(h) {
35381
35583
  return new Uint8Array(Buffer.from(h, "hex"));
35382
35584
  }
35383
35585
  var BOOT_DRAIN_WAIT_MS_DEFAULT = 6e4;
35586
+ var SEARCH_CAPABILITY_REFETCH_DEBOUNCE_MS = 1e3;
35587
+ var SEARCH_CAPABILITY_REFETCH_MAX_WAIT_MS = 1e4;
35384
35588
  var BootDrainRotationRefusedError = class extends Error {
35385
35589
  constructor() {
35386
35590
  super("key-package rotation refused: the boot drain has not settled");
@@ -35430,6 +35634,12 @@ var BridgeAgent = class _BridgeAgent {
35430
35634
  workspaceDir: this.cfg.memoryWorkspaceDir,
35431
35635
  factsCharBudget: this.cfg.memoryFactsCharBudget
35432
35636
  });
35637
+ this.callBuffer = new CallBuffer({
35638
+ cap: CALL_BUFFER_CAP,
35639
+ idleMs: CALL_BUFFER_IDLE_MS,
35640
+ now: () => Date.now(),
35641
+ enabled: (this.cfg.memoryProvider ?? "securecoms") !== "none"
35642
+ });
35433
35643
  this.provider = new MlsGroupKeyProvider({
35434
35644
  store: this.store,
35435
35645
  backend: this.backend,
@@ -35576,6 +35786,8 @@ var BridgeAgent = class _BridgeAgent {
35576
35786
  this.actionClock = deps.actionThrottleNow ?? Date.now;
35577
35787
  this.rng = deps.rng ?? Math.random;
35578
35788
  this.bootDrainWaitMs = deps.bootDrainWaitMs ?? BOOT_DRAIN_WAIT_MS_DEFAULT;
35789
+ this.searchCapabilityRefetchDebounceMs = deps.searchCapabilityRefetchDebounceMs ?? SEARCH_CAPABILITY_REFETCH_DEBOUNCE_MS;
35790
+ this.searchCapabilityRefetchMaxWaitMs = deps.searchCapabilityRefetchMaxWaitMs ?? SEARCH_CAPABILITY_REFETCH_MAX_WAIT_MS;
35579
35791
  this.replySleep = deps.replySleep ?? ((ms) => new Promise((resolve) => {
35580
35792
  const t = setTimeout(resolve, ms);
35581
35793
  t.unref?.();
@@ -36014,6 +36226,8 @@ var BridgeAgent = class _BridgeAgent {
36014
36226
  bootSweepTimer;
36015
36227
  rng;
36016
36228
  bootDrainWaitMs;
36229
+ searchCapabilityRefetchDebounceMs;
36230
+ searchCapabilityRefetchMaxWaitMs;
36017
36231
  /** Resolved (and cleared) each time an ingest cycle settles: see start(). */
36018
36232
  ingestSettledWaiters = [];
36019
36233
  /** The boot key-package publish, when start() had to defer it. Test seam. */
@@ -36249,6 +36463,11 @@ var BridgeAgent = class _BridgeAgent {
36249
36463
  startedAt = 0;
36250
36464
  store;
36251
36465
  memory;
36466
+ /**
36467
+ * Voice fast-path privacy, Task 4b (spec §2.1, OQ-1 ruled): the current calls' spoken turns, in process only. The
36468
+ * ONLY history a voice-scoped turn sees; read through voiceScopedHistory, once per turn, at ingest.
36469
+ */
36470
+ callBuffer;
36252
36471
  /**
36253
36472
  * Markdown memory 7a: the one owner of every note mutation and of the
36254
36473
  * delivery cache (spec §6.3, §7.2). One per store, so one per identity.
@@ -36459,9 +36678,14 @@ var BridgeAgent = class _BridgeAgent {
36459
36678
  }
36460
36679
  const skillsJson = payload.skills_json ?? {};
36461
36680
  if (version && inForce === null) {
36681
+ let asDelivered = skillsJson;
36682
+ if (skillsJson !== null && typeof skillsJson === "object" && !Array.isArray(skillsJson)) {
36683
+ const { owner_dm_search_cap: _cap, search_signed: _signed, ...rest } = skillsJson;
36684
+ asDelivered = rest;
36685
+ }
36462
36686
  this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, {
36463
36687
  ...payload.files ?? {},
36464
- "skills.json": `${JSON.stringify(skillsJson, null, 2)}
36688
+ "skills.json": `${JSON.stringify(asDelivered, null, 2)}
36465
36689
  `
36466
36690
  });
36467
36691
  }
@@ -36516,7 +36740,7 @@ var BridgeAgent = class _BridgeAgent {
36516
36740
  }
36517
36741
  let bound;
36518
36742
  try {
36519
- bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants });
36743
+ bound = this.grantWriters.bindRuntimeConfig(manifest, payload, { search: prior }, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable());
36520
36744
  } catch (e) {
36521
36745
  bound = null;
36522
36746
  return this.bindFailed(manifest, payload.memory, e);
@@ -36535,9 +36759,9 @@ var BridgeAgent = class _BridgeAgent {
36535
36759
  return true;
36536
36760
  }
36537
36761
  try {
36538
- this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, bound.policy);
36539
36762
  this.grantWriters.writeManagedFiles(this.home, this.cfg.identityId, bound.files);
36540
36763
  if (manifest.config.llm === void 0) this.grantWriters.removeManagedLlmSource(this.home, this.cfg.identityId);
36764
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, this.searchProvidersCapable()));
36541
36765
  } catch (e) {
36542
36766
  this.manifestBinding = null;
36543
36767
  throw e;
@@ -36590,6 +36814,7 @@ var BridgeAgent = class _BridgeAgent {
36590
36814
  /** The report half of refetchRuntimeConfigOnce (split out for the plan 4 Task 11 bind_failed path). */
36591
36815
  async postRuntimeConfigReport(base, version, skillsJson) {
36592
36816
  const local = readLocalReport(this.home, this.cfg.identityId);
36817
+ const reportedSearchProviders = this.searchProvidersCapable();
36593
36818
  const report = {
36594
36819
  applied_version: version,
36595
36820
  paths: checkConfiguredPaths(skillsJson),
@@ -36604,6 +36829,13 @@ var BridgeAgent = class _BridgeAgent {
36604
36829
  // to the backend. The backend needs it to generate a truthful tool
36605
36830
  // paragraph, so it rides along here.
36606
36831
  has_search_key: local?.has_search_key ?? false,
36832
+ // L-2 (spec §7.9): only the runtime knows whether an extract-capable provider key is readable; never derived
36833
+ // from has_search_key. An older runtime omits it: false.
36834
+ // Final review I-2 (controller ruling): AND the current search_providers_v2, so the web never shows extract while
36835
+ // the bridge withholds web_extract from the runtime.
36836
+ has_extract_provider: (local?.has_extract_provider ?? false) && reportedSearchProviders,
36837
+ // D39: the owner-DM daily cap the runtime enforces, passed through when it reported a valid one; never the signed value.
36838
+ ...local?.owner_dm_search_cap_effective !== void 0 ? { owner_dm_search_cap_effective: local.owner_dm_search_cap_effective } : {},
36607
36839
  // Same idea, for the BYOK LLM key file: only the runtime can see
36608
36840
  // whether one is configured in its own environment.
36609
36841
  has_llm_key: local?.has_llm_key ?? false,
@@ -36643,7 +36875,11 @@ var BridgeAgent = class _BridgeAgent {
36643
36875
  // Plan 4 Task 15 (OQ-5): sorted, unique enums; capability is the latest hello's class.
36644
36876
  config_binding_mismatches: [...this.configBindingMismatches],
36645
36877
  target_mismatches: ["database", "endpoint"].filter((k) => this.targetMismatches[k] > 0),
36646
- capability: this.lastConsumerCapability
36878
+ capability: this.lastConsumerCapability,
36879
+ // D28 (§11.1 step 3): a separate key, never a value in the capability enum. False with no runtime attached.
36880
+ search_providers_v2: reportedSearchProviders,
36881
+ // D19 item 4: content-free; the signed provider is one the attached runtime(s) cannot honour.
36882
+ search_provider_unsupported: this.searchProviderUnsupported(reportedSearchProviders)
36647
36883
  }
36648
36884
  };
36649
36885
  const reportedVerifyFailures = this.ownerVerifyFailures;
@@ -36664,6 +36900,7 @@ var BridgeAgent = class _BridgeAgent {
36664
36900
  return;
36665
36901
  }
36666
36902
  this.ownerVerifyFailures = Math.max(0, this.ownerVerifyFailures - reportedVerifyFailures);
36903
+ this.lastReportedSearchProviders = reportedSearchProviders;
36667
36904
  if (version) writeServiceLog2("info", "runtime_config.applied", { version });
36668
36905
  else writeServiceLog2("info", "runtime_config.legacy_memory_reported");
36669
36906
  }
@@ -36733,6 +36970,7 @@ var BridgeAgent = class _BridgeAgent {
36733
36970
  this.startedAt = Date.now();
36734
36971
  await this.restoreRuntimeInbox();
36735
36972
  await this.loadToolGrantState();
36973
+ this.recomputeManifestPolicy(false);
36736
36974
  try {
36737
36975
  this.store.pruneRoomSearchCap(utcDayOf(Date.now()));
36738
36976
  } catch (e) {
@@ -37877,6 +38115,9 @@ var BridgeAgent = class _BridgeAgent {
37877
38115
  const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
37878
38116
  const bridgeLine = this.unifiedOn && body.kind === "text" && isBridgeMemoryLine(body.text);
37879
38117
  if (opts.inReplyTo !== void 0 && this.isOwnerVerifyFailed(opts.inReplyTo)) return;
38118
+ if (body.kind === "text" && opts.callBufferReply === true && opts.inReplyTo !== void 0 && !this.spokenReplyIds.has(id) && !isBridgeMemoryLine(body.text) && this.callBuffer.isVoiceScopedId(opts.inReplyTo)) {
38119
+ this.callBuffer.appendAgent(channelId, body.text, "reply");
38120
+ }
37880
38121
  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";
37881
38122
  if (body.kind === "text" && !bridgeLine) void recordOutboundMemory(this.memory, channelId, body.text, id, modality, purgeEpoch, origin);
37882
38123
  if (this.unifiedOn && body.kind === "text" && !bridgeLine) this.spawnUnifiedReply(channelId, body.text, id, replyCapture ?? null, origin);
@@ -38647,6 +38888,27 @@ var BridgeAgent = class _BridgeAgent {
38647
38888
  mayReadHistoryForTurn(messageId, authorization) {
38648
38889
  return !this.isOwnerVerifyFailed(messageId) && this.mayReadOpenClawMemoryForTurn(authorization);
38649
38890
  }
38891
+ /**
38892
+ * Voice fast-path privacy (spec §2.1, OQ-1 ruled): the ONLY history source of a voice-scoped turn, the call buffer.
38893
+ * Never reads the memory provider, the store, the unified store or the 7b index. A-6: called once per turn, at ingest
38894
+ * (takeVoiceSnapshot), after the reset signals and before the turn's own append.
38895
+ */
38896
+ voiceScopedHistory(channelId, limit, upTo) {
38897
+ return this.callBuffer.recent(channelId, limit, upTo);
38898
+ }
38899
+ /**
38900
+ * Task 4b (A-6): the turn's snapshot, taken BEFORE its own text is appended (audit finding 2). Final fix I-1: a turn
38901
+ * already admitted to this call (a redelivered row) is snapshotted up to just before its own entry, as it was the
38902
+ * first time, so its snapshot never contains itself.
38903
+ */
38904
+ takeVoiceSnapshot(channelId, messageId) {
38905
+ const upTo = this.callBuffer.admittedAt(messageId);
38906
+ return {
38907
+ recent: this.voiceScopedHistory(channelId, _BridgeAgent.TURN_MEMORY_LIMIT, upTo),
38908
+ endSeq: upTo ?? this.callBuffer.seqAt(channelId),
38909
+ generation: this.callBuffer.generation(channelId)
38910
+ };
38911
+ }
38650
38912
  /** An inbound turn may receive OpenClaw memory only from an active owner DM. */
38651
38913
  mayReadOpenClawMemoryForTurn(authorization) {
38652
38914
  return this.cfg.memoryProvider !== "openclaw" || authorization.sender_membership === "active" && authorization.audience === "owner_dm";
@@ -38656,16 +38918,26 @@ var BridgeAgent = class _BridgeAgent {
38656
38918
  * stays owner-DM-only; every provider failure degrades to empty context and
38657
38919
  * never falls back to the runtime's retired plaintext JSON files.
38658
38920
  */
38659
- async memoryForTurn(channelId, messageId, authorization, limit = 50) {
38921
+ async memoryForTurn(channelId, messageId, authorization, limit = 50, voice) {
38660
38922
  if (!this.mayReadHistoryForTurn(messageId, authorization)) {
38661
38923
  return { recent: [], facts: [] };
38662
38924
  }
38925
+ if (voice) return { recent: this.voiceFrameTurns(voice.snapshot?.recent ?? []), facts: [] };
38663
38926
  try {
38664
38927
  return this.fenceMemory(channelId, await this.memory.context(channelId, limit), holdsManifestGrant(authorization));
38665
38928
  } catch {
38666
38929
  return { recent: [], facts: [] };
38667
38930
  }
38668
38931
  }
38932
+ /**
38933
+ * Task 6 (spec §2.2 A-1, N1): call-buffer entries as runtime-frame turns. Under the voice fence (a pin is loaded) the
38934
+ * fast model's own lines (origin "fast_path") are dropped, as fenceMemory drops them from store history; the fast
38935
+ * prompt keeps them. The internal origin is not carried into the frame; `seq` stays 0 (A-5).
38936
+ */
38937
+ voiceFrameTurns(entries) {
38938
+ const fenced = this.voiceFenceOn();
38939
+ return entries.filter((e) => !(fenced && e.origin === "fast_path")).map((e) => ({ seq: e.seq, role: e.role, text: e.text }));
38940
+ }
38669
38941
  /**
38670
38942
  * Owner grants plan 4 Task 16 (T-30, audit r2 H-1): the voice fence is on whenever a pin is loaded, on EVERY runtime
38671
38943
  * turn (manifest, fallback, greeting, relayed, constants-only alike). It never depends on a turn's grants.
@@ -38700,7 +38972,7 @@ var BridgeAgent = class _BridgeAgent {
38700
38972
  if (!strict && !ctx.recent.some((t) => t.role === "agent")) return ctx;
38701
38973
  const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
38702
38974
  const securecoms = (this.cfg.memoryProvider ?? "securecoms") === "securecoms";
38703
- const meta = securecoms ? this.store.memoryLineMeta(channelId, ctx.recent.filter((t) => strict || t.role === "agent").map((t) => t.seq)) : /* @__PURE__ */ new Map();
38975
+ const meta = securecoms ? this.store.memoryLineMeta(channelId, ctx.recent.filter((t) => (strict || t.role === "agent") && t.seq !== 0).map((t) => t.seq)) : /* @__PURE__ */ new Map();
38704
38976
  const fence = this.fenceContext(channelId);
38705
38977
  const recent = ctx.recent.filter((t) => {
38706
38978
  const m = meta.get(t.seq);
@@ -38719,7 +38991,7 @@ var BridgeAgent = class _BridgeAgent {
38719
38991
  if (recent.length === 0) return false;
38720
38992
  const ownerId = this.cfg.ownerGrantPin?.pin?.ownerIdentityId ?? "";
38721
38993
  const securecoms = (this.cfg.memoryProvider ?? "securecoms") === "securecoms";
38722
- const meta = securecoms ? this.store.memoryLineMeta(m.channel_id, recent.filter((t) => t.role !== "agent").map((t) => t.seq)) : /* @__PURE__ */ new Map();
38994
+ const meta = securecoms ? this.store.memoryLineMeta(m.channel_id, recent.filter((t) => t.role !== "agent" && t.seq !== 0).map((t) => t.seq)) : /* @__PURE__ */ new Map();
38723
38995
  return recent.some((t) => t.role !== "agent" && isRelayClassPeer({ peerId: t.peerId, modality: meta.get(t.seq)?.modality }, ownerId));
38724
38996
  }
38725
38997
  /**
@@ -39015,9 +39287,18 @@ var BridgeAgent = class _BridgeAgent {
39015
39287
  writeServiceLog2("warn", "memory.recall_prepare_failed", {}, e);
39016
39288
  }
39017
39289
  }
39290
+ /**
39291
+ * Voice fast-path privacy, Task 7 (spec §4): the frame-keyed delivery check. A voice-scoped frame gets no notes, no
39292
+ * recall and no unified items, whatever its authorization says. Keys on the frame's own marks, NEVER on the call
39293
+ * buffer's id set (a reset evicts ids; the staged frame must stay scoped).
39294
+ */
39295
+ frameIsVoiceScoped(m) {
39296
+ return m.voice_scoped === true || m.transcribed === true || m.speaker !== void 0;
39297
+ }
39018
39298
  /** ws flush (synchronous). Q1 to Q3 and Q6 again at delivery, on the ORIGINAL message; Q4 lives in withConsumerRecall. */
39019
39299
  recallForDelivery(m) {
39020
39300
  if (this.unifiedOn) return void 0;
39301
+ if (this.frameIsVoiceScoped(m)) return void 0;
39021
39302
  if (!this.recallProviderOn() || !this.notesReadable(m.authorization)) return void 0;
39022
39303
  return this.recall.deliverable(m.message_id, m.channel_id);
39023
39304
  }
@@ -39250,6 +39531,7 @@ var BridgeAgent = class _BridgeAgent {
39250
39531
  /** ws flush (synchronous). The predicate and the owner scope again at delivery, on the ORIGINAL message (H-4, Review Focus 3). */
39251
39532
  unifiedForDelivery(m) {
39252
39533
  if (!this.unifiedProviderOn()) return void 0;
39534
+ if (this.frameIsVoiceScoped(m)) return void 0;
39253
39535
  const a = m.authorization;
39254
39536
  if (!(a?.memory_write === true || a?.relay_speaker === "owner")) return void 0;
39255
39537
  const scopeId = this.ownerScopeNow(m.channel_id);
@@ -39485,6 +39767,7 @@ var BridgeAgent = class _BridgeAgent {
39485
39767
  notesForDelivery(m) {
39486
39768
  if (this.unifiedOn) return { generation: this.notes.appliedGeneration() };
39487
39769
  const generation = this.notes.appliedGeneration();
39770
+ if (this.frameIsVoiceScoped(m)) return { generation };
39488
39771
  if (!this.notesReadable(m.authorization)) return { generation };
39489
39772
  const d = this.notes.deliverable(m.channel_id);
39490
39773
  if (d.miss) {
@@ -39844,7 +40127,7 @@ var BridgeAgent = class _BridgeAgent {
39844
40127
  * deferring fast reply was actually published — F3).
39845
40128
  */
39846
40129
  async speakAndSettle(turn, plan, held, heldAfterAck, fastText, guidance, fastSpeaker, greeting) {
39847
- const outcome = plan.kind === "ack" ? await this.sendFastLine(turn.channelId, turn.messageId, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
40130
+ const outcome = plan.kind === "ack" ? await this.sendFastLine(turn, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
39848
40131
  if (outcome === "answered") await this.settleHeldRuntimeMessage(held, false);
39849
40132
  else await this.settleHeldRuntimeMessage(outcome === "deferred" ? heldAfterAck : held, true);
39850
40133
  }
@@ -39863,19 +40146,29 @@ var BridgeAgent = class _BridgeAgent {
39863
40146
  async settleFastReplies() {
39864
40147
  while (this.fastReplyChains.size) await Promise.all([...this.fastReplyChains.values()]);
39865
40148
  }
39866
- /** Send a line the voice fast path speaks: its memory record is "voice" (H1). */
39867
- async sendSpoken(channelId, text, messageId) {
40149
+ /**
40150
+ * Send a line the voice fast path speaks: its memory record is "voice" (H1).
40151
+ * Task 4b (spec §2.1 agent appends (1), A-1, A-2): once published, the line enters the call buffer as "fast_path",
40152
+ * unless the turn it answers failed owner verification (its id, or any fragment of a coalesced turn). A line answering
40153
+ * a turn the ingest did not find voice-scoped is not appended either (defense in depth: the fast path fires only on
40154
+ * voice-scoped turns, Task 5).
40155
+ */
40156
+ async sendSpoken(channelId, text, messageId, turn) {
39868
40157
  this.spokenReplyIds.add(messageId);
39869
40158
  try {
39870
40159
  await this.send(channelId, text, messageId);
39871
40160
  } finally {
39872
40161
  this.spokenReplyIds.delete(messageId);
39873
40162
  }
40163
+ if (turn.voiceScoped === true && turn.anyFragmentVerifyFailed !== true && !this.isOwnerVerifyFailed(turn.messageId)) {
40164
+ this.callBuffer.appendAgent(channelId, text, "fast_path");
40165
+ }
39874
40166
  }
39875
40167
  /** Speak a fixed acknowledgement line; failures are counted, never thrown. */
39876
- async sendFastLine(channelId, messageId, line) {
40168
+ async sendFastLine(turn, line) {
40169
+ const { channelId, messageId } = turn;
39877
40170
  try {
39878
- await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
40171
+ await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId), turn);
39879
40172
  this.fastPathSent++;
39880
40173
  return true;
39881
40174
  } catch (e) {
@@ -39889,8 +40182,8 @@ var BridgeAgent = class _BridgeAgent {
39889
40182
  * the fast model's reply — or, in `ack_only`, the greeting template — is
39890
40183
  * spoken, and the outcome decides whether the runtime answers next.
39891
40184
  *
39892
- * MUST NEVER throw: every failure path (memory lookup, the LLM call,
39893
- * normalization, or the send itself) is caught, counted in
40185
+ * MUST NEVER throw: every failure path (the LLM call, normalization,
40186
+ * or the send itself) is caught, counted in
39894
40187
  * `fastPathFailed`, and logged WITHOUT the turn text or the model's reply.
39895
40188
  * The `fastReplyId(...)` send id is deterministic, so a duplicate that
39896
40189
  * slipped past the in-memory dedupe (e.g. a restart) resolves to the same
@@ -39904,20 +40197,18 @@ var BridgeAgent = class _BridgeAgent {
39904
40197
  try {
39905
40198
  if ((this.cfg.voiceFastPathMode ?? "full") === "ack_only") {
39906
40199
  if (!isGreeting) return "failed";
39907
- const ok = await this.sendFastLine(m.channelId, m.messageId, ackOnlyGreeting(this.voiceAgentName(), speaker?.displayName));
40200
+ const ok = await this.sendFastLine(m, ackOnlyGreeting(this.voiceAgentName(), speaker?.displayName));
39908
40201
  return ok ? "answered" : "failed";
39909
40202
  }
39910
40203
  const fastLlm = this.fastLlm;
39911
40204
  if (!fastLlm || !this.cfg.voice) return "failed";
39912
- let memory;
39913
- if (!this.mayReadHistoryForTurn(m.messageId, m.authorization)) {
39914
- memory = { recent: [], facts: [] };
39915
- } else {
39916
- try {
39917
- memory = await this.memory.context(m.channelId, FAST_PATH_MEMORY_TURNS);
39918
- } catch {
39919
- memory = { recent: [], facts: [] };
39920
- }
40205
+ let memory = { recent: [], facts: [] };
40206
+ const snapshot = m.voiceSnapshot;
40207
+ if (m.voiceScoped === true && snapshot !== void 0 && m.anyFragmentVerifyFailed !== true && this.mayReadHistoryForTurn(m.messageId, m.authorization)) {
40208
+ memory = {
40209
+ recent: [...snapshot.recent, ...this.callBuffer.agentAfter(m.channelId, { seq: snapshot.endSeq, generation: snapshot.generation })].slice(-FAST_PATH_MEMORY_TURNS),
40210
+ facts: []
40211
+ };
39921
40212
  }
39922
40213
  const messages = buildFastMessages({
39923
40214
  persona: this.cfg.voice.persona,
@@ -39940,7 +40231,7 @@ var BridgeAgent = class _BridgeAgent {
39940
40231
  const refusal = !isGreeting && isRefusal(split3.reply);
39941
40232
  const defer = split3.defer || refusal;
39942
40233
  const reply = refusal ? deferAck(m.messageId) : split3.reply;
39943
- await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
40234
+ await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId), m);
39944
40235
  this.fastPathSent++;
39945
40236
  if (defer) this.fastPathDeferred++;
39946
40237
  if (refusal) this.fastPathRefusalDeferred++;
@@ -40004,6 +40295,15 @@ var BridgeAgent = class _BridgeAgent {
40004
40295
  async memoryAtRelease(message) {
40005
40296
  const prior = message.memory;
40006
40297
  if (!prior || !this.mayReadHistoryForTurn(message.message_id, message.authorization)) return prior;
40298
+ if (this.frameIsVoiceScoped(message)) {
40299
+ const tails = this.voiceCoalescedTails.get(message.message_id) ?? [];
40300
+ if (tails.some((id) => this.isOwnerVerifyFailed(id))) return prior;
40301
+ const noted = this.callBuffer.seqNoted(message.message_id);
40302
+ if (noted === void 0) return prior;
40303
+ const spoken2 = this.voiceFrameTurns(this.callBuffer.agentAfter(message.channel_id, noted));
40304
+ if (spoken2.length === 0) return prior;
40305
+ return { recent: [...prior.recent, ...spoken2].slice(-_BridgeAgent.TURN_MEMORY_LIMIT), facts: [] };
40306
+ }
40007
40307
  let fresh;
40008
40308
  try {
40009
40309
  fresh = await this.memory.context(message.channel_id, _BridgeAgent.TURN_MEMORY_LIMIT);
@@ -40515,9 +40815,9 @@ var BridgeAgent = class _BridgeAgent {
40515
40815
  });
40516
40816
  }
40517
40817
  /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
40518
- async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId) {
40818
+ async authorizeTurn(channelId, messageId, senderId, ownerVerdict, relaySpeakerId, voiceScoped) {
40519
40819
  const held = await this.heldPosition(channelId);
40520
- const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict });
40820
+ const auth = this.authorizationFor(channelId, senderId, relaySpeakerId, held.epoch, held.groupId, { ownerVerdict, voiceScoped });
40521
40821
  if (!(auth.audience === "team_room" && auth.tool_grants.length > 0)) return auth;
40522
40822
  if (this.roomGrantEpochs.size >= ROOM_GRANT_TRACK_MAX || held.epoch === null || held.groupId === null) {
40523
40823
  writeServiceLog2("warn", "room_grant.untracked");
@@ -40532,17 +40832,22 @@ var BridgeAgent = class _BridgeAgent {
40532
40832
  * guess. The returned object and grant array are frozen before they cross the
40533
40833
  * daemon boundary so one consumer cannot widen a later execution check.
40534
40834
  *
40535
- * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
40536
- * the kill switch is on, the channel is a DM, and EITHER
40835
+ * Grants (architect ruling 2026-10-02; P-5 and G-1, spec §9): an owner-DM
40836
+ * constant only when the kill switch is on, the channel is a DM, and EITHER
40537
40837
  * (a) audience is owner_dm AND the sender is the channel's pinned DM
40538
40838
  * counterpart (`channelOwner`, never "anomalous"). A server-marked
40539
40839
  * `is_agent_owner` on a member who is NOT the DM's single human
40540
40840
  * counterpart gets nothing; on the counterpart it is trusted (see
40541
- * LIMIT below); OR
40841
+ * LIMIT below). OWNER_DM_DEFAULT_READ_GRANTS on a typed turn;
40842
+ * RELAYED_VOICE_GRANTS and no memory_write when `opts.voiceScoped`
40843
+ * (G-1: the ingest predicate isVoiceScopedTurn, spec §1); OR
40542
40844
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
40543
40845
  * relayed `speaker_identity_id` (passed by the caller only for relay
40544
40846
  * turns) is that same pinned owner, whose roster row is the agent's
40545
- * owner. A speaker claim from any non-relay sender gets nothing.
40847
+ * owner: RELAYED_VOICE_GRANTS. A speaker claim from any non-relay
40848
+ * sender gets nothing.
40849
+ * RELAYED_VOICE_GRANTS is never manifest-eligible: effectiveToolGrants gives
40850
+ * the constant by reference, or the fallback when a good manifest is held.
40546
40851
  * Both need the roster fetched at `heldEpoch` (the agent's current MLS
40547
40852
  * epoch for the channel), or the DM marked epoch-fresh at this ingest
40548
40853
  * cycle's refresh (audit M-B4).
@@ -40586,9 +40891,11 @@ var BridgeAgent = class _BridgeAgent {
40586
40891
  const ownerDirect = audience === "owner_dm" && senderId === ownerId;
40587
40892
  const owner = roster?.get(ownerId);
40588
40893
  const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
40589
- if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
40894
+ if (ownerDirect) tool_grants = opts.voiceScoped === true ? RELAYED_VOICE_GRANTS : OWNER_DM_DEFAULT_READ_GRANTS;
40895
+ if (ownerRelayed) tool_grants = RELAYED_VOICE_GRANTS;
40590
40896
  if (ownerRelayed) relaySpeaker = "owner";
40591
- if (ownerDirect && relaySpeakerId === void 0 && (this.cfg.memoryProvider ?? "securecoms") === "securecoms" && // Final review M-4: the live payload flag too, so with notes off the
40897
+ if (ownerDirect && relaySpeakerId === void 0 && // G-1: never on a voice-scoped turn.
40898
+ opts.voiceScoped !== true && (this.cfg.memoryProvider ?? "securecoms") === "securecoms" && // Final review M-4: the live payload flag too, so with notes off the
40592
40899
  // runtime does not offer memory_note at all. Not memoryConfigConfirmed:
40593
40900
  // a turn the boot drain pulls before the boot refetch keeps its mark,
40594
40901
  // and the read and write gates (notesProviderOn) hold it until then.
@@ -40605,11 +40912,11 @@ var BridgeAgent = class _BridgeAgent {
40605
40912
  const binding = this.manifestBinding;
40606
40913
  const boundNow = binding !== null && binding.state === "bound" && binding.counter === this.toolGrantCounter;
40607
40914
  const signedTargets = boundNow ? binding.signedTargets : { endpoints: 0, databases: 0 };
40608
- if (tool_grants === OWNER_DM_DEFAULT_READ_GRANTS) {
40609
- const eligible = relaySpeakerId === void 0 && opts.ownerVerdict?.kind === "verified" && manifest?.owner_dm_channel_id === channelId && boundNow && this.manifestConsumer !== null;
40915
+ if (tool_grants === OWNER_DM_DEFAULT_READ_GRANTS || tool_grants === RELAYED_VOICE_GRANTS) {
40916
+ const eligible = relaySpeakerId === void 0 && opts.ownerVerdict?.kind === "verified" && manifest?.owner_dm_channel_id === channelId && boundNow && this.manifestConsumer !== null && tool_grants !== RELAYED_VOICE_GRANTS;
40610
40917
  const r = effectiveToolGrants({
40611
40918
  audience: "owner_dm",
40612
- base: OWNER_DM_DEFAULT_READ_GRANTS,
40919
+ base: tool_grants,
40613
40920
  manifest,
40614
40921
  eligible,
40615
40922
  manifestSwitchOn: this.ownerDmManifestGrants,
@@ -41331,6 +41638,7 @@ var BridgeAgent = class _BridgeAgent {
41331
41638
  const acked = [];
41332
41639
  for (const m of entries) {
41333
41640
  try {
41641
+ if (m.voiceMode === false) this.callBuffer.reset(m.channelId);
41334
41642
  const encId = m.envelope.enclave_id;
41335
41643
  if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
41336
41644
  this.channelEnclaveRecorded.add(m.channelId);
@@ -41488,6 +41796,7 @@ var BridgeAgent = class _BridgeAgent {
41488
41796
  continue;
41489
41797
  }
41490
41798
  if (typeof parsed.text === "string") {
41799
+ if (parsed.text.trim() === VOICE_CALL_START_MARKER) this.callBuffer.reset(m.channelId);
41491
41800
  const meta = this.freshMeta(m.channelId);
41492
41801
  const senderType = m.envelope.sender_type;
41493
41802
  const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
@@ -41529,23 +41838,39 @@ var BridgeAgent = class _BridgeAgent {
41529
41838
  const senderName = this.freshRoster(m.channelId)?.get(
41530
41839
  m.envelope.sender_id
41531
41840
  )?.name;
41841
+ const voiceScoped = isVoiceScopedTurn({
41842
+ speaker,
41843
+ transcribed: parsed.transcribed,
41844
+ senderIsRelay: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.is_relay === true,
41845
+ text: parsed.text
41846
+ });
41532
41847
  const ownerVerdict = await this.verifyOwnerTurn(m);
41533
41848
  const turnAuthorization = await this.authorizeTurn(
41534
41849
  m.channelId,
41535
41850
  m.messageId,
41536
41851
  m.envelope.sender_id,
41537
41852
  ownerVerdict,
41538
- speaker?.identityId
41853
+ speaker?.identityId,
41854
+ voiceScoped
41539
41855
  );
41540
41856
  await this.prepareNotesForTurn(m.channelId, turnAuthorization);
41541
- const runtimeMemory = this.memoryForTurn(m.channelId, m.messageId, turnAuthorization);
41542
41857
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
41543
41858
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
41544
- const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
41859
+ const fastPathEligible = Boolean(voiceScoped && m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
41545
41860
  const turnText = parsed.text;
41546
41861
  const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
41547
41862
  const windowEligible = voiceWindowEligible({ voiceMode: m.voiceMode, speaker, transcribed: parsed.transcribed, voiceGuidance, isCallStart });
41548
41863
  const coalescible = this.voiceCoalesceWindowMs > 0 && windowEligible;
41864
+ const callPeer = admitsToCallBuffer({ speaker, transcribed: parsed.transcribed });
41865
+ let voiceSnapshot;
41866
+ if (voiceScoped) {
41867
+ this.callBuffer.markVoiceScoped(m.channelId, m.messageId);
41868
+ if (!coalescible) {
41869
+ voiceSnapshot = this.takeVoiceSnapshot(m.channelId, m.messageId);
41870
+ if (callPeer && !this.isOwnerVerifyFailed(m.messageId)) this.callBuffer.appendPeer(m.channelId, parsed.text, m.messageId);
41871
+ }
41872
+ }
41873
+ const runtimeMemory = this.memoryForTurn(m.channelId, m.messageId, turnAuthorization, void 0, voiceScoped ? { snapshot: voiceSnapshot } : void 0);
41549
41874
  if (!coalescible) {
41550
41875
  const kind2 = m.voiceMode === true || parsed.transcribed === true ? "voice" : "text";
41551
41876
  if (this.unifiedOn) await this.prepareUnifiedForTurn({ channelId: m.channelId, messageId: m.messageId, text: parsed.text, authorization: turnAuthorization, kind: kind2 });
@@ -41561,7 +41886,9 @@ var BridgeAgent = class _BridgeAgent {
41561
41886
  const fastGreeting = isCallStart && fastPathFiring;
41562
41887
  const runtimeVoice = (ackSent) => m.voiceMode ? {
41563
41888
  voice: true,
41564
- guidance: ackSent ? `${voiceGuidance} ${FAST_PATH_SPOKEN_GUIDANCE}` : voiceGuidance
41889
+ // Task 6 (OQ-3): a voice-scoped frame's guidance ends with VOICE_CALL_CLAUSE, on all three frames
41890
+ // (held, coalesced after-ack, after-ack); a typed turn during a call keeps its guidance.
41891
+ guidance: `${ackSent ? `${voiceGuidance} ${FAST_PATH_SPOKEN_GUIDANCE}` : voiceGuidance}${voiceScoped ? ` ${VOICE_CALL_CLAUSE}` : ""}`
41565
41892
  } : {};
41566
41893
  const runtimeEntry = (ackSent, memory) => ({
41567
41894
  channel_id: m.channelId,
@@ -41582,6 +41909,7 @@ var BridgeAgent = class _BridgeAgent {
41582
41909
  // identify: the envelope's own claim (relay or on-device
41583
41910
  // transcript) or a relay-attributed turn.
41584
41911
  ...parsed.transcribed === true || speaker !== void 0 ? { transcribed: true } : {},
41912
+ ...voiceScoped ? { voice_scoped: true } : {},
41585
41913
  ...runtimeVoice(ackSent)
41586
41914
  });
41587
41915
  if (coalescible) {
@@ -41611,6 +41939,7 @@ var BridgeAgent = class _BridgeAgent {
41611
41939
  acked.push(m.queueId);
41612
41940
  continue;
41613
41941
  }
41942
+ const bufferGeneration = this.callBuffer.generation(m.channelId);
41614
41943
  const held = runtimeEntry(false, await runtimeMemory);
41615
41944
  if (!await this.stageHeldRuntimeMessage(held)) continue;
41616
41945
  this.noteVoiceCoalesced(m.messageId);
@@ -41621,7 +41950,8 @@ var BridgeAgent = class _BridgeAgent {
41621
41950
  closed: false,
41622
41951
  pending: Promise.resolve(),
41623
41952
  purgeEpoch: purgeEpochAtReceipt.get(m.channelId),
41624
- itemCapture: itemCaptureAtReceipt.get(m.channelId)
41953
+ itemCapture: itemCaptureAtReceipt.get(m.channelId),
41954
+ bufferGeneration
41625
41955
  };
41626
41956
  this.voiceCoalescing.set(windowKey, win);
41627
41957
  const openedAt = Date.now();
@@ -41637,9 +41967,18 @@ var BridgeAgent = class _BridgeAgent {
41637
41967
  const [firstId, ...tailIds] = win.ids;
41638
41968
  if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
41639
41969
  const text = win.parts.join(" ");
41970
+ const sameCall = this.callBuffer.generation(channelId) === win.bufferGeneration;
41971
+ const anyFragmentVerifyFailed = win.ids.some((id) => this.isOwnerVerifyFailed(id));
41972
+ let windowSnapshot;
41973
+ if (sameCall) {
41974
+ this.callBuffer.markVoiceScoped(channelId, firstId);
41975
+ windowSnapshot = this.takeVoiceSnapshot(channelId, firstId);
41976
+ }
41977
+ if (callPeer && sameCall && !anyFragmentVerifyFailed) this.callBuffer.appendPeer(channelId, text, firstId);
41640
41978
  if (this.unifiedOn) await this.prepareUnifiedForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
41641
41979
  else await this.prepareRecallForTurn({ channelId, messageId: firstId, text, authorization: turnAuthorization, kind: "voice" });
41642
- const merged = { ...held, text };
41980
+ const windowMemory = await this.memoryForTurn(channelId, firstId, turnAuthorization, void 0, { snapshot: anyFragmentVerifyFailed ? void 0 : windowSnapshot });
41981
+ const merged = { ...held, text, memory: windowMemory };
41643
41982
  if (this.unifiedOn) {
41644
41983
  const snap = win.itemCapture ?? { ...this.store.itemCapture(channelId), purgeEpoch: -1 };
41645
41984
  const work = this.tapUnifiedVoice(merged, { stamp: this.ingestGeneration.get(firstId), capture: { ...snap, purgeEpoch: win.purgeEpoch ?? -1 } });
@@ -41667,7 +42006,7 @@ var BridgeAgent = class _BridgeAgent {
41667
42006
  const release = this.reserveFastReply(channelId);
41668
42007
  try {
41669
42008
  await this.speakAndSettle(
41670
- { channelId, messageId: firstId, senderId, authorization: turnAuthorization },
42009
+ { channelId, messageId: firstId, senderId, authorization: turnAuthorization, voiceScoped: voiceScoped && sameCall, voiceSnapshot: windowSnapshot, anyFragmentVerifyFailed },
41671
42010
  plan,
41672
42011
  merged,
41673
42012
  { ...merged, ...runtimeVoice(true) },
@@ -41699,7 +42038,9 @@ var BridgeAgent = class _BridgeAgent {
41699
42038
  channelId: m.channelId,
41700
42039
  messageId: m.messageId,
41701
42040
  senderId: m.envelope.sender_id,
41702
- authorization: turnAuthorization
42041
+ authorization: turnAuthorization,
42042
+ voiceScoped,
42043
+ voiceSnapshot
41703
42044
  };
41704
42045
  const guidanceForFast = voiceGuidance;
41705
42046
  const greeting = fastGreeting;
@@ -42524,6 +42865,115 @@ ${channelId}`;
42524
42865
  manifestConsumerAttached() {
42525
42866
  return this.manifestConsumer !== null;
42526
42867
  }
42868
+ /**
42869
+ * D28 (B-4): every socket that said hello, with whether its latest hello declared search_providers_v2 on the
42870
+ * manifest_grants_v1 predicate (ws-server.ts). The agent value is the AND over all of them, absent (false) with
42871
+ * none: unlike manifestConsumer, never newest wins. The per-delivery filter judges each socket by its own hello.
42872
+ */
42873
+ searchProvidersSockets = /* @__PURE__ */ new Map();
42874
+ /**
42875
+ * The D28 value the files were last recomputed for; null until the first socket observation, so that observation
42876
+ * always recomputes (final review I-1), even when it is non-capable.
42877
+ */
42878
+ searchProvidersState = null;
42879
+ searchCapabilityRefetchTimer = null;
42880
+ /** When the current burst's first change happened (valid while the timer is set). */
42881
+ searchCapabilityBurstStart = 0;
42882
+ /** The D28 value in the last report posted (null until one is); a change needs a refetch to reach the web. */
42883
+ lastReportedSearchProviders = null;
42884
+ noteSearchProvidersSocket(token, declared) {
42885
+ this.searchProvidersSockets.set(token, declared);
42886
+ this.onSearchProvidersMaybeChanged();
42887
+ }
42888
+ clearSearchProvidersSocket(token) {
42889
+ if (this.searchProvidersSockets.delete(token)) this.onSearchProvidersMaybeChanged();
42890
+ }
42891
+ /** D28: every attached runtime socket declared search_providers_v2; no socket attached means absent (false). */
42892
+ searchProvidersCapable() {
42893
+ if (this.searchProvidersSockets.size === 0) return false;
42894
+ for (const v of this.searchProvidersSockets.values()) if (!v) return false;
42895
+ return true;
42896
+ }
42897
+ /**
42898
+ * D19 item 3 (B-6): the search provider signed in the stored verified manifest in force (a pin loaded and a good
42899
+ * manifest), never the server payload; undefined with no pin, no manifest, or no signed search block. The
42900
+ * per-delivery filter (consumer-grants.ts withSearchCapability) and the S-28 report reason both read this.
42901
+ */
42902
+ signedSearchProvider() {
42903
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
42904
+ return pin !== null ? this.toolGrantManifest?.config.search?.provider : void 0;
42905
+ }
42906
+ /** D19 item 4, D34: the in-force signed provider is not brave, and the attached runtime(s) lack the capability. */
42907
+ searchProviderUnsupported(capable) {
42908
+ if (capable) return false;
42909
+ const provider = this.signedSearchProvider();
42910
+ return provider !== void 0 && provider !== "brave";
42911
+ }
42912
+ /**
42913
+ * D35 (P-4): on a capability change, rewrite manifest-policy.json at once from the stored manifest, the kill
42914
+ * switches and the new capability, then a debounced refetch (one per burst) only when needed.
42915
+ */
42916
+ onSearchProvidersMaybeChanged() {
42917
+ const capable = this.searchProvidersCapable();
42918
+ const prev = this.searchProvidersState;
42919
+ if (capable === prev) return;
42920
+ this.searchProvidersState = capable;
42921
+ this.recomputeManifestPolicy(capable);
42922
+ if (this.closingDown) return;
42923
+ if (prev === null && !capable) return;
42924
+ const now = Date.now();
42925
+ if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
42926
+ else this.searchCapabilityBurstStart = now;
42927
+ const delay = Math.max(0, Math.min(this.searchCapabilityRefetchDebounceMs, this.searchCapabilityBurstStart + this.searchCapabilityRefetchMaxWaitMs - now));
42928
+ this.searchCapabilityRefetchTimer = setTimeout(() => {
42929
+ this.searchCapabilityRefetchTimer = null;
42930
+ if (this.searchCapabilityRefetchNeeded()) void this.refetchRuntimeConfig();
42931
+ }, delay);
42932
+ this.searchCapabilityRefetchTimer.unref?.();
42933
+ }
42934
+ /**
42935
+ * A refetch re-renders skills.json (owner_dm_search_cap counts web_extract only with the capability, D39) and posts
42936
+ * the report. Needed under a manifest in force, or when the reported value is stale. Never before the boot fetch.
42937
+ */
42938
+ searchCapabilityRefetchNeeded() {
42939
+ if (!this.bootConfigSettled || this.closingDown) return false;
42940
+ const inForce = (this.cfg.ownerGrantPin?.pin ?? null) !== null && this.toolGrantManifest !== null;
42941
+ return inForce || this.lastReportedSearchProviders !== this.searchProvidersCapable();
42942
+ }
42943
+ /**
42944
+ * D35: the local recompute. Widening (web_extract back in) only while the binding for the in-force manifest stands,
42945
+ * so a policy is never written ahead of its skills.json. Narrowing never waits: with no binding in force, a file that
42946
+ * lists web_extract loses it at once, so an older runtime never reads it as unreadable (which drops every grant).
42947
+ * Content-free; a write failure is logged and the debounced refetch tries again.
42948
+ */
42949
+ recomputeManifestPolicy(capable) {
42950
+ const pin = this.cfg.ownerGrantPin?.pin ?? null;
42951
+ const manifest = pin !== null ? this.toolGrantManifest : null;
42952
+ try {
42953
+ if (manifest !== null && this.manifestBinding !== null && this.manifestBinding.counter === manifest.counter) {
42954
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, manifestPolicyFor(manifest, { ownerDm: this.ownerDmManifestGrants, room: this.roomManifestGrants }, capable));
42955
+ return;
42956
+ }
42957
+ if (capable) return;
42958
+ const path = join17(runtimeConfigDir(this.home, this.cfg.identityId), MANIFEST_POLICY_FILE);
42959
+ if (!existsSync5(path)) return;
42960
+ let doc;
42961
+ try {
42962
+ doc = JSON.parse(readFileSync10(path, "utf8"));
42963
+ } catch {
42964
+ return;
42965
+ }
42966
+ const tools = doc?.manifest_tools;
42967
+ if (!Array.isArray(tools) || !tools.includes("web_extract")) return;
42968
+ this.grantWriters.writeManifestPolicy(this.home, this.cfg.identityId, {
42969
+ v: 1,
42970
+ manifest_tools: tools.filter((t) => typeof t === "string" && t !== "web_extract"),
42971
+ secret_path_mode: "enforce"
42972
+ });
42973
+ } catch (e) {
42974
+ writeServiceLog2("warn", "authz.search_capability", { state: ServiceLogEnum.of("policy_write_failed", ["policy_write_failed"]) }, e);
42975
+ }
42976
+ }
42527
42977
  noteConsumerCapability(state) {
42528
42978
  this.lastConsumerCapability = state;
42529
42979
  if (state !== "ok") {
@@ -42715,6 +43165,8 @@ ${channelId}`;
42715
43165
  this.supersedeRecheckTimers.clear();
42716
43166
  if (this.redeliveryTimer !== null) clearTimeout(this.redeliveryTimer);
42717
43167
  this.redeliveryTimer = null;
43168
+ if (this.searchCapabilityRefetchTimer !== null) clearTimeout(this.searchCapabilityRefetchTimer);
43169
+ this.searchCapabilityRefetchTimer = null;
42718
43170
  this.pingHandle?.close();
42719
43171
  if (this.safetyTimer) clearInterval(this.safetyTimer);
42720
43172
  if (this.runtimeConfigTimer) clearInterval(this.runtimeConfigTimer);
@@ -42824,7 +43276,7 @@ ${channelId}`;
42824
43276
  };
42825
43277
 
42826
43278
  // src/config.ts
42827
- import { readFileSync as readFileSync10, statSync as statSync5 } from "node:fs";
43279
+ import { readFileSync as readFileSync11, statSync as statSync5 } from "node:fs";
42828
43280
  import { dirname as dirname6, join as join18 } from "node:path";
42829
43281
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
42830
43282
  var CONFIG_ERROR_MESSAGE = {
@@ -42846,7 +43298,7 @@ var ConfigError = class extends Error {
42846
43298
  function loadConfig(opts) {
42847
43299
  let raw;
42848
43300
  try {
42849
- raw = readFileSync10(opts.credentialPath, "utf8");
43301
+ raw = readFileSync11(opts.credentialPath, "utf8");
42850
43302
  } catch (e) {
42851
43303
  const code = e?.code;
42852
43304
  throw new ConfigError(code === "ENOENT" ? "credential_not_found" : "credential_unreadable");
@@ -42976,11 +43428,11 @@ import { z as z3 } from "zod";
42976
43428
 
42977
43429
  // src/auth.ts
42978
43430
  import { randomBytes as randomBytes11, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
42979
- import { existsSync as existsSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync2 } from "node:fs";
43431
+ import { existsSync as existsSync6, readFileSync as readFileSync12, writeFileSync as writeFileSync2 } from "node:fs";
42980
43432
  function resolveToken(opts) {
42981
43433
  if (opts.token) return opts.token;
42982
43434
  const f = opts.tokenFile;
42983
- if (f && existsSync6(f)) return readFileSync11(f, "utf8").trim();
43435
+ if (f && existsSync6(f)) return readFileSync12(f, "utf8").trim();
42984
43436
  const tok = randomBytes11(32).toString("base64url");
42985
43437
  if (f) writeFileSync2(f, tok, { mode: 384 });
42986
43438
  return tok;
@@ -43076,6 +43528,7 @@ function withConsumerGrants(m, perToolConsumer) {
43076
43528
  return { ...m, authorization: Object.freeze({ ...rest, ...stripGrants ? { tool_grants: NO_GRANTS } : {} }) };
43077
43529
  }
43078
43530
  var MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
43531
+ var SEARCH_PROVIDERS_FEATURE = "search_providers_v2";
43079
43532
  var CONSTANT_TOOL_NAMES = Object.freeze({
43080
43533
  owner_dm: Object.freeze(["current_time", "web_search"]),
43081
43534
  team_room: Object.freeze(["current_time"])
@@ -43098,6 +43551,15 @@ function withHarnessGrants(m, withheld) {
43098
43551
  if (!a || a.tool_grants.length === 0) return m;
43099
43552
  return { ...m, authorization: Object.freeze({ ...a, tool_grants: NO_GRANTS }) };
43100
43553
  }
43554
+ function withSearchCapability(m, consumerHasFeature, signedProvider) {
43555
+ if (consumerHasFeature) return m;
43556
+ const a = m.authorization;
43557
+ if (!a) return m;
43558
+ const stripSearch = signedProvider !== void 0 && signedProvider !== "brave";
43559
+ const kept = a.tool_grants.filter((g) => g.tool !== "web_extract" && !(stripSearch && g.tool === "web_search"));
43560
+ if (kept.length === a.tool_grants.length) return m;
43561
+ return { ...m, authorization: Object.freeze({ ...a, tool_grants: kept.length === 0 ? NO_GRANTS : Object.freeze(kept) }) };
43562
+ }
43101
43563
 
43102
43564
  // src/http-server.ts
43103
43565
  function writeSse(res, msgs, onWriteError) {
@@ -43120,7 +43582,8 @@ data: ${JSON.stringify(msgs[i])}
43120
43582
  }
43121
43583
  function deliverSseBatch(res, batch, agent, onWriteError) {
43122
43584
  const withheld = agent.harnessGrantsWithheld();
43123
- const left3 = writeSse(res, batch.map((m) => withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false)), onWriteError);
43585
+ const provider = agent.signedSearchProvider();
43586
+ const left3 = writeSse(res, batch.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)), onWriteError);
43124
43587
  const leftIds = new Set(left3.map((message) => message.message_id));
43125
43588
  agent.ackRuntimeMessages(batch.filter((message) => !leftIds.has(message.message_id)).map((message) => message.message_id));
43126
43589
  if (left3.length) agent.requeueFront(batch.filter((message) => leftIds.has(message.message_id)));
@@ -43391,7 +43854,10 @@ function createHttpServer(agent, opts) {
43391
43854
  if (method === "GET" && path === "/v1/messages") {
43392
43855
  const channel = url.searchParams.get("channel_id") ?? void 0;
43393
43856
  const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
43394
- return json(res, 200, (await agent.drain(channel, wait)).map((m) => withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), agent.harnessGrantsWithheld()), false)));
43857
+ const drained = await agent.drain(channel, wait);
43858
+ const withheld = agent.harnessGrantsWithheld();
43859
+ const provider = agent.signedSearchProvider();
43860
+ return json(res, 200, drained.map((m) => withSearchCapability(withManifestCapability(withHarnessGrants(withConsumerGrants(m, false), withheld), false), false, provider)));
43395
43861
  }
43396
43862
  if (method === "GET" && path === "/v1/events") {
43397
43863
  const channel = url.searchParams.get("channel_id") ?? void 0;
@@ -43565,6 +44031,7 @@ function attachAgentWebSocket(server, agent, opts) {
43565
44031
  consumer: "unknown",
43566
44032
  perToolGrants: false,
43567
44033
  manifestGrants: false,
44034
+ searchProviders: false,
43568
44035
  // helloWaitMs 0 keeps today's synchronous initial flush exactly (the opt-out the existing tests use).
43569
44036
  deliveryOpen: helloWaitMs <= 0,
43570
44037
  deliveryToken: /* @__PURE__ */ new Map(),
@@ -43618,9 +44085,13 @@ function attachAgentWebSocket(server, agent, opts) {
43618
44085
  continue;
43619
44086
  }
43620
44087
  const out = withConsumerNotes(
43621
- withManifestCapability(
43622
- withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
43623
- session.manifestGrants
44088
+ withSearchCapability(
44089
+ withManifestCapability(
44090
+ withHarnessGrants(withConsumerGrants(batch[i], session.perToolGrants), !session.perToolGrants && agent.harnessGrantsWithheld()),
44091
+ session.manifestGrants
44092
+ ),
44093
+ session.searchProviders,
44094
+ agent.signedSearchProvider()
43624
44095
  ),
43625
44096
  batch[i],
43626
44097
  session,
@@ -43656,6 +44127,7 @@ function attachAgentWebSocket(server, agent, opts) {
43656
44127
  const cleanup = () => {
43657
44128
  if (cleaned) return;
43658
44129
  cleaned = true;
44130
+ session.closed = true;
43659
44131
  if (helloTimer) clearTimeout(helloTimer);
43660
44132
  agent.noteSessionClose();
43661
44133
  unsubscribe();
@@ -43676,6 +44148,7 @@ function attachAgentWebSocket(server, agent, opts) {
43676
44148
  if (session.harness) agent.clearHarnessReport(session.harness);
43677
44149
  if (session.perToolGrants) agent.clearPerToolConsumer(session);
43678
44150
  if (session.manifestGrants) agent.clearManifestConsumer(session);
44151
+ agent.clearSearchProvidersSocket(session);
43679
44152
  };
43680
44153
  current = { sock, cleanup, sweepStale };
43681
44154
  sock.on("close", cleanup);
@@ -43751,6 +44224,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
43751
44224
  return;
43752
44225
  }
43753
44226
  if (m.type === "hello") {
44227
+ if (session.closed) return;
43754
44228
  const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
43755
44229
  if (report) {
43756
44230
  session.harness = report;
@@ -43774,6 +44248,8 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
43774
44248
  agent.clearManifestConsumer(session);
43775
44249
  }
43776
44250
  agent.noteConsumerCapability(manifest ? "ok" : session.harnessClaimed ? "harness_consumer" : "missing");
44251
+ session.searchProviders = session.consumer === "runtime" && session.harnessClaimed !== true && perTool && Array.isArray(m.features) && m.features.includes(SEARCH_PROVIDERS_FEATURE);
44252
+ agent.noteSearchProvidersSocket(session, session.searchProviders);
43777
44253
  if (session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_NOTES_FEATURE)) {
43778
44254
  agent.noteNotesConsumer();
43779
44255
  }
@@ -43781,7 +44257,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
43781
44257
  if (session.recallConsumer) agent.noteRecallConsumer();
43782
44258
  session.unifiedConsumer = agent.unifiedFeatureOn() && session.consumer === "runtime" && session.harnessClaimed !== true && Array.isArray(m.features) && m.features.includes(MEMORY_UNIFIED_FEATURE);
43783
44259
  if (session.unifiedConsumer) agent.noteUnifiedConsumer();
43784
- sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE] });
44260
+ sendJson(sock, { type: "hello", features: ["nack", MEMORY_NOTES_FEATURE, MEMORY_RECALL_FEATURE, ...agent.unifiedFeatureOn() ? [MEMORY_UNIFIED_FEATURE] : [], MANIFEST_GRANTS_FEATURE, SEARCH_PROVIDERS_FEATURE] });
43785
44261
  if (!session.deliveryOpen) {
43786
44262
  session.deliveryOpen = true;
43787
44263
  flush();
@@ -43833,10 +44309,13 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
43833
44309
  sendJson(sock, wsErrorFrame("invalid_request"));
43834
44310
  return;
43835
44311
  }
44312
+ const record = typeof m.in_reply_to === "string" ? inflight.get(m.in_reply_to) : void 0;
44313
+ 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);
43836
44314
  const sendOpts = {
43837
44315
  agentTurn: isAgentTurn(agent, m),
43838
44316
  ...typeof m.in_reply_to === "string" ? { inReplyTo: m.in_reply_to } : {},
43839
- ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {}
44317
+ ...typeof m.idempotency_key === "string" ? { idempotencyKey: m.idempotency_key } : {},
44318
+ ...callBufferReply ? { callBufferReply: true } : {}
43840
44319
  };
43841
44320
  if (m.a2a && typeof m.a2a === "object") {
43842
44321
  const a = m.a2a;